From 49726f1dfdbba17358cf4c32c9e4cd58aa79dfbf Mon Sep 17 00:00:00 2001 From: keyhan Date: Sat, 20 Jun 2026 11:48:45 +0330 Subject: [PATCH] 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 --- backend/src/billing/billing.controller.ts | 65 ++- backend/src/billing/billing.module.ts | 12 +- backend/src/billing/billing.service.ts | 137 ++++- backend/src/billing/discount.controller.ts | 124 +++++ backend/src/billing/discount.service.spec.ts | 149 ++++++ backend/src/billing/discount.service.ts | 319 +++++++++++ backend/src/billing/dto/billing.dto.ts | 26 + backend/src/billing/dto/discount.dto.ts | 191 +++++++ .../entities/discount-redemption.entity.ts | 38 ++ .../src/billing/entities/discount.entity.ts | 72 +++ .../src/billing/entities/invoice.entity.ts | 8 + .../src/billing/pricing-catalog.constants.ts | 25 +- .../src/billing/pricing-catalog.service.ts | 79 ++- .../admin/billing/DiscountsSection.tsx | 501 ++++++++++++++++++ .../[lang]/dashboard/admin/billing/page.tsx | 3 + .../app/[lang]/dashboard/apps/[id]/page.tsx | 30 +- .../src/app/[lang]/dashboard/deploy/page.tsx | 64 ++- .../app/[lang]/dashboard/invoices/page.tsx | 12 + frontend/src/i18n/dictionaries/en.ts | 64 +++ frontend/src/i18n/dictionaries/fa.ts | 64 +++ frontend/src/types/index.ts | 50 ++ 21 files changed, 2013 insertions(+), 20 deletions(-) create mode 100644 backend/src/billing/discount.controller.ts create mode 100644 backend/src/billing/discount.service.spec.ts create mode 100644 backend/src/billing/discount.service.ts create mode 100644 backend/src/billing/dto/discount.dto.ts create mode 100644 backend/src/billing/entities/discount-redemption.entity.ts create mode 100644 backend/src/billing/entities/discount.entity.ts create mode 100644 frontend/src/app/[lang]/dashboard/admin/billing/DiscountsSection.tsx diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index 67fcc6d..2998c9c 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -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; diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts index 155736d..c8f0f34 100644 --- a/backend/src/billing/billing.module.ts +++ b/backend/src/billing/billing.module.ts @@ -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 {} diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index ef54cac..593ae6a 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -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, @InjectRepository(WalletTransaction) private txRepo: Repository, @InjectRepository(ResourceCredit) private creditRepo: Repository, @@ -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 { + return (await this.pricingCatalog.computeTotalsFromDb(dto)).breakdown; + } + + /** Service-tagged cost breakdown for an existing app (renewal). */ + async getAppChargeBreakdown(app: Application): Promise { + 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 { + 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 }[]; dueDate?: Date; metadata?: Record; + discount?: { discountId: string; code: string; amount: number }; }): Promise { 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, }; } diff --git a/backend/src/billing/discount.controller.ts b/backend/src/billing/discount.controller.ts new file mode 100644 index 0000000..c4edb86 --- /dev/null +++ b/backend/src/billing/discount.controller.ts @@ -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 { + 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); + } +} diff --git a/backend/src/billing/discount.service.spec.ts b/backend/src/billing/discount.service.spec.ts new file mode 100644 index 0000000..003cbfc --- /dev/null +++ b/backend/src/billing/discount.service.spec.ts @@ -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 { + 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); + }); + }); +}); diff --git a/backend/src/billing/discount.service.ts b/backend/src/billing/discount.service.ts new file mode 100644 index 0000000..4bc7865 --- /dev/null +++ b/backend/src/billing/discount.service.ts @@ -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, + @InjectRepository(DiscountRedemption) + private readonly redemptionRepo: Repository, + 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 { + return this.discountRepo.find({ order: { createdAt: 'DESC' } }); + } + + async getById(id: string): Promise { + 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 { + 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 { + 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 { + return this.discountRepo.findOne({ where: { code: this.normalizeCode(code) } }); + } + + async userRedemptionCount(discountId: string, userId: string): Promise { + 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 { + 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 { + 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 { + 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)`); + } +} diff --git a/backend/src/billing/dto/billing.dto.ts b/backend/src/billing/dto/billing.dto.ts index 27047c3..ce15cd2 100644 --- a/backend/src/billing/dto/billing.dto.ts +++ b/backend/src/billing/dto/billing.dto.ts @@ -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 {} diff --git a/backend/src/billing/dto/discount.dto.ts b/backend/src/billing/dto/discount.dto.ts new file mode 100644 index 0000000..5455b21 --- /dev/null +++ b/backend/src/billing/dto/discount.dto.ts @@ -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 (1–100)' }) + @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; +} diff --git a/backend/src/billing/entities/discount-redemption.entity.ts b/backend/src/billing/entities/discount-redemption.entity.ts new file mode 100644 index 0000000..064e72c --- /dev/null +++ b/backend/src/billing/entities/discount-redemption.entity.ts @@ -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; +} diff --git a/backend/src/billing/entities/discount.entity.ts b/backend/src/billing/entities/discount.entity.ts new file mode 100644 index 0000000..98f982e --- /dev/null +++ b/backend/src/billing/entities/discount.entity.ts @@ -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, 1–100. */ + @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; +} diff --git a/backend/src/billing/entities/invoice.entity.ts b/backend/src/billing/entities/invoice.entity.ts index 72e6d98..2c154dd 100644 --- a/backend/src/billing/entities/invoice.entity.ts +++ b/backend/src/billing/entities/invoice.entity.ts @@ -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; diff --git a/backend/src/billing/pricing-catalog.constants.ts b/backend/src/billing/pricing-catalog.constants.ts index 276e143..c377cde 100644 --- a/backend/src/billing/pricing-catalog.constants.ts +++ b/backend/src/billing/pricing-catalog.constants.ts @@ -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 = { + [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[] { diff --git a/backend/src/billing/pricing-catalog.service.ts b/backend/src/billing/pricing-catalog.service.ts index 5d42853..ae64d0b 100644 --- a/backend/src/billing/pricing-catalog.service.ts +++ b/backend/src/billing/pricing-catalog.service.ts @@ -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; optionalServices: Record; 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 { 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, + 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( diff --git a/frontend/src/app/[lang]/dashboard/admin/billing/DiscountsSection.tsx b/frontend/src/app/[lang]/dashboard/admin/billing/DiscountsSection.tsx new file mode 100644 index 0000000..3ea71f3 --- /dev/null +++ b/frontend/src/app/[lang]/dashboard/admin/billing/DiscountsSection.tsx @@ -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(null); + + const { data: discounts } = useQuery({ + queryKey: ['discounts'], + queryFn: () => api.get('/billing/discounts').then((r) => r.data), + }); + const { data: catalog } = useQuery({ + queryKey: ['pricing-catalog'], + queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data), + }); + + const serviceOptions = catalog?.discountServiceOptions ?? []; + const groups = useMemo(() => { + const map = new Map(); + 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 ( +
+
+
+ +
+

{d.title}

+

{d.subtitle}

+
+
+ {!draft && ( + + )} +
+ + {draft && ( + saveMutation.mutate(draft)} + saving={saveMutation.isPending} + /> + )} + + {!discounts?.length && !draft ? ( +

{d.empty}

+ ) : ( +
+ {discounts?.map((item) => ( +
+
+ + {item.code} + +
+

+ {item.name} · {item.percentOff}% +

+

+ {item.services.length === 0 ? d.allServices : item.services.join('، ')} + {' · '} + {item.isPublic ? d.public : d.restricted} + {' · '} + {d.used}: {item.usedCount} + {item.maxUses != null ? `/${item.maxUses}` : ''} +

+
+
+
+ + {item.isActive ? d.active : d.inactive} + + + +
+
+ ))} +
+ )} +
+ ); +} + +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) => 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 ( +
+
+
+ + patch({ code: e.target.value.toUpperCase() })} + /> +
+
+ + patch({ name: e.target.value })} + /> +
+
+ + patch({ percentOff: Number(e.target.value) || 0 })} + /> +
+
+ +
+ + patch({ description: e.target.value })} + /> +
+ + {/* Scope */} +
+ {d.scope} +
+ + +
+ {!draft.scopeAll && ( +
+ {groups.map(([group, options]) => ( +
+

+ {(d.groups as Record)[group] ?? group} +

+
+ {options.map((opt) => { + const active = draft.services.includes(opt.value); + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ + {/* Audience */} +
+ {d.audience} +
+ + +
+ {!draft.isPublic && ( + patch({ allowedUsers })} + /> + )} +
+ + {/* Limits & dates */} +
+
+ + patch({ maxUses: e.target.value })} + /> +
+
+ + patch({ maxUsesPerUser: e.target.value })} + /> +
+
+ + patch({ startsAt: e.target.value })} + /> +
+
+ + patch({ endsAt: e.target.value })} + /> +
+
+ + + +
+ + +
+
+ ); +} + +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({ + 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 ( +
+ setSearch(e.target.value)} + /> + {search.trim().length >= 2 && results && results.length > 0 && ( +
+ {results.slice(0, 8).map((u) => ( + + ))} +
+ )} + {selected.length === 0 ? ( +

{d.noUsersSelected}

+ ) : ( +
+ {selected.map((u) => ( + + {u.label} + + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx b/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx index 90e10fa..2b156e2 100644 --- a/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx @@ -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() { )} + + ); diff --git a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx index 7d428ef..4eb5715 100644 --- a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx @@ -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(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data), + api + .post(`/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 */} +
+ + setRenewCoupon(e.target.value.toUpperCase())} + /> +
+ {/* Actions */}
+ {/* Coupon */} + {costData && costData.monthly > 0 && ( +
+ +
+ setCouponCode(e.target.value.toUpperCase())} + disabled={!!appliedCoupon} + /> + {appliedCoupon ? ( + + ) : ( + + )} +
+ {appliedCoupon && couponDiscount && ( + couponDiscount.valid ? ( +

+ {t.dashboard.billing.discounts.coupon.applied} · {couponDiscount.percentOff}% + − {Number(couponDiscountAmount).toLocaleString('en-US')} Toman +

+ ) : ( +

+ {(t.dashboard.billing.discounts.reasons as Record)[couponDiscount.reason ?? 'not_found'] ?? couponDiscount.reason} +

+ ) + )} +
+ )} + {/* Payment Method */} {costData && costData.monthly > 0 && !requiresPayment && (
{dw.noPaymentCredit}
diff --git a/frontend/src/app/[lang]/dashboard/invoices/page.tsx b/frontend/src/app/[lang]/dashboard/invoices/page.tsx index 3f4729e..efbae28 100644 --- a/frontend/src/app/[lang]/dashboard/invoices/page.tsx +++ b/frontend/src/app/[lang]/dashboard/invoices/page.tsx @@ -233,6 +233,18 @@ export default function InvoicesPage() {
+ {Number(selectedInvoice.discountAmount || 0) > 0 && ( + <> +
{inv.subtotal}{formatPrice(selectedInvoice.subtotal)} {inv.toman}
+
+ + {t.dashboard.billing.discounts.coupon.discountLine} + {selectedInvoice.discountCode ? ` (${selectedInvoice.discountCode})` : ''} + + − {formatPrice(Number(selectedInvoice.discountAmount))} {inv.toman} +
+ + )}
{inv.total}{formatPrice(selectedInvoice.total)} {inv.toman}
{inv.paidLabel}{formatPrice(selectedInvoice.paidAmount)} {inv.toman}
{inv.due}{formatPrice(selectedInvoice.dueAmount)} {inv.toman}
diff --git a/frontend/src/i18n/dictionaries/en.ts b/frontend/src/i18n/dictionaries/en.ts index f7151ba..f73b5e6 100644 --- a/frontend/src/i18n/dictionaries/en.ts +++ b/frontend/src/i18n/dictionaries/en.ts @@ -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'], diff --git a/frontend/src/i18n/dictionaries/fa.ts b/frontend/src/i18n/dictionaries/fa.ts index 0370661..af1d761 100644 --- a/frontend/src/i18n/dictionaries/fa.ts +++ b/frontend/src/i18n/dictionaries/fa.ts @@ -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: ['نوع سرویس', 'پیکربندی', 'بررسی و پرداخت'], diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4e2f229..365a887 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -704,12 +704,49 @@ export interface CatalogOptionalServiceOption { label: string; } +export interface DiscountServiceOption { + value: string; + label: string; + group: string; +} + export interface PricingCatalog { runtimes: Record; optionalServices: Record; 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 ──────────────────────