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(
@@ -0,0 +1,501 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { notify } from '@/lib/notify';
import { useT } from '@/i18n/I18nProvider';
import type { Discount, DiscountServiceOption, PricingCatalog, User } from '@/types';
import { Tag, Plus, Edit2, Trash2, X, Check } from 'lucide-react';
interface DraftDiscount {
id?: string;
code: string;
name: string;
description: string;
percentOff: number;
scopeAll: boolean;
services: string[];
isPublic: boolean;
allowedUsers: { id: string; label: string }[];
maxUses: string;
maxUsesPerUser: string;
startsAt: string;
endsAt: string;
isActive: boolean;
}
function userLabel(u: User): string {
const name = `${u.firstName ?? ''} ${u.lastName ?? ''}`.trim();
return name ? `${name}${u.email ? ` · ${u.email}` : ''}` : u.email || u.phone || u.id;
}
function emptyDraft(): DraftDiscount {
return {
code: '',
name: '',
description: '',
percentOff: 10,
scopeAll: true,
services: [],
isPublic: true,
allowedUsers: [],
maxUses: '',
maxUsesPerUser: '',
startsAt: '',
endsAt: '',
isActive: true,
};
}
function toDraft(d: Discount): DraftDiscount {
return {
id: d.id,
code: d.code,
name: d.name,
description: d.description ?? '',
percentOff: d.percentOff,
scopeAll: !d.services || d.services.length === 0,
services: d.services ?? [],
isPublic: d.isPublic,
allowedUsers: (d.allowedUserIds ?? []).map((id) => ({ id, label: id })),
maxUses: d.maxUses != null ? String(d.maxUses) : '',
maxUsesPerUser: d.maxUsesPerUser != null ? String(d.maxUsesPerUser) : '',
startsAt: d.startsAt ? d.startsAt.slice(0, 10) : '',
endsAt: d.endsAt ? d.endsAt.slice(0, 10) : '',
isActive: d.isActive,
};
}
export default function DiscountsSection() {
const t = useT();
const d = t.dashboard.billing.discounts;
const queryClient = useQueryClient();
const [draft, setDraft] = useState<DraftDiscount | null>(null);
const { data: discounts } = useQuery<Discount[]>({
queryKey: ['discounts'],
queryFn: () => api.get('/billing/discounts').then((r) => r.data),
});
const { data: catalog } = useQuery<PricingCatalog>({
queryKey: ['pricing-catalog'],
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
});
const serviceOptions = catalog?.discountServiceOptions ?? [];
const groups = useMemo(() => {
const map = new Map<string, DiscountServiceOption[]>();
for (const opt of serviceOptions) {
if (!map.has(opt.group)) map.set(opt.group, []);
map.get(opt.group)!.push(opt);
}
return Array.from(map.entries());
}, [serviceOptions]);
const saveMutation = useMutation({
mutationFn: (body: DraftDiscount) => {
const payload = {
code: body.code.trim(),
name: body.name.trim(),
description: body.description.trim() || undefined,
percentOff: body.percentOff,
services: body.scopeAll ? [] : body.services,
isPublic: body.isPublic,
allowedUserIds: body.isPublic ? [] : body.allowedUsers.map((u) => u.id),
maxUses: body.maxUses ? Number(body.maxUses) : null,
maxUsesPerUser: body.maxUsesPerUser ? Number(body.maxUsesPerUser) : null,
startsAt: body.startsAt ? new Date(body.startsAt).toISOString() : null,
endsAt: body.endsAt ? new Date(body.endsAt).toISOString() : null,
isActive: body.isActive,
};
return body.id
? api.patch(`/billing/discounts/${body.id}`, payload)
: api.post('/billing/discounts', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['discounts'] });
notify.success(d.saved);
setDraft(null);
},
onError: (err: unknown) => notify.error(err, d.saveFailed),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/billing/discounts/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['discounts'] });
notify.success(d.deleted);
},
onError: (err: unknown) => notify.error(err, d.saveFailed),
});
return (
<div className="card space-y-4 mt-8">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-start gap-3">
<Tag className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{d.title}</h2>
<p className="text-sm text-gray-500 mt-0.5">{d.subtitle}</p>
</div>
</div>
{!draft && (
<button onClick={() => setDraft(emptyDraft())} className="btn-primary text-sm flex items-center gap-2">
<Plus className="w-4 h-4" /> {d.add}
</button>
)}
</div>
{draft && (
<DiscountForm
draft={draft}
setDraft={setDraft}
groups={groups}
onSave={() => saveMutation.mutate(draft)}
saving={saveMutation.isPending}
/>
)}
{!discounts?.length && !draft ? (
<p className="text-center py-8 text-gray-400 text-sm">{d.empty}</p>
) : (
<div className="space-y-2">
{discounts?.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-3 border border-gray-200 rounded-lg p-3 flex-wrap"
>
<div className="flex items-center gap-3 min-w-0">
<span className="font-mono font-semibold text-primary-700 bg-primary-50 px-2 py-0.5 rounded">
{item.code}
</span>
<div className="min-w-0">
<p className="font-medium text-gray-900 truncate">
{item.name} · {item.percentOff}%
</p>
<p className="text-xs text-gray-500 truncate">
{item.services.length === 0 ? d.allServices : item.services.join('، ')}
{' · '}
{item.isPublic ? d.public : d.restricted}
{' · '}
{d.used}: {item.usedCount}
{item.maxUses != null ? `/${item.maxUses}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-xs px-2 py-0.5 rounded-full ${
item.isActive ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'
}`}
>
{item.isActive ? d.active : d.inactive}
</span>
<button
onClick={() => setDraft(toDraft(item))}
className="p-1.5 text-gray-500 hover:text-primary-600"
aria-label={d.edit}
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => {
if (confirm(d.deleteConfirm)) deleteMutation.mutate(item.id);
}}
className="p-1.5 text-gray-500 hover:text-red-600"
aria-label={d.delete}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
function DiscountForm({
draft,
setDraft,
groups,
onSave,
saving,
}: {
draft: DraftDiscount;
setDraft: (d: DraftDiscount | null) => void;
groups: [string, DiscountServiceOption[]][];
onSave: () => void;
saving: boolean;
}) {
const t = useT();
const d = t.dashboard.billing.discounts;
const patch = (p: Partial<DraftDiscount>) => setDraft({ ...draft, ...p });
const toggleService = (value: string) => {
patch({
services: draft.services.includes(value)
? draft.services.filter((s) => s !== value)
: [...draft.services, value],
});
};
const canSave = draft.code.trim() && draft.name.trim() && draft.percentOff > 0;
return (
<div className="border border-primary-200 bg-primary-50/30 rounded-xl p-4 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium text-gray-600">{d.code}</label>
<input
className="input-field w-full font-mono mt-0.5"
placeholder={d.codePlaceholder}
value={draft.code}
onChange={(e) => patch({ code: e.target.value.toUpperCase() })}
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600">{d.name}</label>
<input
className="input-field w-full mt-0.5"
placeholder={d.namePlaceholder}
value={draft.name}
onChange={(e) => patch({ name: e.target.value })}
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600">{d.percentOff}</label>
<input
type="number"
min={1}
max={100}
className="input-field w-full mt-0.5"
value={draft.percentOff}
onChange={(e) => patch({ percentOff: Number(e.target.value) || 0 })}
/>
</div>
</div>
<div>
<label className="text-xs font-medium text-gray-600">{d.description}</label>
<input
className="input-field w-full mt-0.5"
value={draft.description}
onChange={(e) => patch({ description: e.target.value })}
/>
</div>
{/* Scope */}
<div className="space-y-2">
<span className="text-xs font-medium text-gray-600">{d.scope}</span>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="radio" checked={draft.scopeAll} onChange={() => patch({ scopeAll: true })} />
{d.allServices}
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
checked={!draft.scopeAll}
onChange={() => patch({ scopeAll: false })}
/>
{d.selectServices}
</label>
</div>
{!draft.scopeAll && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-white p-3">
{groups.map(([group, options]) => (
<div key={group}>
<p className="text-xs font-semibold text-gray-500 mb-1">
{(d.groups as Record<string, string>)[group] ?? group}
</p>
<div className="flex flex-wrap gap-2">
{options.map((opt) => {
const active = draft.services.includes(opt.value);
return (
<button
key={opt.value}
type="button"
onClick={() => toggleService(opt.value)}
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
active
? 'bg-primary-600 text-white border-primary-600'
: 'bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100'
}`}
>
{active && <Check className="w-3 h-3 inline -mt-0.5 mr-1 rtl:mr-0 rtl:ml-1" />}
{opt.label}
</button>
);
})}
</div>
</div>
))}
</div>
)}
</div>
{/* Audience */}
<div className="space-y-2">
<span className="text-xs font-medium text-gray-600">{d.audience}</span>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="radio" checked={draft.isPublic} onChange={() => patch({ isPublic: true })} />
{d.public}
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
checked={!draft.isPublic}
onChange={() => patch({ isPublic: false })}
/>
{d.restricted}
</label>
</div>
{!draft.isPublic && (
<UserPicker
selected={draft.allowedUsers}
onChange={(allowedUsers) => patch({ allowedUsers })}
/>
)}
</div>
{/* Limits & dates */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
<div>
<label className="text-xs font-medium text-gray-600">{d.maxUses}</label>
<input
type="number"
min={1}
className="input-field w-full mt-0.5"
placeholder={d.unlimited}
value={draft.maxUses}
onChange={(e) => patch({ maxUses: e.target.value })}
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600">{d.maxUsesPerUser}</label>
<input
type="number"
min={1}
className="input-field w-full mt-0.5"
placeholder={d.unlimited}
value={draft.maxUsesPerUser}
onChange={(e) => patch({ maxUsesPerUser: e.target.value })}
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600">{d.startsAt}</label>
<input
type="date"
className="input-field w-full mt-0.5"
value={draft.startsAt}
onChange={(e) => patch({ startsAt: e.target.value })}
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600">{d.endsAt}</label>
<input
type="date"
className="input-field w-full mt-0.5"
value={draft.endsAt}
onChange={(e) => patch({ endsAt: e.target.value })}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={draft.isActive}
onChange={(e) => patch({ isActive: e.target.checked })}
/>
{d.active}
</label>
<div className="flex justify-end gap-2 pt-1">
<button onClick={() => setDraft(null)} className="btn-secondary text-sm">
{d.cancel}
</button>
<button
onClick={onSave}
disabled={!canSave || saving}
className="btn-primary text-sm disabled:opacity-50"
>
{saving ? d.saving : d.save}
</button>
</div>
</div>
);
}
function UserPicker({
selected,
onChange,
}: {
selected: { id: string; label: string }[];
onChange: (users: { id: string; label: string }[]) => void;
}) {
const t = useT();
const d = t.dashboard.billing.discounts;
const [search, setSearch] = useState('');
const { data: results } = useQuery<User[]>({
queryKey: ['discount-user-search', search],
queryFn: () => api.get(`/users?search=${encodeURIComponent(search)}`).then((r) => r.data),
enabled: search.trim().length >= 2,
});
const add = (u: User) => {
if (selected.some((s) => s.id === u.id)) return;
onChange([...selected, { id: u.id, label: userLabel(u) }]);
setSearch('');
};
return (
<div className="rounded-lg border border-gray-200 bg-white p-3 space-y-2">
<input
className="input-field w-full text-sm"
placeholder={d.searchUsers}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search.trim().length >= 2 && results && results.length > 0 && (
<div className="max-h-40 overflow-y-auto border border-gray-100 rounded-lg divide-y">
{results.slice(0, 8).map((u) => (
<button
key={u.id}
type="button"
onClick={() => add(u)}
className="w-full text-left rtl:text-right px-3 py-1.5 text-sm hover:bg-gray-50"
>
{userLabel(u)}
</button>
))}
</div>
)}
{selected.length === 0 ? (
<p className="text-xs text-gray-400">{d.noUsersSelected}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{selected.map((u) => (
<span
key={u.id}
className="inline-flex items-center gap-1 text-xs bg-gray-100 rounded-full pl-2.5 pr-1 py-0.5"
>
{u.label}
<button
type="button"
onClick={() => onChange(selected.filter((s) => s.id !== u.id))}
className="text-gray-400 hover:text-red-600"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
);
}
@@ -16,6 +16,7 @@ import type {
} from '@/types';
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
import { Select } from '@/components/ui/select';
import DiscountsSection from './DiscountsSection';
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
@@ -711,6 +712,8 @@ export default function AdminBillingPage() {
</>
)}
<DiscountsSection />
<LifecycleSettingsSection />
</div>
);
@@ -123,6 +123,7 @@ export default function AppDetailPage() {
const [snapshotTab, setSnapshotTab] = useState<'revisions' | 'snapshots'>('revisions');
const [showRenewalModal, setShowRenewalModal] = useState(false);
const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly');
const [renewCoupon, setRenewCoupon] = useState('');
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false);
const [upgradeCostData, setUpgradeCostData] = useState<{
proratedAmount: number;
@@ -262,12 +263,17 @@ export default function AppDetailPage() {
});
const renewMutation = useMutation({
mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew`, { cycle }),
mutationFn: (cycle: string) =>
api.post(`/billing/applications/${appId}/renew`, {
cycle,
couponCode: renewCoupon.trim() || undefined,
}),
onSuccess: (res) => {
notify.success(res.data.message || 'Application renewed successfully!');
queryClient.invalidateQueries({ queryKey: ['application', appId] });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
setShowRenewalModal(false);
setRenewCoupon('');
},
onError: (err: any) => {
notify.error(err, 'Failed to renew application');
@@ -276,7 +282,12 @@ export default function AppDetailPage() {
const createRenewalInvoiceMutation = useMutation({
mutationFn: (cycle: string) =>
api.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data),
api
.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, {
cycle,
couponCode: renewCoupon.trim() || undefined,
})
.then((r) => r.data),
onSuccess: (invoice) => {
notify.success(ad.invoiceCreated);
queryClient.invalidateQueries({ queryKey: ['invoices'] });
@@ -1299,10 +1310,23 @@ export default function AppDetailPage() {
})()
)}
{/* Coupon */}
<div className="mb-4">
<label className="text-sm font-medium text-gray-700">
{t.dashboard.billing.discounts.coupon.label}
</label>
<input
className="input-field w-full font-mono mt-1.5"
placeholder={t.dashboard.billing.discounts.coupon.placeholder}
value={renewCoupon}
onChange={(e) => setRenewCoupon(e.target.value.toUpperCase())}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={() => setShowRenewalModal(false)}
onClick={() => { setShowRenewalModal(false); setRenewCoupon(''); }}
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
>{t.common.cancel}</button>
<button
@@ -297,6 +297,8 @@ export default function DeployPage() {
const [isWpDragging, setIsWpDragging] = useState(false);
const wpFileInputRef = useRef<HTMLInputElement>(null);
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
const [couponCode, setCouponCode] = useState('');
const [appliedCoupon, setAppliedCoupon] = useState('');
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
const [isPaid, setIsPaid] = useState(false);
@@ -388,6 +390,7 @@ export default function DeployPage() {
form.runtime !== 'wordpress' && form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
enableCustomDomain,
cycle: selectedCycle,
couponCode: appliedCoupon || undefined,
};
// Cost calculation for the review step (includes prepaid resource credits)
@@ -404,7 +407,10 @@ export default function DeployPage() {
enabled: step >= 2,
});
const payAmount = costData?.amountDue ?? 0;
const couponDiscount = costData?.couponDiscount ?? null;
const couponDiscountAmount = couponDiscount?.valid ? couponDiscount.discountAmount ?? 0 : 0;
const beforeDiscount = costData?.amountDue ?? 0;
const payAmount = couponDiscountAmount > 0 ? (costData?.amountDueAfterDiscount ?? beforeDiscount) : beforeDiscount;
const fullPrice = costData?.fullAmount ?? 0;
const coveredAmount = costData?.coveredAmount ?? 0;
const extrasBreakdown = costData?.extrasBreakdown ?? [];
@@ -455,7 +461,10 @@ export default function DeployPage() {
// Deduct from wallet
setDeployStage('paying');
await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle });
await api.post(`/billing/wallet/pay/${appId}`, {
cycle: selectedCycle,
couponCode: appliedCoupon || undefined,
});
return res;
},
@@ -534,7 +543,10 @@ export default function DeployPage() {
// Deduct from the wallet (which was just charged by gateway)
setDeployStage('paying');
await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle });
await api.post(`/billing/wallet/pay/${appId}`, {
cycle: selectedCycle,
couponCode: appliedCoupon || undefined,
});
return res;
},
@@ -2596,6 +2608,52 @@ export default function DeployPage() {
)}
</div>
{/* Coupon */}
{costData && costData.monthly > 0 && (
<div className="bg-white rounded-xl p-4 border border-gray-200">
<label className="text-sm font-semibold text-gray-700">{t.dashboard.billing.discounts.coupon.label}</label>
<div className="flex gap-2 mt-2">
<input
className="input-field flex-1 font-mono"
placeholder={t.dashboard.billing.discounts.coupon.placeholder}
value={couponCode}
onChange={(e) => setCouponCode(e.target.value.toUpperCase())}
disabled={!!appliedCoupon}
/>
{appliedCoupon ? (
<button
type="button"
onClick={() => { setAppliedCoupon(''); setCouponCode(''); }}
className="btn-secondary text-sm shrink-0"
>
{t.dashboard.billing.discounts.coupon.remove}
</button>
) : (
<button
type="button"
onClick={() => setAppliedCoupon(couponCode.trim())}
disabled={!couponCode.trim()}
className="btn-primary text-sm shrink-0 disabled:opacity-50"
>
{t.dashboard.billing.discounts.coupon.apply}
</button>
)}
</div>
{appliedCoupon && couponDiscount && (
couponDiscount.valid ? (
<p className="text-xs text-emerald-600 mt-2 flex items-center justify-between">
<span>{t.dashboard.billing.discounts.coupon.applied} · {couponDiscount.percentOff}%</span>
<span className="font-semibold"> {Number(couponDiscountAmount).toLocaleString('en-US')} Toman</span>
</p>
) : (
<p className="text-xs text-red-500 mt-2">
{(t.dashboard.billing.discounts.reasons as Record<string, string>)[couponDiscount.reason ?? 'not_found'] ?? couponDiscount.reason}
</p>
)
)}
</div>
)}
{/* Payment Method */}
{costData && costData.monthly > 0 && !requiresPayment && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{dw.noPaymentCredit}</div>
@@ -233,6 +233,18 @@ export default function InvoicesPage() {
</div>
<div className="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
{Number(selectedInvoice.discountAmount || 0) > 0 && (
<>
<div className="flex justify-between"><span className="text-gray-500">{inv.subtotal}</span><span>{formatPrice(selectedInvoice.subtotal)} {inv.toman}</span></div>
<div className="flex justify-between text-emerald-600">
<span>
{t.dashboard.billing.discounts.coupon.discountLine}
{selectedInvoice.discountCode ? ` (${selectedInvoice.discountCode})` : ''}
</span>
<span> {formatPrice(Number(selectedInvoice.discountAmount))} {inv.toman}</span>
</div>
</>
)}
<div className="flex justify-between"><span className="text-gray-500">{inv.total}</span><span className="font-semibold">{formatPrice(selectedInvoice.total)} {inv.toman}</span></div>
<div className="flex justify-between"><span className="text-gray-500">{inv.paidLabel}</span><span className="font-semibold text-green-600">{formatPrice(selectedInvoice.paidAmount)} {inv.toman}</span></div>
<div className="flex justify-between"><span className="text-gray-500">{inv.due}</span><span className="font-semibold text-primary-700">{formatPrice(selectedInvoice.dueAmount)} {inv.toman}</span></div>
+64
View File
@@ -655,6 +655,7 @@ const en: Dictionary = {
},
title: 'Invoices',
subtitle: 'Review what each payment was for and pay open invoices.',
subtotal: 'Subtotal',
walletBalance: 'Wallet balance',
toman: 'Toman',
filterAll: 'All',
@@ -1102,6 +1103,69 @@ const en: Dictionary = {
saveFailedShort: 'Failed to save',
hours: 'hours',
days: 'days',
discounts: {
title: 'Discount codes',
subtitle: 'Percentage discounts on different services — public or for specific users.',
add: 'New discount',
empty: 'No discount codes yet',
edit: 'Edit',
delete: 'Delete',
deleteConfirm: 'Delete this discount code?',
code: 'Code',
codePlaceholder: 'NOWRUZ1403',
name: 'Label',
namePlaceholder: 'Nowruz discount',
description: 'Description',
percentOff: 'Percent off',
scope: 'Eligible services',
allServices: 'All services',
selectServices: 'Select specific services',
audience: 'Eligible users',
public: 'Public (all users)',
restricted: 'Specific users',
searchUsers: 'Search users by name or email…',
noUsersSelected: 'No users selected yet',
limits: 'Limits',
maxUses: 'Total usage cap',
maxUsesPerUser: 'Per-user cap',
unlimited: 'Unlimited',
startsAt: 'Start date',
endsAt: 'End date',
active: 'Active',
inactive: 'Inactive',
used: 'Used',
save: 'Save',
saving: 'Saving…',
cancel: 'Cancel',
saved: 'Discount code saved',
deleted: 'Discount code deleted',
saveFailed: 'Failed to save discount code',
groups: {
runtime: 'App runtimes',
optional: 'Optional services',
managed: 'Managed services',
addon: 'Add-ons',
},
coupon: {
label: 'Discount code',
placeholder: 'Enter discount code',
apply: 'Apply',
checking: 'Checking…',
applied: 'Discount applied',
remove: 'Remove',
discountLine: 'Discount',
},
reasons: {
not_found: 'Invalid discount code',
inactive: 'This discount code is inactive',
not_started: 'This code is not active yet',
expired: 'This code has expired',
max_uses_reached: 'This code has reached its usage limit',
max_uses_per_user_reached: 'You have reached your usage limit for this code',
not_eligible_user: 'This code is not available for your account',
no_eligible_services: 'This code does not discount your selected services',
},
},
},
servicesNew: {
steps: ['Service type', 'Configuration', 'Review & pay'],
+64
View File
@@ -654,6 +654,7 @@ const fa = {
},
title: 'فاکتورها',
subtitle: 'ببین هر پرداخت بابت چه بوده و فاکتورهای باز را پرداخت کن.',
subtotal: 'جمع جزء',
walletBalance: 'موجودی کیف‌پول',
toman: 'تومان',
filterAll: 'همه',
@@ -1101,6 +1102,69 @@ const fa = {
saveFailedShort: 'ذخیره ناموفق بود',
hours: 'ساعت',
days: 'روز',
discounts: {
title: 'کدهای تخفیف',
subtitle: 'تخفیف درصدی روی سرویس‌های مختلف؛ عمومی یا مخصوص کاربران خاص.',
add: 'کد تخفیف جدید',
empty: 'هنوز کد تخفیفی تعریف نشده',
edit: 'ویرایش',
delete: 'حذف',
deleteConfirm: 'این کد تخفیف حذف شود؟',
code: 'کد',
codePlaceholder: 'NOWRUZ1403',
name: 'عنوان',
namePlaceholder: 'تخفیف نوروزی',
description: 'توضیحات',
percentOff: 'درصد تخفیف',
scope: 'سرویس‌های مشمول',
allServices: 'همهٔ سرویس‌ها',
selectServices: 'انتخاب سرویس‌های خاص',
audience: 'کاربران مشمول',
public: 'عمومی (همهٔ کاربران)',
restricted: 'کاربران مشخص',
searchUsers: 'جست‌وجوی کاربر بر اساس نام یا ایمیل…',
noUsersSelected: 'هنوز کاربری انتخاب نشده',
limits: 'محدودیت‌ها',
maxUses: 'سقف کل استفاده',
maxUsesPerUser: 'سقف هر کاربر',
unlimited: 'نامحدود',
startsAt: 'تاریخ شروع',
endsAt: 'تاریخ پایان',
active: 'فعال',
inactive: 'غیرفعال',
used: 'استفاده‌شده',
save: 'ذخیره',
saving: 'در حال ذخیره…',
cancel: 'انصراف',
saved: 'کد تخفیف ذخیره شد',
deleted: 'کد تخفیف حذف شد',
saveFailed: 'ذخیرهٔ کد تخفیف ناموفق بود',
groups: {
runtime: 'رانتایم اپلیکیشن',
optional: 'سرویس‌های جانبی',
managed: 'سرویس‌های مدیریت‌شده',
addon: 'افزونه‌ها',
},
coupon: {
label: 'کد تخفیف',
placeholder: 'کد تخفیف را وارد کنید',
apply: 'اعمال',
checking: 'در حال بررسی…',
applied: 'کد تخفیف اعمال شد',
remove: 'حذف کد',
discountLine: 'تخفیف',
},
reasons: {
not_found: 'کد تخفیف نامعتبر است',
inactive: 'این کد تخفیف غیرفعال است',
not_started: 'این کد هنوز فعال نشده است',
expired: 'این کد منقضی شده است',
max_uses_reached: 'ظرفیت استفاده از این کد تمام شده است',
max_uses_per_user_reached: 'سقف استفادهٔ شما از این کد پر شده است',
not_eligible_user: 'این کد برای حساب شما قابل استفاده نیست',
no_eligible_services: 'این کد روی سرویس‌های انتخابی شما تخفیف ندارد',
},
},
},
servicesNew: {
steps: ['نوع سرویس', 'پیکربندی', 'بررسی و پرداخت'],
+50
View File
@@ -704,12 +704,49 @@ export interface CatalogOptionalServiceOption {
label: string;
}
export interface DiscountServiceOption {
value: string;
label: string;
group: string;
}
export interface PricingCatalog {
runtimes: Record<string, PricingRateRow[]>;
optionalServices: Record<string, OptionalServiceCatalogEntry>;
customDomain: CustomDomainCatalogRow;
runtimeOptions: CatalogRuntimeOption[];
optionalServiceOptions: CatalogOptionalServiceOption[];
discountServiceOptions: DiscountServiceOption[];
}
export interface Discount {
id: string;
code: string;
name: string;
description?: string;
percentOff: number;
services: string[];
isPublic: boolean;
allowedUserIds: string[];
maxUses: number | null;
maxUsesPerUser: number | null;
usedCount: number;
startsAt: string | null;
endsAt: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
/** Result of POST /billing/discounts/validate */
export interface DiscountValidation {
valid: boolean;
reason?: string;
code?: string;
name?: string;
percentOff?: number;
eligibleAmount?: number;
discountAmount?: number;
}
export interface WalletBalance {
@@ -753,6 +790,8 @@ export interface Invoice {
status: InvoiceStatus;
paymentMethod?: PaymentMethod;
subtotal: number;
discountAmount?: number;
discountCode?: string;
total: number;
paidAmount: number;
dueAmount: number;
@@ -794,6 +833,15 @@ export interface DeployExtraChargeLine {
fullPeriodAmount?: number;
}
export interface CouponDiscountPreview {
valid: boolean;
reason?: string;
code?: string;
name?: string;
percentOff?: number;
discountAmount?: number;
}
export interface DeployCostPreview extends CostBreakdown {
cycle: BillingCycle;
fullAmount: number;
@@ -805,6 +853,8 @@ export interface DeployCostPreview extends CostBreakdown {
prepaidCreditUsed: boolean;
prorateRemainingDays?: number;
proratePeriodDays?: number;
couponDiscount?: CouponDiscountPreview | null;
amountDueAfterDiscount?: number;
}
// ─── Snapshot / Rollback types ──────────────────────