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.Tenancy; namespace Tiku.Infrastructure.Commerce; internal abstract partial class CommerceServiceBase { protected 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 commercePersistence.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) { commercePersistence.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; } commercePersistence.PaymentEvents.Add(new PaymentEvent { TenantId = actor.TenantId, PaymentId = payment.Id, Provider = payment.Provider, EventType = eventType, EventId = eventId, SignatureValid = signatureValid, Payload = rawPayload, ProcessedAt = paidAt }); } protected 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) }; } protected 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) }; } protected async Task ResolveOrClaimCouponByCodeAsync( CommerceActor actor, string? couponCode, CancellationToken cancellationToken) { var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken); ValidateCouponClaimable(coupon, null); var existing = await commercePersistence.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; commercePersistence.CouponRedemptions.Add(redemption); return new CouponApplication(coupon, redemption, 0); } protected async Task ResolveCouponByCodeForCheckAsync( CommerceActor actor, string? couponCode, CancellationToken cancellationToken) { var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken); var redemption = await commercePersistence.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); } protected async Task ResolveCouponByRedemptionAsync( CommerceActor actor, Guid couponRedemptionId, CancellationToken cancellationToken) { var redemption = await commercePersistence.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 commercePersistence.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); } protected async Task FindCouponByCodeAsync( Guid tenantId, string? couponCode, CancellationToken cancellationToken) { var code = NormalizeRequired(couponCode, "coupon_code_required"); return await commercePersistence.Coupons .SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken) ?? throw new CommerceException("Coupon was not found.", "coupon_not_found"); } protected 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"); } protected 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"); } protected 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); } protected static decimal PercentFactor(decimal value) { if (value <= 0) return 0; return value <= 1 ? value : value / 100; } protected async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken) { var exists = await identityPersistence.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"); } protected async Task FindActorOrderAsync( CommerceActor actor, string orderNo, CancellationToken cancellationToken) { var trimmed = orderNo.Trim(); return await commercePersistence.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"); } protected 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); } protected 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); } protected 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); } protected 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"); } protected 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"); } protected static string NormalizeEnum(string? value) { return string.Concat((value ?? string.Empty).Split( ['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries)); } protected 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); } protected 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") }; } protected static string NormalizeMethod(string? method) { var normalized = (method ?? "manual").Trim().ToLowerInvariant(); return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized; } protected static bool IsPaid(string status) { return string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase); } protected static string GenerateOrderNo() { Span bytes = stackalloc byte[4]; RandomNumberGenerator.Fill(bytes); return $"TK{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}"; } protected static string FormatCny(int cents) { return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); } }