using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Commerce; public sealed partial class CommerceService { 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); } }