1002 lines
37 KiB
C#
1002 lines
37 KiB
C#
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<CommerceOrderItem> 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<CommerceOrderList> 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<CommerceOrderItem> 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<CommercePaymentItem> 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<CurrentEntitlementItem> 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<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,
|
|
IReadOnlyDictionary<string, string> 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<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 =>
|
|
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<Order> 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<OrderStatus>(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<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)
|
|
.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<byte> 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);
|
|
}
|