feat(billing): add percentage discount coupons

Admins can create coupon codes that discount specific services (app
runtimes, optional services, managed products, custom-domain addon, or
all) and restrict them to specific users or make them public, with total
and per-user usage caps and an active date window.

Coupons apply in deploy, renewal, and upgrade flows: cost-breakdown lines
are tagged with a service key, the eligible portion is discounted and
capped to the payable amount, the invoice records discountAmount/
discountCode, and the redemption is recorded once when the invoice is
fully paid (covering wallet, gateway, and mixed payments).

- Discount + DiscountRedemption entities; invoice discount columns
- DiscountService (CRUD, validation, redemption) + admin/validate API
- Idempotent schema bootstrap on init so production (synchronize off)
  provisions the tables/columns without a migration runner
- Admin discounts UI, coupon entry in deploy/renewal, invoice discount line
- fa/en strings; discount.service unit spec

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-20 11:48:45 +03:30
parent b232129ee2
commit 49726f1dfd
21 changed files with 2013 additions and 20 deletions
+132 -5
View File
@@ -20,7 +20,9 @@ import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { Application } from '../applications/entities/application.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
import { PricingCatalogService } from './pricing-catalog.service';
import { CostBreakdownLine, PricingCatalogService } from './pricing-catalog.service';
import { DiscountService } from './discount.service';
import { productServiceKey, runtimeServiceKey } from './pricing-catalog.constants';
@Injectable()
export class BillingService {
@@ -28,6 +30,7 @@ export class BillingService {
constructor(
private readonly pricingCatalog: PricingCatalogService,
private readonly discountService: DiscountService,
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
@@ -55,11 +58,69 @@ export class BillingService {
hourly: number;
monthly: number;
yearly: number;
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
breakdown: CostBreakdownLine[];
}> {
return this.pricingCatalog.computeTotalsFromDb(dto);
}
// ─── Coupon discounts ─────────────────────────────────────────────
/** Service-tagged cost breakdown for a deploy config (used for coupon scoping). */
async getDeployBreakdown(dto: CalculateCostDto): Promise<CostBreakdownLine[]> {
return (await this.pricingCatalog.computeTotalsFromDb(dto)).breakdown;
}
/** Service-tagged cost breakdown for an existing app (renewal). */
async getAppChargeBreakdown(app: Application): Promise<CostBreakdownLine[]> {
const config = this.appToResourceConfig(app, { enableCustomDomain: !!app.customDomain });
return (await this.pricingCatalog.computeTotalsFromDb(this.toCalculateDto(config))).breakdown;
}
/** Single-line breakdown for an upgrade, tagged with the app's service key. */
async getUpgradeBreakdown(
app: Application,
dto: UpgradeResourcesDto,
): Promise<CostBreakdownLine[]> {
const cost = await this.calculateUpgradeCost(app, dto);
const amount = cost.proratedAmount;
if (amount <= 0) return [];
const productType = app.productType ?? ProductType.APPLICATION;
const serviceKey =
productType === ProductType.APPLICATION
? runtimeServiceKey(app.runtime)
: productServiceKey(productType);
return [{ label: 'Resource upgrade', hourly: amount, monthly: amount, yearly: amount, serviceKey }];
}
/** Preview a coupon without charging — returns the evaluation for UI. */
previewCoupon(
userId: string,
code: string,
breakdown: CostBreakdownLine[],
cycle: BillingCycle,
) {
return this.discountService.evaluate(code, userId, breakdown, cycle);
}
/**
* Resolve a coupon at payment time. Returns the discount to attach to an
* invoice (capped to the payable amount), or null when no code is supplied.
* Throws if the code is supplied but cannot be applied.
*/
async resolveCoupon(
userId: string,
couponCode: string | undefined,
breakdown: CostBreakdownLine[],
cycle: BillingCycle,
payableAmount: number,
): Promise<{ discountId: string; code: string; amount: number } | null> {
if (!couponCode) return null;
const result = await this.discountService.resolveForCharge(couponCode, userId, breakdown, cycle);
const amount = Math.min(result.discountAmount, Math.max(0, Math.round(payableAmount)));
if (amount <= 0) return null;
return { discountId: result.discount.id, code: result.discount.code, amount };
}
// ─── Optional services & custom domain (delegates to catalog) ─────
getOptionalServicesPricing() {
@@ -249,6 +310,7 @@ export class BillingService {
lines: { label: string; description?: string; quantity?: number; unitAmount?: number; amount: number; metadata?: Record<string, any> }[];
dueDate?: Date;
metadata?: Record<string, any>;
discount?: { discountId: string; code: string; amount: number };
}): Promise<Invoice> {
const lines = input.lines
.filter((line) => this.normalizeAmount(line.amount) > 0)
@@ -265,7 +327,11 @@ export class BillingService {
});
});
const total = this.normalizeAmount(lines.reduce((sum, line) => sum + Number(line.amount), 0));
const subtotal = this.normalizeAmount(lines.reduce((sum, line) => sum + Number(line.amount), 0));
const discountAmount = input.discount
? Math.min(this.normalizeAmount(input.discount.amount), subtotal)
: 0;
const total = this.normalizeAmount(subtotal - discountAmount);
if (total <= 0) {
throw new BadRequestException('Invoice total must be positive');
}
@@ -276,12 +342,16 @@ export class BillingService {
applicationId: input.applicationId,
reason: input.reason,
status: InvoiceStatus.ISSUED,
subtotal: total,
subtotal,
discountAmount,
discountCode: input.discount?.code,
total,
paidAmount: 0,
dueAmount: total,
dueDate: input.dueDate,
metadata: input.metadata,
metadata: input.discount
? { ...(input.metadata || {}), discountId: input.discount.discountId }
: input.metadata,
lines,
});
@@ -367,6 +437,21 @@ export class BillingService {
invoice.paidAt = dueAmount <= 0 ? new Date() : invoice.paidAt;
invoice.gatewayTrackingCode = gatewayTrackingCode || invoice.gatewayTrackingCode;
invoice.gatewayReference = gatewayReference || invoice.gatewayReference;
// Record the coupon redemption exactly once, when the invoice is fully paid.
const discountId = invoice.metadata?.discountId;
const alreadyRedeemed = invoice.metadata?.discountRedeemed;
if (invoice.status === InvoiceStatus.PAID && discountId && !alreadyRedeemed) {
invoice.metadata = { ...(invoice.metadata || {}), discountRedeemed: true };
const saved = await this.invoiceRepo.save(invoice);
await this.discountService.recordRedemption(
discountId,
invoice.userId,
invoice.id,
Number(invoice.discountAmount) || 0,
);
return saved;
}
return this.invoiceRepo.save(invoice);
}
@@ -1140,6 +1225,7 @@ export class BillingService {
userId: string,
dto: CalculateCostDto,
cycle: BillingCycle,
couponCode?: string,
) {
const costs = await this.calculateCost(dto);
const fullAmount = this.amountForCycle(costs, cycle);
@@ -1152,7 +1238,13 @@ export class BillingService {
} as CalculateCostDto);
const credit = await this.findApplicableCredit(userId, config);
// Coupon discount is scoped against the full service breakdown but capped
// to whatever is actually payable after prepaid credits.
const couponFor = (amountDue: number) =>
this.previewCouponForResponse(userId, couponCode, costs.breakdown, cycle, amountDue);
if (!credit) {
const couponDiscount = await couponFor(fullAmount);
return {
...costs,
cycle,
@@ -1163,6 +1255,9 @@ export class BillingService {
extrasBreakdown: [],
creditApplied: null,
prepaidCreditUsed: false,
couponDiscount,
amountDueAfterDiscount:
fullAmount - (couponDiscount && couponDiscount.valid ? couponDiscount.discountAmount : 0),
};
}
@@ -1170,6 +1265,7 @@ export class BillingService {
await this.calculateExtrasBeyondCredit(config, credit, cycle);
const waivedAmount = Math.max(0, fullAmount - extrasDue);
const prorate = this.getCreditProrateFactor(credit);
const couponDiscount = await couponFor(extrasDue);
return {
...costs,
cycle,
@@ -1182,6 +1278,37 @@ export class BillingService {
prepaidCreditUsed: waivedAmount > 0,
prorateRemainingDays: prorate.remainingDays,
proratePeriodDays: prorate.periodDays,
couponDiscount,
amountDueAfterDiscount:
extrasDue - (couponDiscount && couponDiscount.valid ? couponDiscount.discountAmount : 0),
};
}
/** Evaluate a coupon for a preview response (no throw), capped to amountDue. */
private async previewCouponForResponse(
userId: string,
couponCode: string | undefined,
breakdown: CostBreakdownLine[],
cycle: BillingCycle,
amountDue: number,
): Promise<
| { valid: true; code: string; name: string; percentOff: number; discountAmount: number }
| { valid: false; reason: string }
| null
> {
if (!couponCode) return null;
const result = await this.discountService.evaluate(couponCode, userId, breakdown, cycle);
if (!result.ok) {
return { valid: false, reason: result.reason ?? 'not_found' };
}
const discountAmount = Math.min(result.discountAmount, Math.max(0, Math.round(amountDue)));
if (discountAmount <= 0) return { valid: false, reason: 'no_eligible_services' };
return {
valid: true,
code: result.discount.code,
name: result.discount.name,
percentOff: result.discount.percentOff,
discountAmount,
};
}