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