using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Commerce; 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, CancellationToken cancellationToken = default) { if (command.Quantity is < 1 or > 99) { throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity"); } await AssertActiveMemberAsync(actor, cancellationToken); var plan = await dbContext.SvipPlans .AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.PlanId && item.IsActive, cancellationToken) ?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found"); if (plan.CouponOnly && string.IsNullOrWhiteSpace(command.CouponCode) && !command.CouponRedemptionId.HasValue) { throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required"); } if (command.RegionId.HasValue) { var regionExists = await dbContext.Regions .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken); if (!regionExists) { throw new CommerceException("Region was not found.", "region_not_found"); } } await using var transaction = dbContext.Database.IsRelational() ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; var originalAmountCents = checked(plan.PriceCents * command.Quantity); var coupon = await ApplyCouponForOrderAsync( actor, command, plan, originalAmountCents, cancellationToken); if (plan.CouponOnly && coupon is null) { throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required"); } var amountCents = Math.Max(0, originalAmountCents - (coupon?.DiscountCents ?? 0)); var order = new Order { TenantId = actor.TenantId, UserId = actor.UserId, PlanId = plan.Id, RegionId = command.RegionId ?? plan.RegionId, OrderNo = GenerateOrderNo(), Status = OrderStatus.Pending, ProductType = "svip", ProductName = plan.Name, AmountCents = amountCents, PayMethod = NormalizeMethod(command.PayMethod), PayProvider = NormalizeProvider(command.PayProvider), Days = checked(plan.Days * command.Quantity), RawPayload = JsonSerializer.SerializeToElement(new { source = "student_checkout", command.Quantity, requestedCouponCode = command.CouponCode, requestedCouponRedemptionId = command.CouponRedemptionId, plan.PriceCents, plan.OriginalPriceCents, originalAmountCents, discountCents = coupon?.DiscountCents ?? 0, couponId = coupon?.Coupon.Id, couponCode = coupon?.Coupon.Code, couponRedemptionId = coupon?.Redemption.Id }) }; dbContext.Orders.Add(order); dbContext.OrderItems.Add(new OrderItem { TenantId = actor.TenantId, OrderId = order.Id, ItemType = "svip_plan", ItemId = plan.Id, Name = plan.Name, Quantity = command.Quantity, UnitAmountCents = plan.PriceCents, TotalAmountCents = originalAmountCents, Metadata = JsonSerializer.SerializeToElement(new { plan.Days, plan.RegionId, plan.VpProductId }) }); if (coupon is not null) { coupon.Redemption.Status = CouponRedemptionStatus.Used; coupon.Redemption.OrderId = order.Id; coupon.Redemption.DiscountAppliedCents = coupon.DiscountCents; coupon.Redemption.UsedAt = DateTimeOffset.UtcNow; } if (amountCents == 0) { var payment = new Payment { TenantId = actor.TenantId, OrderId = order.Id, Provider = "manual", Method = "zero_amount", Status = PaymentStatus.Pending, AmountCents = 0 }; dbContext.Payments.Add(payment); await MarkPaidAsync( actor, order, payment, $"zero-{order.OrderNo}", order.RawPayload, "zero_amount_paid", $"zero-{order.OrderNo}", true, DateTimeOffset.UtcNow, cancellationToken); } await dbContext.SaveChangesAsync(cancellationToken); if (transaction is not null) { await transaction.CommitAsync(cancellationToken); } return ToOrderItem(order); } public async Task GetOrdersAsync( CommerceActor actor, CommerceOrderQuery query, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); var orders = dbContext.Orders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(query.Status)) { orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status)); } var items = await orders .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 20, 1, 100)) .ToArrayAsync(cancellationToken); return new CommerceOrderList(items.Select(ToOrderItem).ToArray()); } public async Task GetOrderAsync( CommerceActor actor, string orderNo, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); var order = await FindActorOrderAsync(actor, orderNo, cancellationToken); return ToOrderItem(order); } public async Task CreatePaymentAsync( CommerceActor actor, CreateCommercePaymentCommand command, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); var order = await FindActorOrderAsync(actor, command.OrderNo, cancellationToken); if (order.Status != OrderStatus.Pending) { throw new CommerceException("Only pending orders can create payments.", "order_status_invalid"); } var provider = NormalizeProvider(command.Provider); var method = NormalizeMethod(command.Method); var payment = await dbContext.Payments .Where(item => item.TenantId == actor.TenantId && item.OrderId == order.Id && item.Provider == provider && item.Status == PaymentStatus.Pending) .OrderByDescending(item => item.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (payment is null) { payment = new Payment { TenantId = actor.TenantId, OrderId = order.Id, Provider = provider, Method = method, Status = PaymentStatus.Pending, AmountCents = order.AmountCents }; dbContext.Payments.Add(payment); } var result = await paymentGateway.CreatePaymentAsync( provider, new CreatePaymentProviderRequest( actor.TenantId, order.OrderNo, order.ProductName ?? order.OrderNo, order.AmountCents, method, command.OpenId, command.ReturnUrl, command.QuitUrl, $"/api/commerce/payments/notify/{provider.Replace("_", "-", StringComparison.Ordinal)}?tenantId={actor.TenantId}", JsonSerializer.SerializeToElement(new { order.Id, actor.UserId })), cancellationToken); payment.Method = result.Method; payment.RawPayload = result.RawPayload; if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo)) { payment.ProviderTradeNo = result.ProviderTradeNo; } if (IsPaid(result.Status)) { await MarkPaidAsync( actor, order, payment, result.ProviderTradeNo, result.RawPayload, "payment_paid", result.ProviderTradeNo, true, null, cancellationToken); } else { dbContext.PaymentEvents.Add(new PaymentEvent { TenantId = actor.TenantId, PaymentId = payment.Id, Provider = provider, EventType = "payment_created", Payload = result.RawPayload }); } await dbContext.SaveChangesAsync(cancellationToken); return ToPaymentItem(payment, order.OrderNo, result.ClientPayload); } public async Task GetCurrentEntitlementAsync( CommerceActor actor, CancellationToken cancellationToken = default) { await AssertActiveMemberAsync(actor, cancellationToken); var now = DateTimeOffset.UtcNow; var entitlement = await dbContext.Entitlements .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.EntitlementType == "svip" && item.Status == EntitlementStatus.Active && (item.ExpiresAt == null || item.ExpiresAt > now)) .OrderByDescending(item => item.ExpiresAt) .FirstOrDefaultAsync(cancellationToken); if (entitlement is null) { return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null); } return new CurrentEntitlementItem( true, entitlement.EntitlementType, entitlement.StartsAt, entitlement.ExpiresAt, entitlement.Status.ToString(), entitlement.ExpiresAt.HasValue ? Math.Max(0, (int)Math.Ceiling((entitlement.ExpiresAt.Value - now).TotalDays)) : 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, IReadOnlyDictionary headers, string rawBody, JsonElement body, CancellationToken cancellationToken = default) { var normalizedProvider = NormalizeProvider(provider); var notification = await paymentGateway.ParsePaymentNotificationAsync( normalizedProvider, new PaymentNotificationRequest( tenantId, normalizedProvider, headers, rawBody, body), cancellationToken); if (!notification.SignatureValid) { throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid"); } var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync( item => item.Provider == normalizedProvider && item.EventId == notification.EventId && item.ProcessedAt != null, cancellationToken); if (alreadyProcessed) { return new PaymentNotificationProcessResult( normalizedProvider, notification.EventId, notification.OrderNo, "processed", true); } var order = await dbContext.Orders .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.OrderNo == notification.OrderNo, cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found"); if (order.UserId is null) { throw new CommerceException("Order does not belong to a user.", "order_user_missing"); } if (order.AmountCents != notification.AmountCents) { dbContext.PaymentEvents.Add(new PaymentEvent { TenantId = tenantId, Provider = normalizedProvider, EventType = notification.EventType, EventId = notification.EventId, SignatureValid = true, Payload = notification.RawPayload, Error = "payment_amount_mismatch" }); await dbContext.SaveChangesAsync(cancellationToken); throw new CommerceException("Payment amount does not match order amount.", "payment_amount_mismatch"); } var payment = await dbContext.Payments .Where(item => item.TenantId == tenantId && item.OrderId == order.Id && item.Provider == normalizedProvider) .OrderByDescending(item => item.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (payment is null) { payment = new Payment { TenantId = tenantId, OrderId = order.Id, Provider = normalizedProvider, Method = order.PayMethod, Status = PaymentStatus.Pending, AmountCents = order.AmountCents }; dbContext.Payments.Add(payment); } if (notification.Paid && order.Status == OrderStatus.Pending) { await MarkPaidAsync( new CommerceActor(tenantId, order.UserId.Value), order, payment, notification.ProviderTradeNo, notification.RawPayload, notification.EventType, notification.EventId, notification.SignatureValid, notification.PaidAt, cancellationToken); } else { dbContext.PaymentEvents.Add(new PaymentEvent { TenantId = tenantId, PaymentId = payment.Id, Provider = normalizedProvider, EventType = notification.EventType, EventId = notification.EventId, SignatureValid = notification.SignatureValid, Payload = notification.RawPayload, ProcessedAt = DateTimeOffset.UtcNow }); } await dbContext.SaveChangesAsync(cancellationToken); return new PaymentNotificationProcessResult( normalizedProvider, notification.EventId, notification.OrderNo, "processed", false); } private async Task MarkPaidAsync( CommerceActor actor, Order order, Payment payment, string? providerTradeNo, JsonElement rawPayload, string eventType, string? eventId, bool signatureValid, DateTimeOffset? paidAtOverride, CancellationToken cancellationToken) { var paidAt = paidAtOverride ?? DateTimeOffset.UtcNow; payment.Status = PaymentStatus.Paid; payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo; payment.PaidAt = paidAt; order.Status = OrderStatus.Paid; order.TradeNo = payment.ProviderTradeNo; order.PaidAt = paidAt; var days = Math.Max(order.Days ?? 0, 0); var current = await dbContext.Entitlements .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.EntitlementType == "svip" && item.Status == EntitlementStatus.Active) .OrderByDescending(item => item.ExpiresAt) .FirstOrDefaultAsync(cancellationToken); if (current is null) { dbContext.Entitlements.Add(new Entitlement { TenantId = actor.TenantId, UserId = actor.UserId, EntitlementType = "svip", ScopeType = EntitlementScopeType.Tenant, SourceType = "order", SourceId = order.Id, StartsAt = paidAt, ExpiresAt = days > 0 ? paidAt.AddDays(days) : null, Status = EntitlementStatus.Active, Metadata = rawPayload }); } else if (days > 0) { var baseAt = current.ExpiresAt.HasValue && current.ExpiresAt > paidAt ? current.ExpiresAt.Value : paidAt; current.ExpiresAt = baseAt.AddDays(days); current.Metadata = rawPayload; } dbContext.PaymentEvents.Add(new PaymentEvent { TenantId = actor.TenantId, PaymentId = payment.Id, Provider = payment.Provider, EventType = eventType, EventId = eventId, SignatureValid = signatureValid, Payload = rawPayload, ProcessedAt = paidAt }); } 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 => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == MembershipStatus.Active, cancellationToken); if (!exists) { throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied"); } } private async Task FindActorOrderAsync( CommerceActor actor, string orderNo, CancellationToken cancellationToken) { var trimmed = orderNo.Trim(); return await dbContext.Orders .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.OrderNo == trimmed, cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found"); } private static CommerceOrderItem ToOrderItem(Order order) { return new CommerceOrderItem( order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId, order.ProductType, order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider, order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload); } private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo, JsonElement clientPayload) { return new CommercePaymentItem( payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, payment.Status.ToString(), payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, clientPayload, 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) ? parsed : 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) .Trim() .ToLowerInvariant() .Replace("-", "_", StringComparison.Ordinal); return normalized switch { "" => PaymentProviders.Manual, "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay, "ali_pay" => PaymentProviders.Alipay, PaymentProviders.WechatPay or PaymentProviders.Alipay or PaymentProviders.Manual => normalized, _ => throw new CommerceException("Payment provider is invalid.", "invalid_payment_provider") }; } private static string NormalizeMethod(string? method) { var normalized = (method ?? "manual").Trim().ToLowerInvariant(); return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized; } private static bool IsPaid(string status) => string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase); private static string GenerateOrderNo() { Span bytes = stackalloc byte[4]; RandomNumberGenerator.Fill(bytes); return $"TK{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}"; } private static string FormatCny(int cents) => (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); }