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:
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Discount } from './entities/discount.entity';
|
||||
import { DiscountRedemption } from './entities/discount-redemption.entity';
|
||||
import { CreateDiscountDto, UpdateDiscountDto } from './dto/discount.dto';
|
||||
import { BillingCycle } from '../common/enums';
|
||||
import { CostBreakdownLine } from './pricing-catalog.service';
|
||||
|
||||
/** Why a coupon could not be applied — translated client-side. */
|
||||
export type DiscountRejectReason =
|
||||
| 'not_found'
|
||||
| 'inactive'
|
||||
| 'not_started'
|
||||
| 'expired'
|
||||
| 'max_uses_reached'
|
||||
| 'max_uses_per_user_reached'
|
||||
| 'not_eligible_user'
|
||||
| 'no_eligible_services';
|
||||
|
||||
export interface DiscountEvaluation {
|
||||
ok: boolean;
|
||||
reason?: DiscountRejectReason;
|
||||
discount: Discount;
|
||||
eligibleAmount: number;
|
||||
discountAmount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DiscountService implements OnModuleInit {
|
||||
private readonly logger = new Logger(DiscountService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Discount) private readonly discountRepo: Repository<Discount>,
|
||||
@InjectRepository(DiscountRedemption)
|
||||
private readonly redemptionRepo: Repository<DiscountRedemption>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureSchema();
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently ensure the discount tables and invoice discount columns exist.
|
||||
*
|
||||
* In development TypeORM `synchronize` creates these from the entities, so the
|
||||
* statements below are no-ops. In production (`synchronize` off, no migration
|
||||
* runner) this is what actually provisions the schema — mirroring the existing
|
||||
* bootstrap pattern used by PricingCatalogService.ensureDefaults().
|
||||
*/
|
||||
async ensureSchema() {
|
||||
// uuid_generate_v4() needs uuid-ossp; guard separately so a missing CREATE
|
||||
// EXTENSION privilege (when the extension already exists) doesn't abort the
|
||||
// rest of the bootstrap.
|
||||
try {
|
||||
await this.dataSource.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
|
||||
} catch {
|
||||
/* extension already present or insufficient privilege — ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
await this.dataSource.query(`
|
||||
CREATE TABLE IF NOT EXISTS "discounts" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"code" character varying NOT NULL,
|
||||
"name" character varying NOT NULL,
|
||||
"description" character varying,
|
||||
"percentOff" integer NOT NULL,
|
||||
"services" jsonb NOT NULL DEFAULT '[]',
|
||||
"isPublic" boolean NOT NULL DEFAULT true,
|
||||
"allowedUserIds" jsonb NOT NULL DEFAULT '[]',
|
||||
"maxUses" integer,
|
||||
"maxUsesPerUser" integer,
|
||||
"usedCount" integer NOT NULL DEFAULT 0,
|
||||
"startsAt" TIMESTAMP WITH TIME ZONE,
|
||||
"endsAt" TIMESTAMP WITH TIME ZONE,
|
||||
"isActive" boolean NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_discounts_id" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "UQ_discounts_code" UNIQUE ("code")
|
||||
)
|
||||
`);
|
||||
|
||||
await this.dataSource.query(`
|
||||
CREATE TABLE IF NOT EXISTS "discount_redemptions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"discountId" uuid NOT NULL,
|
||||
"userId" character varying NOT NULL,
|
||||
"invoiceId" character varying,
|
||||
"amount" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_discount_redemptions_id" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_discount_redemptions_discount" FOREIGN KEY ("discountId")
|
||||
REFERENCES "discounts"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await this.dataSource.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_discount_redemptions_discount_user"
|
||||
ON "discount_redemptions" ("discountId", "userId")
|
||||
`);
|
||||
|
||||
await this.dataSource.query(`
|
||||
ALTER TABLE "invoices"
|
||||
ADD COLUMN IF NOT EXISTS "discountAmount" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "discountCode" character varying
|
||||
`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to ensure discount schema: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CRUD (Admin) ─────────────────────────────────────────────────
|
||||
|
||||
list(): Promise<Discount[]> {
|
||||
return this.discountRepo.find({ order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Discount> {
|
||||
const discount = await this.discountRepo.findOne({ where: { id } });
|
||||
if (!discount) throw new NotFoundException('Discount not found');
|
||||
return discount;
|
||||
}
|
||||
|
||||
private normalizeCode(code: string): string {
|
||||
return code.trim().toUpperCase();
|
||||
}
|
||||
|
||||
async create(dto: CreateDiscountDto): Promise<Discount> {
|
||||
const code = this.normalizeCode(dto.code);
|
||||
const existing = await this.discountRepo.findOne({ where: { code } });
|
||||
if (existing) throw new BadRequestException('A discount with this code already exists');
|
||||
|
||||
const discount = this.discountRepo.create({
|
||||
code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
percentOff: dto.percentOff,
|
||||
services: this.cleanServices(dto.services),
|
||||
isPublic: dto.isPublic ?? true,
|
||||
allowedUserIds: dto.isPublic === false ? dto.allowedUserIds ?? [] : [],
|
||||
maxUses: dto.maxUses ?? null,
|
||||
maxUsesPerUser: dto.maxUsesPerUser ?? null,
|
||||
startsAt: dto.startsAt ? new Date(dto.startsAt) : null,
|
||||
endsAt: dto.endsAt ? new Date(dto.endsAt) : null,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
return this.discountRepo.save(discount);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateDiscountDto): Promise<Discount> {
|
||||
const discount = await this.getById(id);
|
||||
|
||||
if (dto.code !== undefined) {
|
||||
const code = this.normalizeCode(dto.code);
|
||||
if (code !== discount.code) {
|
||||
const clash = await this.discountRepo.findOne({ where: { code } });
|
||||
if (clash) throw new BadRequestException('A discount with this code already exists');
|
||||
discount.code = code;
|
||||
}
|
||||
}
|
||||
if (dto.name !== undefined) discount.name = dto.name;
|
||||
if (dto.description !== undefined) discount.description = dto.description;
|
||||
if (dto.percentOff !== undefined) discount.percentOff = dto.percentOff;
|
||||
if (dto.services !== undefined) discount.services = this.cleanServices(dto.services);
|
||||
if (dto.isPublic !== undefined) discount.isPublic = dto.isPublic;
|
||||
if (dto.allowedUserIds !== undefined) discount.allowedUserIds = dto.allowedUserIds ?? [];
|
||||
if (discount.isPublic) discount.allowedUserIds = [];
|
||||
if (dto.maxUses !== undefined) discount.maxUses = dto.maxUses;
|
||||
if (dto.maxUsesPerUser !== undefined) discount.maxUsesPerUser = dto.maxUsesPerUser;
|
||||
if (dto.startsAt !== undefined) discount.startsAt = dto.startsAt ? new Date(dto.startsAt) : null;
|
||||
if (dto.endsAt !== undefined) discount.endsAt = dto.endsAt ? new Date(dto.endsAt) : null;
|
||||
if (dto.isActive !== undefined) discount.isActive = dto.isActive;
|
||||
|
||||
return this.discountRepo.save(discount);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<{ success: true }> {
|
||||
const discount = await this.getById(id);
|
||||
await this.discountRepo.remove(discount);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private cleanServices(services?: string[]): string[] {
|
||||
if (!services) return [];
|
||||
const cleaned = services.map((s) => s.trim()).filter(Boolean);
|
||||
// '*' means "all services" — collapse to the canonical empty set.
|
||||
return cleaned.includes('*') ? [] : Array.from(new Set(cleaned));
|
||||
}
|
||||
|
||||
// ─── Discount math ────────────────────────────────────────────────
|
||||
|
||||
/** Whether a breakdown line is in scope for the discount (empty scope = all). */
|
||||
private serviceInScope(discount: Discount, serviceKey?: string): boolean {
|
||||
if (!discount.services || discount.services.length === 0) return true;
|
||||
if (!serviceKey) return false;
|
||||
return discount.services.includes(serviceKey);
|
||||
}
|
||||
|
||||
private amountForCycle(line: CostBreakdownLine, cycle: BillingCycle): number {
|
||||
switch (cycle) {
|
||||
case BillingCycle.HOURLY:
|
||||
return line.hourly;
|
||||
case BillingCycle.YEARLY:
|
||||
return line.yearly;
|
||||
case BillingCycle.MONTHLY:
|
||||
default:
|
||||
return line.monthly;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum of the in-scope portion of a breakdown for the given cycle. */
|
||||
eligibleAmount(discount: Discount, breakdown: CostBreakdownLine[], cycle: BillingCycle): number {
|
||||
return breakdown.reduce(
|
||||
(sum, line) =>
|
||||
this.serviceInScope(discount, line.serviceKey)
|
||||
? sum + this.amountForCycle(line, cycle)
|
||||
: sum,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
computeAmount(
|
||||
discount: Discount,
|
||||
breakdown: CostBreakdownLine[],
|
||||
cycle: BillingCycle,
|
||||
): { eligibleAmount: number; discountAmount: number } {
|
||||
const eligibleAmount = Math.max(0, Math.round(this.eligibleAmount(discount, breakdown, cycle)));
|
||||
const discountAmount = Math.round((eligibleAmount * discount.percentOff) / 100);
|
||||
return { eligibleAmount, discountAmount };
|
||||
}
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────
|
||||
|
||||
private async findByCode(code: string): Promise<Discount | null> {
|
||||
return this.discountRepo.findOne({ where: { code: this.normalizeCode(code) } });
|
||||
}
|
||||
|
||||
async userRedemptionCount(discountId: string, userId: string): Promise<number> {
|
||||
return this.redemptionRepo.count({ where: { discountId, userId } });
|
||||
}
|
||||
|
||||
/** Evaluate a coupon without throwing — used for live UI previews. */
|
||||
async evaluate(
|
||||
code: string,
|
||||
userId: string,
|
||||
breakdown: CostBreakdownLine[],
|
||||
cycle: BillingCycle,
|
||||
): Promise<DiscountEvaluation | { ok: false; reason: 'not_found' }> {
|
||||
const discount = await this.findByCode(code);
|
||||
if (!discount) return { ok: false, reason: 'not_found' };
|
||||
|
||||
const now = new Date();
|
||||
const base = { discount, eligibleAmount: 0, discountAmount: 0 };
|
||||
|
||||
if (!discount.isActive) return { ...base, ok: false, reason: 'inactive' };
|
||||
if (discount.startsAt && now < new Date(discount.startsAt)) {
|
||||
return { ...base, ok: false, reason: 'not_started' };
|
||||
}
|
||||
if (discount.endsAt && now > new Date(discount.endsAt)) {
|
||||
return { ...base, ok: false, reason: 'expired' };
|
||||
}
|
||||
if (discount.maxUses != null && discount.usedCount >= discount.maxUses) {
|
||||
return { ...base, ok: false, reason: 'max_uses_reached' };
|
||||
}
|
||||
if (!discount.isPublic && !discount.allowedUserIds?.includes(userId)) {
|
||||
return { ...base, ok: false, reason: 'not_eligible_user' };
|
||||
}
|
||||
if (discount.maxUsesPerUser != null) {
|
||||
const used = await this.userRedemptionCount(discount.id, userId);
|
||||
if (used >= discount.maxUsesPerUser) {
|
||||
return { ...base, ok: false, reason: 'max_uses_per_user_reached' };
|
||||
}
|
||||
}
|
||||
|
||||
const { eligibleAmount, discountAmount } = this.computeAmount(discount, breakdown, cycle);
|
||||
if (discountAmount <= 0) {
|
||||
return { ...base, ok: false, reason: 'no_eligible_services' };
|
||||
}
|
||||
|
||||
return { ok: true, discount, eligibleAmount, discountAmount };
|
||||
}
|
||||
|
||||
/** Resolve a coupon at payment time — throws if it cannot be applied. */
|
||||
async resolveForCharge(
|
||||
code: string,
|
||||
userId: string,
|
||||
breakdown: CostBreakdownLine[],
|
||||
cycle: BillingCycle,
|
||||
): Promise<DiscountEvaluation> {
|
||||
const result = await this.evaluate(code, userId, breakdown, cycle);
|
||||
if (!result.ok) {
|
||||
throw new BadRequestException(`Coupon cannot be applied: ${result.reason}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Record a successful redemption and bump the usage counter. */
|
||||
async recordRedemption(
|
||||
discountId: string,
|
||||
userId: string,
|
||||
invoiceId: string | undefined,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
await this.redemptionRepo.save(
|
||||
this.redemptionRepo.create({ discountId, userId, invoiceId, amount }),
|
||||
);
|
||||
await this.discountRepo.increment({ id: discountId }, 'usedCount', 1);
|
||||
this.logger.log(`Discount ${discountId} redeemed by user ${userId} (-${amount} Toman)`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user