feat: add commerce order and payment checkout
This commit is contained in:
419
Tiku.Infrastructure/Commerce/CommerceService.cs
Normal file
419
Tiku.Infrastructure/Commerce/CommerceService.cs
Normal file
@@ -0,0 +1,419 @@
|
||||
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
|
||||
{
|
||||
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))
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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 = checked(plan.PriceCents * command.Quantity),
|
||||
PayMethod = NormalizeMethod(command.PayMethod),
|
||||
PayProvider = NormalizeProvider(command.PayProvider),
|
||||
Days = checked(plan.Days * command.Quantity),
|
||||
RawPayload = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
source = "student_checkout",
|
||||
command.Quantity,
|
||||
command.CouponCode,
|
||||
plan.PriceCents,
|
||||
plan.OriginalPriceCents
|
||||
})
|
||||
};
|
||||
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 = order.AmountCents,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
plan.Days,
|
||||
plan.RegionId,
|
||||
plan.VpProductId
|
||||
})
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(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, 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);
|
||||
}
|
||||
|
||||
private async Task MarkPaidAsync(
|
||||
CommerceActor actor,
|
||||
Order order,
|
||||
Payment payment,
|
||||
string? providerTradeNo,
|
||||
JsonElement rawPayload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var paidAt = 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 = "payment_paid",
|
||||
EventId = payment.ProviderTradeNo,
|
||||
SignatureValid = true,
|
||||
Payload = rawPayload,
|
||||
ProcessedAt = paidAt
|
||||
});
|
||||
}
|
||||
|
||||
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 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 string NormalizeEnum(string? value) =>
|
||||
string.Concat((value ?? string.Empty).Split(
|
||||
['_', '-', ' '],
|
||||
StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -63,6 +63,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAssetManagementService, AssetManagementService>();
|
||||
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddScoped<ICommerceService, CommerceService>();
|
||||
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
||||
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
||||
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();
|
||||
|
||||
Reference in New Issue
Block a user