diff --git a/Tiku.Api/Contracts/CommerceDtos.cs b/Tiku.Api/Contracts/CommerceDtos.cs index 07aa865..51c3ae2 100644 --- a/Tiku.Api/Contracts/CommerceDtos.cs +++ b/Tiku.Api/Contracts/CommerceDtos.cs @@ -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); + } +} diff --git a/Tiku.Api/Controllers/CommerceController.cs b/Tiku.Api/Controllers/CommerceController.cs index 132393d..466179a 100644 --- a/Tiku.Api/Controllers/CommerceController.cs +++ b/Tiku.Api/Controllers/CommerceController.cs @@ -81,6 +81,45 @@ public sealed class CommerceController( cancellationToken)); } + [HttpPost("coupons/claim")] + [EndpointSummary("领取优惠券")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ClaimCoupon( + ClaimCommerceCouponDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceService.ClaimCouponAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("coupons")] + [EndpointSummary("查询当前用户优惠券")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Coupons( + [FromQuery] CommerceCouponQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceService.GetCouponsAsync( + ResolveActor(), + query.ToQuery(), + cancellationToken)); + } + + [HttpPost("coupons/check")] + [EndpointSummary("校验优惠券并预览订单金额")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CheckCoupon( + CheckCommerceCouponDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceService.CheckCouponAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + [AllowAnonymous] [HttpPost("payments/notify/wechat-pay")] [EndpointSummary("微信支付回调")] diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 1e89780..0c7e6f2 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -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 }; } diff --git a/Tiku.Application/Commerce/CommerceModels.cs b/Tiku.Application/Commerce/CommerceModels.cs index 3587587..fb597b8 100644 --- a/Tiku.Application/Commerce/CommerceModels.cs +++ b/Tiku.Application/Commerce/CommerceModels.cs @@ -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 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 ClaimCouponAsync( + CommerceActor actor, + ClaimCommerceCouponCommand command, + CancellationToken cancellationToken = default); + + Task GetCouponsAsync( + CommerceActor actor, + CommerceCouponQuery query, + CancellationToken cancellationToken = default); + + Task CheckCouponAsync( + CommerceActor actor, + CheckCommerceCouponCommand command, + CancellationToken cancellationToken = default); + Task ProcessPaymentNotificationAsync( Guid tenantId, string provider, diff --git a/Tiku.Infrastructure/Commerce/CommerceService.cs b/Tiku.Infrastructure/Commerce/CommerceService.cs index 6a1811d..e2d23ea 100644 --- a/Tiku.Infrastructure/Commerce/CommerceService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceService.cs @@ -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 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 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 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 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 ProcessPaymentNotificationAsync( Guid tenantId, string provider, @@ -442,6 +635,217 @@ public sealed class CommerceService( }); } + private async Task 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 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 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 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 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 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(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(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) diff --git a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs index f971d8d..a737073 100644 --- a/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs @@ -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(); + var check = await (await client.PostAsJsonAsync( + "/api/commerce/coupons/check", + new CheckCommerceCouponDto + { + CouponCode = "SAVE5", + PlanId = seed.PlanId, + Quantity = 1 + })) + .Content + .ReadFromJsonAsync(); + + 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(); + var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current")) + .Content + .ReadFromJsonAsync(); + var coupons = await (await client.GetAsync("/api/commerce/coupons?status=used")) + .Content + .ReadFromJsonAsync(); + + 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() {