feat: add coupon checkout support

This commit is contained in:
xiong
2026-07-26 20:04:24 +08:00
parent a4621df728
commit 7139da766a
6 changed files with 673 additions and 9 deletions

View File

@@ -14,6 +14,8 @@ public sealed class CommerceService(
TikuDbContext dbContext,
IPaymentProviderGateway paymentGateway) : ICommerceService
{
private sealed record CouponApplication(Coupon Coupon, CouponRedemption Redemption, int DiscountCents);
public async Task<CommerceOrderItem> CreateOrderAsync(
CommerceActor actor,
CreateCommerceOrderCommand command,
@@ -34,7 +36,9 @@ public sealed class CommerceService(
cancellationToken)
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
if (plan.CouponOnly && string.IsNullOrWhiteSpace(command.CouponCode))
if (plan.CouponOnly &&
string.IsNullOrWhiteSpace(command.CouponCode) &&
!command.CouponRedemptionId.HasValue)
{
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
}
@@ -49,6 +53,22 @@ public sealed class CommerceService(
}
}
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,
@@ -59,7 +79,7 @@ public sealed class CommerceService(
Status = OrderStatus.Pending,
ProductType = "svip",
ProductName = plan.Name,
AmountCents = checked(plan.PriceCents * command.Quantity),
AmountCents = amountCents,
PayMethod = NormalizeMethod(command.PayMethod),
PayProvider = NormalizeProvider(command.PayProvider),
Days = checked(plan.Days * command.Quantity),
@@ -67,9 +87,15 @@ public sealed class CommerceService(
{
source = "student_checkout",
command.Quantity,
command.CouponCode,
requestedCouponCode = command.CouponCode,
requestedCouponRedemptionId = command.CouponRedemptionId,
plan.PriceCents,
plan.OriginalPriceCents
plan.OriginalPriceCents,
originalAmountCents,
discountCents = coupon?.DiscountCents ?? 0,
couponId = coupon?.Coupon.Id,
couponCode = coupon?.Coupon.Code,
couponRedemptionId = coupon?.Redemption.Id
})
};
dbContext.Orders.Add(order);
@@ -82,7 +108,7 @@ public sealed class CommerceService(
Name = plan.Name,
Quantity = command.Quantity,
UnitAmountCents = plan.PriceCents,
TotalAmountCents = order.AmountCents,
TotalAmountCents = originalAmountCents,
Metadata = JsonSerializer.SerializeToElement(new
{
plan.Days,
@@ -91,7 +117,45 @@ public sealed class CommerceService(
})
});
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);
}
@@ -248,6 +312,135 @@ public sealed class CommerceService(
: null);
}
public async Task<CommerceCouponItem> ClaimCouponAsync(
CommerceActor actor,
ClaimCommerceCouponCommand command,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var coupon = await FindCouponByCodeAsync(actor.TenantId, command.CouponCode, cancellationToken);
ValidateCouponClaimable(coupon, null);
var existing = await dbContext.CouponRedemptions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.CouponId == coupon.Id)
.OrderByDescending(item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (existing is not null)
{
return ToCouponItem(coupon, existing, null);
}
if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
{
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
}
var redemption = new CouponRedemption
{
TenantId = actor.TenantId,
CouponId = coupon.Id,
UserId = actor.UserId,
PlanId = coupon.PlanId,
CouponCode = coupon.Code,
Status = CouponRedemptionStatus.Claimed,
Source = "student_claim",
ClaimedAt = DateTimeOffset.UtcNow
};
coupon.UsedCount += 1;
dbContext.CouponRedemptions.Add(redemption);
await dbContext.SaveChangesAsync(cancellationToken);
return ToCouponItem(coupon, redemption, null);
}
public async Task<CommerceCouponList> GetCouponsAsync(
CommerceActor actor,
CommerceCouponQuery query,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var redemptions = dbContext.CouponRedemptions
.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (!string.IsNullOrWhiteSpace(query.Status))
{
redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status));
}
var items = await redemptions
.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 100))
.ToArrayAsync(cancellationToken);
var couponIds = items
.Where(item => item.CouponId.HasValue)
.Select(item => item.CouponId!.Value)
.Distinct()
.ToArray();
var coupons = await dbContext.Coupons
.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && couponIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, cancellationToken);
return new CommerceCouponList(
items.Select(item =>
{
coupons.TryGetValue(item.CouponId ?? Guid.Empty, out var coupon);
return ToCouponItem(coupon, item, item.DiscountAppliedCents);
}).ToArray());
}
public async Task<CommerceCouponCheckResult> CheckCouponAsync(
CommerceActor actor,
CheckCommerceCouponCommand command,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
if (command.Quantity is < 1 or > 99)
{
throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity");
}
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");
var originalAmountCents = checked(plan.PriceCents * command.Quantity);
try
{
var coupon = await ResolveCouponForCheckAsync(actor, command, plan, originalAmountCents, cancellationToken);
return new CommerceCouponCheckResult(
true,
null,
coupon.Coupon.Id,
coupon.Redemption.Id,
coupon.Coupon.Code,
originalAmountCents,
coupon.DiscountCents,
Math.Max(0, originalAmountCents - coupon.DiscountCents),
FormatCny(Math.Max(0, originalAmountCents - coupon.DiscountCents)));
}
catch (CommerceException exception) when (exception.Code.StartsWith("coupon_", StringComparison.Ordinal))
{
return new CommerceCouponCheckResult(
false,
exception.Code,
null,
command.CouponRedemptionId,
command.CouponCode,
originalAmountCents,
0,
originalAmountCents,
FormatCny(originalAmountCents));
}
}
public async Task<PaymentNotificationProcessResult> ProcessPaymentNotificationAsync(
Guid tenantId,
string provider,
@@ -442,6 +635,217 @@ public sealed class CommerceService(
});
}
private async Task<CouponApplication?> ApplyCouponForOrderAsync(
CommerceActor actor,
CreateCommerceOrderCommand command,
SvipPlan plan,
int originalAmountCents,
CancellationToken cancellationToken)
{
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode))
{
return null;
}
var coupon = command.CouponRedemptionId.HasValue
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
: await ResolveOrClaimCouponByCodeAsync(actor, command.CouponCode, cancellationToken);
ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId);
return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) };
}
private async Task<CouponApplication> ResolveCouponForCheckAsync(
CommerceActor actor,
CheckCommerceCouponCommand command,
SvipPlan plan,
int originalAmountCents,
CancellationToken cancellationToken)
{
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode))
{
throw new CommerceException("Coupon code or redemption id is required.", "coupon_required");
}
var coupon = command.CouponRedemptionId.HasValue
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
: await ResolveCouponByCodeForCheckAsync(actor, command.CouponCode, cancellationToken);
ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId);
return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) };
}
private async Task<CouponApplication> ResolveOrClaimCouponByCodeAsync(
CommerceActor actor,
string? couponCode,
CancellationToken cancellationToken)
{
var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken);
ValidateCouponClaimable(coupon, null);
var existing = await dbContext.CouponRedemptions
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.CouponId == coupon.Id)
.OrderByDescending(item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (existing is not null)
{
return new CouponApplication(coupon, existing, 0);
}
if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
{
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
}
var redemption = new CouponRedemption
{
TenantId = actor.TenantId,
CouponId = coupon.Id,
UserId = actor.UserId,
PlanId = coupon.PlanId,
CouponCode = coupon.Code,
Status = CouponRedemptionStatus.Claimed,
Source = "checkout_claim",
ClaimedAt = DateTimeOffset.UtcNow
};
coupon.UsedCount += 1;
dbContext.CouponRedemptions.Add(redemption);
return new CouponApplication(coupon, redemption, 0);
}
private async Task<CouponApplication> ResolveCouponByCodeForCheckAsync(
CommerceActor actor,
string? couponCode,
CancellationToken cancellationToken)
{
var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken);
var redemption = await dbContext.CouponRedemptions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.CouponId == coupon.Id)
.OrderByDescending(item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (redemption is not null)
{
return new CouponApplication(coupon, redemption, 0);
}
ValidateCouponClaimable(coupon, null);
return new CouponApplication(
coupon,
new CouponRedemption
{
Id = Guid.Empty,
TenantId = actor.TenantId,
CouponId = coupon.Id,
UserId = actor.UserId,
PlanId = coupon.PlanId,
CouponCode = coupon.Code,
Status = CouponRedemptionStatus.Claimed
},
0);
}
private async Task<CouponApplication> ResolveCouponByRedemptionAsync(
CommerceActor actor,
Guid couponRedemptionId,
CancellationToken cancellationToken)
{
var redemption = await dbContext.CouponRedemptions
.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Id == couponRedemptionId,
cancellationToken)
?? throw new CommerceException("Coupon redemption was not found.", "coupon_redemption_not_found");
if (redemption.CouponId is null)
{
throw new CommerceException("Coupon redemption is not linked to a coupon.", "coupon_redemption_invalid");
}
var coupon = await dbContext.Coupons
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value, cancellationToken)
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
return new CouponApplication(coupon, redemption, 0);
}
private async Task<Coupon> FindCouponByCodeAsync(
Guid tenantId,
string? couponCode,
CancellationToken cancellationToken)
{
var code = NormalizeRequired(couponCode, "coupon_code_required");
return await dbContext.Coupons
.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken)
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
}
private static void ValidateCouponClaimable(Coupon coupon, CouponRedemption? redemption)
{
var now = DateTimeOffset.UtcNow;
if (coupon.ValidFrom is not null && coupon.ValidFrom > now ||
coupon.ValidTo is not null && coupon.ValidTo <= now)
{
throw new CommerceException("Coupon is expired or not started.", "coupon_inactive");
}
if (redemption is null && coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
{
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
}
}
private static void ValidateCouponUsable(
Coupon coupon,
CouponRedemption redemption,
SvipPlan plan,
Guid? regionId)
{
ValidateCouponClaimable(coupon, redemption);
if (redemption.Status != CouponRedemptionStatus.Claimed)
{
throw new CommerceException("Coupon redemption is not claimable.", "coupon_redemption_status_invalid");
}
if (coupon.PlanId.HasValue && coupon.PlanId != plan.Id ||
redemption.PlanId.HasValue && redemption.PlanId != plan.Id)
{
throw new CommerceException("Coupon is not applicable to this plan.", "coupon_plan_not_applicable");
}
if (redemption.RegionId.HasValue &&
regionId.HasValue &&
redemption.RegionId != regionId)
{
throw new CommerceException("Coupon is not applicable to this region.", "coupon_region_not_applicable");
}
}
private static int CalculateDiscountCents(Coupon coupon, int originalAmountCents)
{
var discount = coupon.DiscountType switch
{
DiscountType.Fixed => (int)Math.Round((coupon.DiscountValue ?? 0) * 100, MidpointRounding.AwayFromZero),
DiscountType.Percent => (int)Math.Round(
originalAmountCents * PercentFactor(coupon.DiscountValue ?? 0),
MidpointRounding.AwayFromZero),
_ => 0
};
return Math.Clamp(discount, 0, originalAmountCents);
}
private static decimal PercentFactor(decimal value)
{
if (value <= 0)
{
return 0;
}
return value <= 1 ? value : value / 100;
}
private async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken)
{
var exists = await dbContext.TenantMemberships.AnyAsync(item =>
@@ -508,6 +912,28 @@ public sealed class CommerceService(
payment.RawPayload);
}
private static CommerceCouponItem ToCouponItem(
Coupon? coupon,
CouponRedemption redemption,
int? discountPreviewCents)
{
return new CommerceCouponItem(
redemption.Id,
redemption.CouponId,
redemption.CouponCode ?? coupon?.Code ?? string.Empty,
redemption.Status.ToString(),
redemption.PlanId ?? coupon?.PlanId,
redemption.RegionId,
coupon?.DiscountType?.ToString(),
coupon?.DiscountValue,
discountPreviewCents,
discountPreviewCents.HasValue ? FormatCny(discountPreviewCents.Value) : null,
coupon?.ValidFrom,
coupon?.ValidTo,
redemption.ClaimedAt,
redemption.UsedAt);
}
private static OrderStatus ParseOrderStatus(string? status)
{
return Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
@@ -515,11 +941,26 @@ public sealed class CommerceService(
: throw new CommerceException("Order status is invalid.", "invalid_order_status");
}
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status)
{
return Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Coupon status is invalid.", "invalid_coupon_status");
}
private static string NormalizeEnum(string? value) =>
string.Concat((value ?? string.Empty).Split(
['_', '-', ' '],
StringSplitOptions.RemoveEmptyEntries));
private static string NormalizeRequired(string? value, string code)
{
var trimmed = value?.Trim();
return !string.IsNullOrWhiteSpace(trimmed)
? trimmed
: throw new CommerceException("Required commerce value is missing.", code);
}
private static string NormalizeProvider(string? provider)
{
var normalized = (provider ?? PaymentProviders.Manual)