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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user