Files
tiku-backend.net/Tiku.Infrastructure/Commerce/Coupons/CommerceCouponService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

130 lines
5.4 KiB
C#

using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Domain.Commerce;
namespace Tiku.Infrastructure.Commerce;
internal sealed class CommerceCouponService(CommerceServiceDependencies dependencies)
: CommerceServiceBase(dependencies), ICommerceCouponService
{
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 commercePersistence.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;
commercePersistence.CouponRedemptions.Add(redemption);
await commercePersistence.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 = commercePersistence.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 commercePersistence.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 commercePersistence.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));
}
}
}