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
+61 -4
View File
@@ -31,6 +31,7 @@ import {
InitiateInvoicePaymentDto,
VerifyInvoiceGatewayDto,
UpdateInvoiceStatusDto,
PayApplicationDto,
} from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { RolesGuard } from '../common/guards/roles.guard';
@@ -94,7 +95,7 @@ export class BillingController {
if (!Object.values(BillingCycle).includes(dto.cycle)) {
throw new BadRequestException(`Invalid billing cycle: ${dto.cycle}`);
}
return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle);
return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle, dto.couponCode);
}
// ─── Custom Domain Pricing ─────────────────────────────────────
@@ -212,7 +213,7 @@ export class BillingController {
async payForApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() body: { cycle: string },
@Body() body: PayApplicationDto,
) {
const cycle = body.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) {
@@ -227,6 +228,14 @@ export class BillingController {
cycle,
);
const coupon = await this.billingService.resolveCoupon(
req.user.id,
body.couponCode,
await this.billingService.getAppChargeBreakdown(app),
cycle,
payment.amountDue,
);
let invoice = null;
if (payment.amountDue > 0) {
invoice = await this.billingService.createInvoice({
@@ -249,6 +258,7 @@ export class BillingController {
action: 'activate',
cycle,
},
discount: coupon ?? undefined,
});
}
@@ -269,7 +279,9 @@ export class BillingController {
invoice,
creditApplied: payment.creditId || null,
waivedAmount: payment.waivedAmount,
paidAmount: payment.amountDue,
discountAmount: coupon?.amount ?? 0,
discountCode: coupon?.code ?? null,
paidAmount: invoice ? Number(invoice.total) : 0,
application: {
id: activated.id,
name: activated.name,
@@ -423,6 +435,14 @@ export class BillingController {
throw new BadRequestException('Invalid cost calculation — no pricing rules found');
}
const coupon = await this.billingService.resolveCoupon(
app.userId,
dto.couponCode,
await this.billingService.getAppChargeBreakdown(app),
dto.cycle,
amount,
);
return this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
@@ -436,6 +456,7 @@ export class BillingController {
},
],
metadata: { action: 'renew', cycle: dto.cycle },
discount: coupon ?? undefined,
});
}
@@ -463,6 +484,14 @@ export class BillingController {
? app.userId
: req.user.id;
const coupon = await this.billingService.resolveCoupon(
walletUserId,
dto.couponCode,
await this.billingService.getAppChargeBreakdown(app),
dto.cycle,
amount,
);
const invoice = await this.billingService.createInvoice({
userId: walletUserId,
applicationId: app.id,
@@ -476,6 +505,7 @@ export class BillingController {
},
],
metadata: { action: 'renew', cycle: dto.cycle },
discount: coupon ?? undefined,
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
@@ -504,7 +534,7 @@ export class BillingController {
async adminRenewApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() body: { cycle: string; bypassPayment?: boolean; reason?: string },
@Body() body: { cycle: string; bypassPayment?: boolean; reason?: string; couponCode?: string },
) {
const app = await this.applicationsService.findOne(applicationId);
const cycle = body.cycle as BillingCycle;
@@ -536,6 +566,14 @@ export class BillingController {
: cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
const coupon = await this.billingService.resolveCoupon(
app.userId,
body.couponCode,
await this.billingService.getAppChargeBreakdown(app),
cycle,
amount,
);
const invoice = await this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
@@ -549,6 +587,7 @@ export class BillingController {
},
],
metadata: { action: 'renew', cycle, initiatedBy: req.user.role },
discount: coupon ?? undefined,
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
@@ -625,6 +664,14 @@ export class BillingController {
throw new BadRequestException('This change does not require a paid invoice');
}
const coupon = await this.billingService.resolveCoupon(
app.userId,
dto.couponCode,
await this.billingService.getUpgradeBreakdown(app, dto),
app.billingCycle ?? BillingCycle.MONTHLY,
costResult.proratedAmount,
);
return this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
@@ -646,6 +693,7 @@ export class BillingController {
resources: dto,
remainingHours: costResult.remainingHours,
},
discount: coupon ?? undefined,
});
}
@@ -675,6 +723,14 @@ export class BillingController {
? app.userId
: req.user.id;
const coupon = await this.billingService.resolveCoupon(
walletUserId,
dto.couponCode,
await this.billingService.getUpgradeBreakdown(app, dto),
app.billingCycle ?? BillingCycle.MONTHLY,
costResult.proratedAmount,
);
const invoice = await this.billingService.createInvoice({
userId: walletUserId,
applicationId: app.id,
@@ -696,6 +752,7 @@ export class BillingController {
resources: dto,
remainingHours: costResult.remainingHours,
},
discount: coupon ?? undefined,
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
paidInvoice = paid.invoice;
+9 -3
View File
@@ -2,6 +2,8 @@ import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingService } from './billing.service';
import { BillingController } from './billing.controller';
import { DiscountController } from './discount.controller';
import { DiscountService } from './discount.service';
import { PricingCatalogService } from './pricing-catalog.service';
import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
@@ -12,6 +14,8 @@ import { WalletTransaction } from './entities/wallet-transaction.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
import { Invoice } from './entities/invoice.entity';
import { InvoiceLine } from './entities/invoice-line.entity';
import { Discount } from './entities/discount.entity';
import { DiscountRedemption } from './entities/discount-redemption.entity';
import { LifecycleModule } from '../lifecycle/lifecycle.module';
import { ApplicationsModule } from '../applications/applications.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
@@ -28,13 +32,15 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
ResourceCredit,
Invoice,
InvoiceLine,
Discount,
DiscountRedemption,
]),
forwardRef(() => LifecycleModule),
forwardRef(() => ApplicationsModule),
forwardRef(() => KubernetesModule),
],
controllers: [BillingController],
providers: [BillingService, PricingCatalogService],
exports: [BillingService, PricingCatalogService],
controllers: [BillingController, DiscountController],
providers: [BillingService, PricingCatalogService, DiscountService],
exports: [BillingService, PricingCatalogService, DiscountService],
})
export class BillingModule {}
+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,
};
}
+124
View File
@@ -0,0 +1,124 @@
import {
Body,
Controller,
Delete,
forwardRef,
Get,
Inject,
Param,
Patch,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BillingService } from './billing.service';
import { DiscountService } from './discount.service';
import { ApplicationsService } from '../applications/applications.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
import {
CreateDiscountDto,
DiscountFlow,
UpdateDiscountDto,
ValidateDiscountDto,
} from './dto/discount.dto';
import { CostBreakdownLine } from './pricing-catalog.service';
@ApiTags('Billing')
@ApiBearerAuth()
@Controller('billing/discounts')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class DiscountController {
constructor(
private readonly discountService: DiscountService,
private readonly billingService: BillingService,
@Inject(forwardRef(() => ApplicationsService))
private readonly applicationsService: ApplicationsService,
) {}
// ─── Admin CRUD ───────────────────────────────────────────────────
@Get()
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List discount coupons (Admin)' })
list() {
return this.discountService.list();
}
@Post()
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Create a discount coupon (Admin)' })
create(@Body() dto: CreateDiscountDto) {
return this.discountService.create(dto);
}
@Patch(':id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update a discount coupon (Admin)' })
update(@Param('id') id: string, @Body() dto: UpdateDiscountDto) {
return this.discountService.update(id, dto);
}
@Delete(':id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Delete a discount coupon (Admin)' })
remove(@Param('id') id: string) {
return this.discountService.remove(id);
}
// ─── User-facing validation / preview ─────────────────────────────
@Post('validate')
@ApiOperation({ summary: 'Validate a coupon for a charge context and preview the discount' })
async validate(@Request() req: any, @Body() dto: ValidateDiscountDto) {
const breakdown = await this.buildBreakdown(req.user, dto);
const result = await this.billingService.previewCoupon(
req.user.id,
dto.code,
breakdown,
dto.cycle,
);
if (!result.ok) {
return { valid: false, reason: result.reason ?? 'not_found' };
}
return {
valid: true,
code: result.discount.code,
name: result.discount.name,
percentOff: result.discount.percentOff,
eligibleAmount: result.eligibleAmount,
discountAmount: result.discountAmount,
};
}
private async buildBreakdown(
user: { id: string; role?: UserRole },
dto: ValidateDiscountDto,
): Promise<CostBreakdownLine[]> {
const flow = dto.flow ?? DiscountFlow.DEPLOY;
if (flow === DiscountFlow.DEPLOY) {
if (!dto.config) return [];
return this.billingService.getDeployBreakdown(dto.config);
}
if (!dto.applicationId) return [];
const app = await this.getAppWithAccess(user, dto.applicationId);
if (flow === DiscountFlow.UPGRADE) {
return this.billingService.getUpgradeBreakdown(app, dto.upgrade ?? {});
}
return this.billingService.getAppChargeBreakdown(app);
}
private getAppWithAccess(user: { id: string; role?: UserRole }, applicationId: string) {
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
return isAdminOrSales
? this.applicationsService.findOne(applicationId)
: this.applicationsService.findOne(applicationId, user.id);
}
}
@@ -0,0 +1,149 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { DiscountService } from './discount.service';
import { Discount } from './entities/discount.entity';
import { DiscountRedemption } from './entities/discount-redemption.entity';
import { BillingCycle } from '../common/enums';
import { CostBreakdownLine } from './pricing-catalog.service';
function makeDiscount(overrides: Partial<Discount> = {}): Discount {
return {
id: 'd1',
code: 'SAVE20',
name: 'Test',
description: '',
percentOff: 20,
services: [],
isPublic: true,
allowedUserIds: [],
maxUses: null,
maxUsesPerUser: null,
usedCount: 0,
startsAt: null,
endsAt: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as Discount;
}
const breakdown: CostBreakdownLine[] = [
{ label: 'CPU', hourly: 0, monthly: 100000, yearly: 0, serviceKey: 'runtime:nodejs' },
{ label: 'Redis', hourly: 0, monthly: 50000, yearly: 0, serviceKey: 'optional:redis' },
];
describe('DiscountService', () => {
let service: DiscountService;
const discountRepo = {
findOne: jest.fn(),
find: jest.fn(),
save: jest.fn(),
create: jest.fn().mockImplementation((x) => x),
remove: jest.fn(),
increment: jest.fn(),
};
const redemptionRepo = {
count: jest.fn().mockResolvedValue(0),
save: jest.fn(),
create: jest.fn().mockImplementation((x) => x),
};
const dataSource = { query: jest.fn().mockResolvedValue(undefined) };
beforeEach(async () => {
jest.clearAllMocks();
redemptionRepo.count.mockResolvedValue(0);
const module: TestingModule = await Test.createTestingModule({
providers: [
DiscountService,
{ provide: getRepositoryToken(Discount), useValue: discountRepo },
{ provide: getRepositoryToken(DiscountRedemption), useValue: redemptionRepo },
{ provide: DataSource, useValue: dataSource },
],
}).compile();
service = module.get(DiscountService);
});
describe('computeAmount', () => {
it('applies the percentage to all services when scope is empty', () => {
const { eligibleAmount, discountAmount } = service.computeAmount(
makeDiscount({ percentOff: 20 }),
breakdown,
BillingCycle.MONTHLY,
);
expect(eligibleAmount).toBe(150000);
expect(discountAmount).toBe(30000);
});
it('only discounts the targeted service', () => {
const { eligibleAmount, discountAmount } = service.computeAmount(
makeDiscount({ percentOff: 20, services: ['optional:redis'] }),
breakdown,
BillingCycle.MONTHLY,
);
expect(eligibleAmount).toBe(50000);
expect(discountAmount).toBe(10000);
});
});
describe('evaluate', () => {
it('rejects an unknown code', async () => {
discountRepo.findOne.mockResolvedValue(null);
const res = await service.evaluate('NOPE', 'u1', breakdown, BillingCycle.MONTHLY);
expect(res).toEqual({ ok: false, reason: 'not_found' });
});
it('rejects an inactive code', async () => {
discountRepo.findOne.mockResolvedValue(makeDiscount({ isActive: false }));
const res = await service.evaluate('SAVE20', 'u1', breakdown, BillingCycle.MONTHLY);
expect(res.ok).toBe(false);
expect((res as any).reason).toBe('inactive');
});
it('rejects an expired code', async () => {
discountRepo.findOne.mockResolvedValue(
makeDiscount({ endsAt: new Date(Date.now() - 86400000) }),
);
const res = await service.evaluate('SAVE20', 'u1', breakdown, BillingCycle.MONTHLY);
expect((res as any).reason).toBe('expired');
});
it('rejects when total usage cap is reached', async () => {
discountRepo.findOne.mockResolvedValue(makeDiscount({ maxUses: 5, usedCount: 5 }));
const res = await service.evaluate('SAVE20', 'u1', breakdown, BillingCycle.MONTHLY);
expect((res as any).reason).toBe('max_uses_reached');
});
it('rejects a user not on the allow-list', async () => {
discountRepo.findOne.mockResolvedValue(
makeDiscount({ isPublic: false, allowedUserIds: ['someone-else'] }),
);
const res = await service.evaluate('SAVE20', 'u1', breakdown, BillingCycle.MONTHLY);
expect((res as any).reason).toBe('not_eligible_user');
});
it('rejects when the per-user cap is reached', async () => {
discountRepo.findOne.mockResolvedValue(makeDiscount({ maxUsesPerUser: 1 }));
redemptionRepo.count.mockResolvedValue(1);
const res = await service.evaluate('SAVE20', 'u1', breakdown, BillingCycle.MONTHLY);
expect((res as any).reason).toBe('max_uses_per_user_reached');
});
it('rejects when the scope matches no billed service', async () => {
discountRepo.findOne.mockResolvedValue(
makeDiscount({ services: ['optional:rabbitmq'] }),
);
const res = await service.evaluate('SAVE20', 'u1', breakdown, BillingCycle.MONTHLY);
expect((res as any).reason).toBe('no_eligible_services');
});
it('accepts an eligible code and returns the discount', async () => {
discountRepo.findOne.mockResolvedValue(makeDiscount({ percentOff: 20 }));
const res = await service.evaluate('SAVE20', 'u1', breakdown, BillingCycle.MONTHLY);
expect(res.ok).toBe(true);
expect((res as any).discountAmount).toBe(30000);
});
});
});
+319
View File
@@ -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)`);
}
}
+26
View File
@@ -172,6 +172,22 @@ export class CalculateDeployCostDto extends CalculateCostDto {
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
@IsEnum(BillingCycle)
cycle: BillingCycle;
@ApiPropertyOptional({ example: 'NOWRUZ1403', description: 'Coupon discount code' })
@IsOptional()
@IsString()
couponCode?: string;
}
export class PayApplicationDto {
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
@IsEnum(BillingCycle)
cycle: BillingCycle;
@ApiPropertyOptional({ example: 'NOWRUZ1403', description: 'Coupon discount code' })
@IsOptional()
@IsString()
couponCode?: string;
}
// ─── Renewal & Upgrade DTOs ─────────────────────────────────────────
@@ -180,6 +196,11 @@ export class RenewApplicationDto {
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
@IsEnum(BillingCycle)
cycle: BillingCycle;
@ApiPropertyOptional({ example: 'NOWRUZ1403', description: 'Coupon discount code' })
@IsOptional()
@IsString()
couponCode?: string;
}
export class UpgradeResourcesDto {
@@ -236,6 +257,11 @@ export class UpgradeResourcesDto {
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
rabbitmqResources?: OptionalServiceResourcesDto;
@ApiPropertyOptional({ example: 'NOWRUZ1403', description: 'Coupon discount code' })
@IsOptional()
@IsString()
couponCode?: string;
}
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}
+191
View File
@@ -0,0 +1,191 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { BillingCycle } from '../../common/enums';
import { CalculateCostDto, UpgradeResourcesDto } from './billing.dto';
export class CreateDiscountDto {
@ApiProperty({ example: 'NOWRUZ1403' })
@IsString()
code: string;
@ApiProperty({ example: 'تخفیف نوروزی' })
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: 20, description: 'Percentage off (1100)' })
@IsInt()
@Min(1)
@Max(100)
percentOff: number;
@ApiPropertyOptional({
type: [String],
description: 'Service keys to target. Empty or ["*"] = all services.',
example: ['optional:redis', 'runtime:nodejs'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
services?: string[];
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isPublic?: boolean;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedUserIds?: string[];
@ApiPropertyOptional({ description: 'Total redemption cap (omit for unlimited)' })
@IsOptional()
@IsInt()
@Min(1)
maxUses?: number;
@ApiPropertyOptional({ description: 'Per-user redemption cap (omit for unlimited)' })
@IsOptional()
@IsInt()
@Min(1)
maxUsesPerUser?: number;
@ApiPropertyOptional({ description: 'ISO start date' })
@IsOptional()
@IsString()
startsAt?: string;
@ApiPropertyOptional({ description: 'ISO end date' })
@IsOptional()
@IsString()
endsAt?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateDiscountDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
code?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ example: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
percentOff?: number;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
services?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isPublic?: boolean;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedUserIds?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
maxUses?: number | null;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
maxUsesPerUser?: number | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
startsAt?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
endsAt?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export enum DiscountFlow {
DEPLOY = 'deploy',
RENEWAL = 'renewal',
UPGRADE = 'upgrade',
}
/** Validate a coupon against a concrete charge context to preview the discount. */
export class ValidateDiscountDto {
@ApiProperty({ example: 'NOWRUZ1403' })
@IsString()
code: string;
@ApiProperty({ enum: BillingCycle })
@IsEnum(BillingCycle)
cycle: BillingCycle;
@ApiPropertyOptional({ enum: DiscountFlow, default: DiscountFlow.DEPLOY })
@IsOptional()
@IsEnum(DiscountFlow)
flow?: DiscountFlow;
@ApiPropertyOptional({ type: CalculateCostDto, description: 'Deploy config (deploy flow)' })
@IsOptional()
@ValidateNested()
@Type(() => CalculateCostDto)
config?: CalculateCostDto;
@ApiPropertyOptional({ description: 'Application id (renewal/upgrade flows)' })
@IsOptional()
@IsString()
applicationId?: string;
@ApiPropertyOptional({ type: UpgradeResourcesDto, description: 'Target resources (upgrade flow)' })
@IsOptional()
@ValidateNested()
@Type(() => UpgradeResourcesDto)
upgrade?: UpgradeResourcesDto;
}
@@ -0,0 +1,38 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Discount } from './discount.entity';
/** One row per coupon use — drives the per-user cap and gives an audit trail. */
@Entity('discount_redemptions')
@Index(['discountId', 'userId'])
export class DiscountRedemption {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
discountId: string;
@ManyToOne(() => Discount, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'discountId' })
discount: Discount;
@Column()
userId: string;
@Column({ nullable: true })
invoiceId: string;
/** Discount amount applied (Toman). */
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
amount: number;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,72 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* A percentage discount redeemed via a coupon code at payment time.
*
* `services` holds canonical service keys the discount applies to (see
* billing service-key helpers). An empty array or `['*']` means "all services".
* A discount is either public (any user) or limited to `allowedUserIds`.
*/
@Entity('discounts')
export class Discount {
@PrimaryGeneratedColumn('uuid')
id: string;
/** Coupon code the user types — stored uppercase, unique. */
@Column({ unique: true })
code: string;
/** Admin-facing label, e.g. "تخفیف نوروزی". */
@Column()
name: string;
@Column({ nullable: true })
description: string;
/** Percentage off, 1100. */
@Column({ type: 'int' })
percentOff: number;
/** Service keys this discount applies to. Empty or ['*'] = all services. */
@Column({ type: 'jsonb', default: () => "'[]'" })
services: string[];
/** When true any user can redeem; otherwise only allowedUserIds. */
@Column({ default: true })
isPublic: boolean;
@Column({ type: 'jsonb', default: () => "'[]'" })
allowedUserIds: string[];
/** Total redemption cap across all users (null = unlimited). */
@Column({ type: 'int', nullable: true })
maxUses: number | null;
/** Per-user redemption cap (null = unlimited). */
@Column({ type: 'int', nullable: true })
maxUsesPerUser: number | null;
@Column({ type: 'int', default: 0 })
usedCount: number;
@Column({ type: 'timestamptz', nullable: true })
startsAt: Date | null;
@Column({ type: 'timestamptz', nullable: true })
endsAt: Date | null;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -48,6 +48,14 @@ export class Invoice {
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
subtotal: number;
/** Coupon discount applied to the subtotal (Toman). total = subtotal - discountAmount. */
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
discountAmount: number;
/** Coupon code that produced discountAmount, if any. */
@Column({ nullable: true })
discountCode: string;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
total: number;
@@ -1,4 +1,27 @@
import { AppRuntime, OptionalService, PricingResourceType } from '../common/enums';
import { AppRuntime, OptionalService, PricingResourceType, ProductType } from '../common/enums';
/**
* Canonical service keys used to scope coupon discounts. Each billed
* cost-breakdown line is tagged with one of these so a discount can target
* a specific service. `['*']` / empty = all services.
*/
export const CUSTOM_DOMAIN_SERVICE_KEY = 'addon:custom_domain';
export const runtimeServiceKey = (runtime: AppRuntime | string) => `runtime:${runtime}`;
export const optionalServiceKey = (service: OptionalService | string) => `optional:${service}`;
export const productServiceKey = (product: ProductType | string) => `product:${product}`;
/** Managed (standalone) products that can be discount-targeted as a whole. */
export const DISCOUNTABLE_MANAGED_PRODUCTS: ProductType[] = [
ProductType.MANAGED_DATABASE,
ProductType.MANAGED_REDIS,
ProductType.MANAGED_RABBITMQ,
];
export const MANAGED_PRODUCT_LABELS: Record<string, string> = {
[ProductType.MANAGED_DATABASE]: 'Managed Database',
[ProductType.MANAGED_REDIS]: 'Managed Redis',
[ProductType.MANAGED_RABBITMQ]: 'Managed RabbitMQ',
};
/** All application runtimes — new enum values appear in billing automatically. */
export function getAllBillingRuntimes(): AppRuntime[] {
+78 -1
View File
@@ -15,14 +15,20 @@ import {
} from '../common/enums';
import { CalculateCostDto } from './dto/billing.dto';
import {
CUSTOM_DOMAIN_SERVICE_KEY,
DISCOUNTABLE_MANAGED_PRODUCTS,
FLUENT_BIT_SIDECAR,
getAllBillingRuntimes,
getAllOptionalServices,
getBillableAddonResourceTypes,
MANAGED_PRODUCT_LABELS,
OPTIONAL_SERVICE_BILLING_RESOURCES,
OPTIONAL_SERVICE_DEPLOY_SPECS,
OPTIONAL_SERVICE_LABELS,
optionalServiceKey,
productServiceKey,
RESOURCE_LABELS,
runtimeServiceKey,
RUNTIME_DISPLAY_LABELS,
RUNTIME_PRICING_RESOURCES,
} from './pricing-catalog.constants';
@@ -81,12 +87,20 @@ export interface CatalogOptionalServiceOption {
label: string;
}
/** Service keys (with labels) a coupon discount can target. */
export interface DiscountServiceOption {
value: string;
label: string;
group: string;
}
export interface PricingCatalogResponse {
runtimes: Record<string, PricingRateRow[]>;
optionalServices: Record<string, OptionalServiceCatalogEntry>;
customDomain: CustomDomainCatalogRow;
runtimeOptions: CatalogRuntimeOption[];
optionalServiceOptions: CatalogOptionalServiceOption[];
discountServiceOptions: DiscountServiceOption[];
}
export interface CostBreakdownLine {
@@ -94,6 +108,8 @@ export interface CostBreakdownLine {
hourly: number;
monthly: number;
yearly: number;
/** Canonical service key for discount scoping (e.g. "runtime:nodejs", "optional:redis"). */
serviceKey?: string;
}
export interface OptionalBillingContext {
@@ -228,9 +244,42 @@ export class PricingCatalogService implements OnModuleInit {
value,
label: OPTIONAL_SERVICE_LABELS[value] ?? value,
})),
discountServiceOptions: this.getDiscountServiceOptions(),
};
}
/** Full set of service keys a coupon discount can target (with labels). */
getDiscountServiceOptions(): DiscountServiceOption[] {
const options: DiscountServiceOption[] = [];
for (const runtime of getAllBillingRuntimes()) {
options.push({
value: runtimeServiceKey(runtime),
label: RUNTIME_DISPLAY_LABELS[runtime] ?? runtime,
group: 'runtime',
});
}
for (const service of getAllOptionalServices()) {
options.push({
value: optionalServiceKey(service),
label: OPTIONAL_SERVICE_LABELS[service] ?? service,
group: 'optional',
});
}
for (const product of DISCOUNTABLE_MANAGED_PRODUCTS) {
options.push({
value: productServiceKey(product),
label: MANAGED_PRODUCT_LABELS[product] ?? product,
group: 'managed',
});
}
options.push({
value: CUSTOM_DOMAIN_SERVICE_KEY,
label: 'Custom domain + SSL',
group: 'addon',
});
return options;
}
async updateCatalog(dto: UpdatePricingCatalogDto): Promise<PricingCatalogResponse> {
if (dto.runtimes) {
for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) {
@@ -345,6 +394,7 @@ export class PricingCatalogService implements OnModuleInit {
const lines: CostBreakdownLine[] = [];
const quantities = this.getQuantities(dto);
const runtimeKey = runtimeServiceKey(dto.runtime);
for (const rate of rates) {
const qty = quantities.get(rate.resourceType) ?? 0;
@@ -357,6 +407,8 @@ export class PricingCatalogService implements OnModuleInit {
Number(rate.yearlyPrice),
rate.resourceType,
dto,
false,
runtimeKey,
);
if (line) lines.push(line);
}
@@ -371,6 +423,7 @@ export class PricingCatalogService implements OnModuleInit {
): CostBreakdownLine[] {
const lines: CostBreakdownLine[] = [];
const quantities = this.getManagedDatabaseQuantities(dto);
const serviceKey = productServiceKey(ProductType.MANAGED_DATABASE);
const allowed = new Set([
PricingResourceType.DATABASE_ADDON,
PricingResourceType.CPU_PER_CORE,
@@ -390,6 +443,8 @@ export class PricingCatalogService implements OnModuleInit {
Number(rate.yearlyPrice),
rate.resourceType,
dto,
false,
serviceKey,
);
if (line) lines.push(line);
}
@@ -419,6 +474,19 @@ export class PricingCatalogService implements OnModuleInit {
const hasDatabase =
dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
const logging = !!dto.enableElasticsearch;
const productType = dto.productType ?? ProductType.APPLICATION;
// Standalone managed Redis/RabbitMQ bill under their product key; the same
// service attached to an application bills under the optional-service key.
const redisKey =
productType === ProductType.MANAGED_REDIS
? productServiceKey(ProductType.MANAGED_REDIS)
: optionalServiceKey(OptionalService.REDIS);
const rabbitmqKey =
productType === ProductType.MANAGED_RABBITMQ
? productServiceKey(ProductType.MANAGED_RABBITMQ)
: optionalServiceKey(OptionalService.RABBITMQ);
const esKey = optionalServiceKey(OptionalService.ELASTICSEARCH);
const profileFor = (service: OptionalService) =>
optional.profiles.find((p) => p.service === service);
@@ -439,6 +507,7 @@ export class PricingCatalogService implements OnModuleInit {
`${workloadLabel} log shipper`,
esRates,
this.getLogShipperQuantities(shipCpu, shipMem),
esKey,
),
);
};
@@ -455,6 +524,7 @@ export class PricingCatalogService implements OnModuleInit {
OPTIONAL_SERVICE_LABELS[OptionalService.REDIS],
profile,
ratesFor(OptionalService.REDIS),
redisKey,
),
);
if (logging) addLogShipper('Redis');
@@ -473,6 +543,7 @@ export class PricingCatalogService implements OnModuleInit {
OPTIONAL_SERVICE_LABELS[OptionalService.RABBITMQ],
profile,
ratesFor(OptionalService.RABBITMQ),
rabbitmqKey,
),
);
if (logging) addLogShipper('RabbitMQ');
@@ -494,6 +565,7 @@ export class PricingCatalogService implements OnModuleInit {
PricingResourceType.CUSTOM_DOMAIN_ADDON,
dto,
true,
CUSTOM_DOMAIN_SERVICE_KEY,
);
if (line) lines.push(line);
}
@@ -505,11 +577,13 @@ export class PricingCatalogService implements OnModuleInit {
serviceLabel: string,
profile: OptionalServiceProfile,
rates: OptionalServiceRate[],
serviceKey?: string,
): CostBreakdownLine[] {
return this.linesForResourceSlice(
serviceLabel,
rates,
this.getOptionalServiceQuantities(profile),
serviceKey,
);
}
@@ -517,6 +591,7 @@ export class PricingCatalogService implements OnModuleInit {
prefix: string,
rates: OptionalServiceRate[],
quantities: Map<PricingResourceType, number>,
serviceKey?: string,
): CostBreakdownLine[] {
const lines: CostBreakdownLine[] = [];
for (const rate of rates) {
@@ -533,6 +608,7 @@ export class PricingCatalogService implements OnModuleInit {
rate.resourceType,
{} as CalculateCostDto,
true,
serviceKey,
);
if (line) lines.push(line);
}
@@ -618,6 +694,7 @@ export class PricingCatalogService implements OnModuleInit {
resourceType: PricingResourceType,
dto: CalculateCostDto,
useFixedLabel = false,
serviceKey?: string,
): CostBreakdownLine | null {
const hourly = Math.round(quantity * hourlyUnit);
const monthly = Math.round(quantity * monthlyUnit);
@@ -627,7 +704,7 @@ export class PricingCatalogService implements OnModuleInit {
const label = useFixedLabel
? baseLabel
: this.describeLine(baseLabel, resourceType, quantity, dto);
return { label, hourly, monthly, yearly };
return { label, hourly, monthly, yearly, serviceKey };
}
private describeLine(