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

@@ -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);
}
}

View File

@@ -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("微信支付回调")]

View File

@@ -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
};
}

View File

@@ -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,

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)

View File

@@ -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()
{