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:
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, 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;
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user