forked from xiongyuxing/tiku-backend.net
feat: add coupon checkout support
This commit is contained in:
@@ -22,6 +22,8 @@ public sealed class CreateCommerceOrderDto
|
||||
[StringLength(100)]
|
||||
public string? CouponCode { get; set; }
|
||||
|
||||
public Guid? CouponRedemptionId { get; set; }
|
||||
|
||||
public CreateCommerceOrderCommand ToCommand()
|
||||
{
|
||||
return new CreateCommerceOrderCommand(
|
||||
@@ -30,7 +32,8 @@ public sealed class CreateCommerceOrderDto
|
||||
PayMethod,
|
||||
PayProvider,
|
||||
RegionId,
|
||||
CouponCode);
|
||||
CouponCode,
|
||||
CouponRedemptionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,3 +80,50 @@ public sealed class CreateCommercePaymentDto
|
||||
QuitUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CommerceCouponQueryDto
|
||||
{
|
||||
[Range(1, 100)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
public CommerceCouponQuery ToQuery()
|
||||
{
|
||||
return new CommerceCouponQuery(Limit, Status);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ClaimCommerceCouponDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string CouponCode { get; set; } = string.Empty;
|
||||
|
||||
public ClaimCommerceCouponCommand ToCommand()
|
||||
{
|
||||
return new ClaimCommerceCouponCommand(CouponCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CheckCommerceCouponDto
|
||||
{
|
||||
[StringLength(100)]
|
||||
public string? CouponCode { get; set; }
|
||||
|
||||
public Guid? CouponRedemptionId { get; set; }
|
||||
|
||||
[Required]
|
||||
public Guid PlanId { get; set; }
|
||||
|
||||
[Range(1, 99)]
|
||||
public int Quantity { get; set; } = 1;
|
||||
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
public CheckCommerceCouponCommand ToCommand()
|
||||
{
|
||||
return new CheckCommerceCouponCommand(CouponCode, CouponRedemptionId, PlanId, Quantity, RegionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,45 @@ public sealed class CommerceController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("coupons/claim")]
|
||||
[EndpointSummary("领取优惠券")]
|
||||
[ProducesResponseType<CommerceCouponItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceCouponItem>> ClaimCoupon(
|
||||
ClaimCommerceCouponDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.ClaimCouponAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("coupons")]
|
||||
[EndpointSummary("查询当前用户优惠券")]
|
||||
[ProducesResponseType<CommerceCouponList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceCouponList>> Coupons(
|
||||
[FromQuery] CommerceCouponQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.GetCouponsAsync(
|
||||
ResolveActor(),
|
||||
query.ToQuery(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("coupons/check")]
|
||||
[EndpointSummary("校验优惠券并预览订单金额")]
|
||||
[ProducesResponseType<CommerceCouponCheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceCouponCheckResult>> CheckCoupon(
|
||||
CheckCommerceCouponDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.CheckCouponAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("payments/notify/wechat-pay")]
|
||||
[EndpointSummary("微信支付回调")]
|
||||
|
||||
@@ -347,9 +347,11 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
{
|
||||
"commerce_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"tenant_admin_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"order_not_found" or "svip_plan_not_found" or "region_not_found" or "activation_code_not_found" => StatusCodes.Status404NotFound,
|
||||
"order_not_found" or "svip_plan_not_found" or "region_not_found" or "activation_code_not_found" or
|
||||
"coupon_not_found" or "coupon_redemption_not_found" => StatusCodes.Status404NotFound,
|
||||
"payment_provider_not_configured" or "payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable,
|
||||
"order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" => StatusCodes.Status409Conflict,
|
||||
"order_status_invalid" or "activation_code_used" or "payment_amount_mismatch" or
|
||||
"coupon_usage_limit_reached" or "coupon_redemption_status_invalid" => StatusCodes.Status409Conflict,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ public sealed record CreateCommerceOrderCommand(
|
||||
string? PayMethod,
|
||||
string? PayProvider,
|
||||
Guid? RegionId,
|
||||
string? CouponCode);
|
||||
string? CouponCode,
|
||||
Guid? CouponRedemptionId);
|
||||
|
||||
public sealed record CommerceOrderQuery(int? Limit = null, string? Status = null);
|
||||
|
||||
@@ -64,6 +65,41 @@ public sealed record CurrentEntitlementItem(
|
||||
string Status,
|
||||
int? DaysLeft);
|
||||
|
||||
public sealed record CommerceCouponQuery(int? Limit = null, string? Status = null);
|
||||
|
||||
public sealed record ClaimCommerceCouponCommand(string CouponCode);
|
||||
|
||||
public sealed record CheckCommerceCouponCommand(string? CouponCode, Guid? CouponRedemptionId, Guid PlanId, int Quantity, Guid? RegionId);
|
||||
|
||||
public sealed record CommerceCouponItem(
|
||||
Guid Id,
|
||||
Guid? CouponId,
|
||||
string CouponCode,
|
||||
string Status,
|
||||
Guid? PlanId,
|
||||
Guid? RegionId,
|
||||
string? DiscountType,
|
||||
decimal? DiscountValue,
|
||||
int? DiscountPreviewCents,
|
||||
string? DiscountPreview,
|
||||
DateTimeOffset? ValidFrom,
|
||||
DateTimeOffset? ValidTo,
|
||||
DateTimeOffset? ClaimedAt,
|
||||
DateTimeOffset? UsedAt);
|
||||
|
||||
public sealed record CommerceCouponList(IReadOnlyCollection<CommerceCouponItem> Items);
|
||||
|
||||
public sealed record CommerceCouponCheckResult(
|
||||
bool Valid,
|
||||
string? ReasonCode,
|
||||
Guid? CouponId,
|
||||
Guid? CouponRedemptionId,
|
||||
string? CouponCode,
|
||||
int OriginalAmountCents,
|
||||
int DiscountCents,
|
||||
int PayableAmountCents,
|
||||
string PayablePrice);
|
||||
|
||||
public sealed record PaymentNotificationProcessResult(
|
||||
string Provider,
|
||||
string EventId,
|
||||
@@ -97,6 +133,21 @@ public interface ICommerceService
|
||||
CommerceActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CommerceCouponItem> ClaimCouponAsync(
|
||||
CommerceActor actor,
|
||||
ClaimCommerceCouponCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CommerceCouponList> GetCouponsAsync(
|
||||
CommerceActor actor,
|
||||
CommerceCouponQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CommerceCouponCheckResult> CheckCouponAsync(
|
||||
CommerceActor actor,
|
||||
CheckCommerceCouponCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PaymentNotificationProcessResult> ProcessPaymentNotificationAsync(
|
||||
Guid tenantId,
|
||||
string provider,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -164,6 +164,87 @@ public sealed class CommerceEndpointTests
|
||||
Assert.Equal(firstPayment!.Id, secondPayment!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Student_can_claim_and_check_coupon()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||
var seed = await SeedLoginUserAsync(factory);
|
||||
await factory.SeedAsync(new Coupon
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Code = "SAVE5",
|
||||
PlanId = seed.PlanId,
|
||||
DiscountType = DiscountType.Fixed,
|
||||
DiscountValue = 5,
|
||||
MaxUses = 10
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var claimResponse = await client.PostAsJsonAsync(
|
||||
"/api/commerce/coupons/claim",
|
||||
new ClaimCommerceCouponDto { CouponCode = "SAVE5" });
|
||||
var coupons = await (await client.GetAsync("/api/commerce/coupons"))
|
||||
.Content
|
||||
.ReadFromJsonAsync<CommerceCouponList>();
|
||||
var check = await (await client.PostAsJsonAsync(
|
||||
"/api/commerce/coupons/check",
|
||||
new CheckCommerceCouponDto
|
||||
{
|
||||
CouponCode = "SAVE5",
|
||||
PlanId = seed.PlanId,
|
||||
Quantity = 1
|
||||
}))
|
||||
.Content
|
||||
.ReadFromJsonAsync<CommerceCouponCheckResult>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, claimResponse.StatusCode);
|
||||
Assert.Contains(coupons!.Items, item => item.CouponCode == "SAVE5" && item.Status == "Claimed");
|
||||
Assert.True(check!.Valid);
|
||||
Assert.Equal(500, check.DiscountCents);
|
||||
Assert.Equal(499, check.PayableAmountCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Coupon_can_make_order_zero_amount_and_grant_entitlement()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||
var seed = await SeedLoginUserAsync(factory);
|
||||
await factory.SeedAsync(new Coupon
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Code = "FREE",
|
||||
PlanId = seed.PlanId,
|
||||
DiscountType = DiscountType.Fixed,
|
||||
DiscountValue = 9.99m,
|
||||
MaxUses = 10
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/commerce/orders",
|
||||
new CreateCommerceOrderDto
|
||||
{
|
||||
PlanId = seed.PlanId,
|
||||
Quantity = 1,
|
||||
CouponCode = "FREE"
|
||||
});
|
||||
var order = await response.Content.ReadFromJsonAsync<CommerceOrderItem>();
|
||||
var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current"))
|
||||
.Content
|
||||
.ReadFromJsonAsync<CurrentEntitlementItem>();
|
||||
var coupons = await (await client.GetAsync("/api/commerce/coupons?status=used"))
|
||||
.Content
|
||||
.ReadFromJsonAsync<CommerceCouponList>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(0, order!.AmountCents);
|
||||
Assert.Equal("Paid", order.Status);
|
||||
Assert.True(entitlement!.IsActive);
|
||||
Assert.Contains(coupons!.Items, item => item.CouponCode == "FREE" && item.DiscountPreviewCents == 999);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Payment_notification_marks_order_paid_once()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user