Files
cloud-host/backend/src/billing/discount.service.ts
T
keyhan 49726f1dfd 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>
2026-06-20 11:48:45 +03:30

320 lines
12 KiB
TypeScript

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)`);
}
}