170 lines
6.4 KiB
C#
170 lines
6.4 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Commerce;
|
|
using Tiku.Domain.Commerce;
|
|
|
|
namespace Tiku.Infrastructure.Commerce;
|
|
|
|
public sealed partial class CommerceService
|
|
{
|
|
public async Task<CommerceOrderItem> CreateOrderAsync(
|
|
CommerceActor actor,
|
|
CreateCommerceOrderCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (command.Quantity is < 1 or > 99)
|
|
throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity");
|
|
|
|
await AssertActiveMemberAsync(actor, cancellationToken);
|
|
var plan = await dbContext.SvipPlans
|
|
.AsNoTracking()
|
|
.SingleOrDefaultAsync(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.Id == command.PlanId &&
|
|
item.IsActive,
|
|
cancellationToken)
|
|
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
|
|
|
|
if (plan.CouponOnly &&
|
|
string.IsNullOrWhiteSpace(command.CouponCode) &&
|
|
!command.CouponRedemptionId.HasValue)
|
|
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
|
|
|
|
if (command.RegionId.HasValue)
|
|
{
|
|
var regionExists = await dbContext.Regions
|
|
.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value,
|
|
cancellationToken);
|
|
if (!regionExists) throw new CommerceException("Region was not found.", "region_not_found");
|
|
}
|
|
|
|
await using var transaction = dbContext.Database.IsRelational()
|
|
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
|
: null;
|
|
var originalAmountCents = checked(plan.PriceCents * command.Quantity);
|
|
var coupon = await ApplyCouponForOrderAsync(
|
|
actor,
|
|
command,
|
|
plan,
|
|
originalAmountCents,
|
|
cancellationToken);
|
|
if (plan.CouponOnly && coupon is null)
|
|
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
|
|
|
|
var amountCents = Math.Max(0, originalAmountCents - (coupon?.DiscountCents ?? 0));
|
|
var order = new Order
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
PlanId = plan.Id,
|
|
RegionId = command.RegionId ?? plan.RegionId,
|
|
OrderNo = GenerateOrderNo(),
|
|
Status = OrderStatus.Pending,
|
|
ProductType = "svip",
|
|
ProductName = plan.Name,
|
|
AmountCents = amountCents,
|
|
PayMethod = NormalizeMethod(command.PayMethod),
|
|
PayProvider = NormalizeProvider(command.PayProvider),
|
|
Days = checked(plan.Days * command.Quantity),
|
|
RawPayload = JsonSerializer.SerializeToElement(new
|
|
{
|
|
source = "student_checkout",
|
|
command.Quantity,
|
|
requestedCouponCode = command.CouponCode,
|
|
requestedCouponRedemptionId = command.CouponRedemptionId,
|
|
plan.PriceCents,
|
|
plan.OriginalPriceCents,
|
|
originalAmountCents,
|
|
discountCents = coupon?.DiscountCents ?? 0,
|
|
couponId = coupon?.Coupon.Id,
|
|
couponCode = coupon?.Coupon.Code,
|
|
couponRedemptionId = coupon?.Redemption.Id
|
|
})
|
|
};
|
|
dbContext.Orders.Add(order);
|
|
dbContext.OrderItems.Add(new OrderItem
|
|
{
|
|
TenantId = actor.TenantId,
|
|
OrderId = order.Id,
|
|
ItemType = "svip_plan",
|
|
ItemId = plan.Id,
|
|
Name = plan.Name,
|
|
Quantity = command.Quantity,
|
|
UnitAmountCents = plan.PriceCents,
|
|
TotalAmountCents = originalAmountCents,
|
|
Metadata = JsonSerializer.SerializeToElement(new
|
|
{
|
|
plan.Days,
|
|
plan.RegionId,
|
|
plan.VpProductId
|
|
})
|
|
});
|
|
|
|
if (coupon is not null)
|
|
{
|
|
coupon.Redemption.Status = CouponRedemptionStatus.Used;
|
|
coupon.Redemption.OrderId = order.Id;
|
|
coupon.Redemption.DiscountAppliedCents = coupon.DiscountCents;
|
|
coupon.Redemption.UsedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
if (amountCents == 0)
|
|
{
|
|
var payment = new Payment
|
|
{
|
|
TenantId = actor.TenantId,
|
|
OrderId = order.Id,
|
|
Provider = "manual",
|
|
Method = "zero_amount",
|
|
Status = PaymentStatus.Pending,
|
|
AmountCents = 0
|
|
};
|
|
dbContext.Payments.Add(payment);
|
|
await MarkPaidAsync(
|
|
actor,
|
|
order,
|
|
payment,
|
|
$"zero-{order.OrderNo}",
|
|
order.RawPayload,
|
|
"zero_amount_paid",
|
|
$"zero-{order.OrderNo}",
|
|
true,
|
|
DateTimeOffset.UtcNow,
|
|
cancellationToken);
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
|
|
|
return ToOrderItem(order);
|
|
}
|
|
|
|
public async Task<CommerceOrderList> GetOrdersAsync(
|
|
CommerceActor actor,
|
|
CommerceOrderQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertActiveMemberAsync(actor, cancellationToken);
|
|
var orders = dbContext.Orders.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
|
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
|
|
|
|
var items = await orders
|
|
.OrderByDescending(item => item.CreatedAt)
|
|
.Take(Math.Clamp(query.Limit ?? 20, 1, 100))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
return new CommerceOrderList(items.Select(ToOrderItem).ToArray());
|
|
}
|
|
|
|
public async Task<CommerceOrderItem> GetOrderAsync(
|
|
CommerceActor actor,
|
|
string orderNo,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertActiveMemberAsync(actor, cancellationToken);
|
|
var order = await FindActorOrderAsync(actor, orderNo, cancellationToken);
|
|
return ToOrderItem(order);
|
|
}
|
|
} |