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,
|
InitiateInvoicePaymentDto,
|
||||||
VerifyInvoiceGatewayDto,
|
VerifyInvoiceGatewayDto,
|
||||||
UpdateInvoiceStatusDto,
|
UpdateInvoiceStatusDto,
|
||||||
|
PayApplicationDto,
|
||||||
} from './dto/billing.dto';
|
} from './dto/billing.dto';
|
||||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
@@ -94,7 +95,7 @@ export class BillingController {
|
|||||||
if (!Object.values(BillingCycle).includes(dto.cycle)) {
|
if (!Object.values(BillingCycle).includes(dto.cycle)) {
|
||||||
throw new BadRequestException(`Invalid billing cycle: ${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 ─────────────────────────────────────
|
// ─── Custom Domain Pricing ─────────────────────────────────────
|
||||||
@@ -212,7 +213,7 @@ export class BillingController {
|
|||||||
async payForApplication(
|
async payForApplication(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Param('applicationId') applicationId: string,
|
@Param('applicationId') applicationId: string,
|
||||||
@Body() body: { cycle: string },
|
@Body() body: PayApplicationDto,
|
||||||
) {
|
) {
|
||||||
const cycle = body.cycle as BillingCycle;
|
const cycle = body.cycle as BillingCycle;
|
||||||
if (!Object.values(BillingCycle).includes(cycle)) {
|
if (!Object.values(BillingCycle).includes(cycle)) {
|
||||||
@@ -227,6 +228,14 @@ export class BillingController {
|
|||||||
cycle,
|
cycle,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const coupon = await this.billingService.resolveCoupon(
|
||||||
|
req.user.id,
|
||||||
|
body.couponCode,
|
||||||
|
await this.billingService.getAppChargeBreakdown(app),
|
||||||
|
cycle,
|
||||||
|
payment.amountDue,
|
||||||
|
);
|
||||||
|
|
||||||
let invoice = null;
|
let invoice = null;
|
||||||
if (payment.amountDue > 0) {
|
if (payment.amountDue > 0) {
|
||||||
invoice = await this.billingService.createInvoice({
|
invoice = await this.billingService.createInvoice({
|
||||||
@@ -249,6 +258,7 @@ export class BillingController {
|
|||||||
action: 'activate',
|
action: 'activate',
|
||||||
cycle,
|
cycle,
|
||||||
},
|
},
|
||||||
|
discount: coupon ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +279,9 @@ export class BillingController {
|
|||||||
invoice,
|
invoice,
|
||||||
creditApplied: payment.creditId || null,
|
creditApplied: payment.creditId || null,
|
||||||
waivedAmount: payment.waivedAmount,
|
waivedAmount: payment.waivedAmount,
|
||||||
paidAmount: payment.amountDue,
|
discountAmount: coupon?.amount ?? 0,
|
||||||
|
discountCode: coupon?.code ?? null,
|
||||||
|
paidAmount: invoice ? Number(invoice.total) : 0,
|
||||||
application: {
|
application: {
|
||||||
id: activated.id,
|
id: activated.id,
|
||||||
name: activated.name,
|
name: activated.name,
|
||||||
@@ -423,6 +435,14 @@ export class BillingController {
|
|||||||
throw new BadRequestException('Invalid cost calculation — no pricing rules found');
|
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({
|
return this.billingService.createInvoice({
|
||||||
userId: app.userId,
|
userId: app.userId,
|
||||||
applicationId: app.id,
|
applicationId: app.id,
|
||||||
@@ -436,6 +456,7 @@ export class BillingController {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
metadata: { action: 'renew', cycle: dto.cycle },
|
metadata: { action: 'renew', cycle: dto.cycle },
|
||||||
|
discount: coupon ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +484,14 @@ export class BillingController {
|
|||||||
? app.userId
|
? app.userId
|
||||||
: req.user.id;
|
: 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({
|
const invoice = await this.billingService.createInvoice({
|
||||||
userId: walletUserId,
|
userId: walletUserId,
|
||||||
applicationId: app.id,
|
applicationId: app.id,
|
||||||
@@ -476,6 +505,7 @@ export class BillingController {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
metadata: { action: 'renew', cycle: dto.cycle },
|
metadata: { action: 'renew', cycle: dto.cycle },
|
||||||
|
discount: coupon ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
||||||
@@ -504,7 +534,7 @@ export class BillingController {
|
|||||||
async adminRenewApplication(
|
async adminRenewApplication(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Param('applicationId') applicationId: string,
|
@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 app = await this.applicationsService.findOne(applicationId);
|
||||||
const cycle = body.cycle as BillingCycle;
|
const cycle = body.cycle as BillingCycle;
|
||||||
@@ -536,6 +566,14 @@ export class BillingController {
|
|||||||
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||||
: costs.yearly;
|
: 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({
|
const invoice = await this.billingService.createInvoice({
|
||||||
userId: app.userId,
|
userId: app.userId,
|
||||||
applicationId: app.id,
|
applicationId: app.id,
|
||||||
@@ -549,6 +587,7 @@ export class BillingController {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
metadata: { action: 'renew', cycle, initiatedBy: req.user.role },
|
metadata: { action: 'renew', cycle, initiatedBy: req.user.role },
|
||||||
|
discount: coupon ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
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');
|
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({
|
return this.billingService.createInvoice({
|
||||||
userId: app.userId,
|
userId: app.userId,
|
||||||
applicationId: app.id,
|
applicationId: app.id,
|
||||||
@@ -646,6 +693,7 @@ export class BillingController {
|
|||||||
resources: dto,
|
resources: dto,
|
||||||
remainingHours: costResult.remainingHours,
|
remainingHours: costResult.remainingHours,
|
||||||
},
|
},
|
||||||
|
discount: coupon ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,6 +723,14 @@ export class BillingController {
|
|||||||
? app.userId
|
? app.userId
|
||||||
: req.user.id;
|
: 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({
|
const invoice = await this.billingService.createInvoice({
|
||||||
userId: walletUserId,
|
userId: walletUserId,
|
||||||
applicationId: app.id,
|
applicationId: app.id,
|
||||||
@@ -696,6 +752,7 @@ export class BillingController {
|
|||||||
resources: dto,
|
resources: dto,
|
||||||
remainingHours: costResult.remainingHours,
|
remainingHours: costResult.remainingHours,
|
||||||
},
|
},
|
||||||
|
discount: coupon ?? undefined,
|
||||||
});
|
});
|
||||||
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
||||||
paidInvoice = paid.invoice;
|
paidInvoice = paid.invoice;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Module, forwardRef } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
import { BillingController } from './billing.controller';
|
import { BillingController } from './billing.controller';
|
||||||
|
import { DiscountController } from './discount.controller';
|
||||||
|
import { DiscountService } from './discount.service';
|
||||||
import { PricingCatalogService } from './pricing-catalog.service';
|
import { PricingCatalogService } from './pricing-catalog.service';
|
||||||
import { PricingRate } from './entities/pricing-rate.entity';
|
import { PricingRate } from './entities/pricing-rate.entity';
|
||||||
import { AddonRate } from './entities/addon-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 { ResourceCredit } from './entities/resource-credit.entity';
|
||||||
import { Invoice } from './entities/invoice.entity';
|
import { Invoice } from './entities/invoice.entity';
|
||||||
import { InvoiceLine } from './entities/invoice-line.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 { LifecycleModule } from '../lifecycle/lifecycle.module';
|
||||||
import { ApplicationsModule } from '../applications/applications.module';
|
import { ApplicationsModule } from '../applications/applications.module';
|
||||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
@@ -28,13 +32,15 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
|||||||
ResourceCredit,
|
ResourceCredit,
|
||||||
Invoice,
|
Invoice,
|
||||||
InvoiceLine,
|
InvoiceLine,
|
||||||
|
Discount,
|
||||||
|
DiscountRedemption,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => LifecycleModule),
|
forwardRef(() => LifecycleModule),
|
||||||
forwardRef(() => ApplicationsModule),
|
forwardRef(() => ApplicationsModule),
|
||||||
forwardRef(() => KubernetesModule),
|
forwardRef(() => KubernetesModule),
|
||||||
],
|
],
|
||||||
controllers: [BillingController],
|
controllers: [BillingController, DiscountController],
|
||||||
providers: [BillingService, PricingCatalogService],
|
providers: [BillingService, PricingCatalogService, DiscountService],
|
||||||
exports: [BillingService, PricingCatalogService],
|
exports: [BillingService, PricingCatalogService, DiscountService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
|
|||||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||||
import { Application } from '../applications/entities/application.entity';
|
import { Application } from '../applications/entities/application.entity';
|
||||||
import { ResourceCredit } from './entities/resource-credit.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()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
@@ -28,6 +30,7 @@ export class BillingService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pricingCatalog: PricingCatalogService,
|
private readonly pricingCatalog: PricingCatalogService,
|
||||||
|
private readonly discountService: DiscountService,
|
||||||
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||||
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
|
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
|
||||||
@@ -55,11 +58,69 @@ export class BillingService {
|
|||||||
hourly: number;
|
hourly: number;
|
||||||
monthly: number;
|
monthly: number;
|
||||||
yearly: number;
|
yearly: number;
|
||||||
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
|
breakdown: CostBreakdownLine[];
|
||||||
}> {
|
}> {
|
||||||
return this.pricingCatalog.computeTotalsFromDb(dto);
|
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) ─────
|
// ─── Optional services & custom domain (delegates to catalog) ─────
|
||||||
|
|
||||||
getOptionalServicesPricing() {
|
getOptionalServicesPricing() {
|
||||||
@@ -249,6 +310,7 @@ export class BillingService {
|
|||||||
lines: { label: string; description?: string; quantity?: number; unitAmount?: number; amount: number; metadata?: Record<string, any> }[];
|
lines: { label: string; description?: string; quantity?: number; unitAmount?: number; amount: number; metadata?: Record<string, any> }[];
|
||||||
dueDate?: Date;
|
dueDate?: Date;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
|
discount?: { discountId: string; code: string; amount: number };
|
||||||
}): Promise<Invoice> {
|
}): Promise<Invoice> {
|
||||||
const lines = input.lines
|
const lines = input.lines
|
||||||
.filter((line) => this.normalizeAmount(line.amount) > 0)
|
.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) {
|
if (total <= 0) {
|
||||||
throw new BadRequestException('Invoice total must be positive');
|
throw new BadRequestException('Invoice total must be positive');
|
||||||
}
|
}
|
||||||
@@ -276,12 +342,16 @@ export class BillingService {
|
|||||||
applicationId: input.applicationId,
|
applicationId: input.applicationId,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
status: InvoiceStatus.ISSUED,
|
status: InvoiceStatus.ISSUED,
|
||||||
subtotal: total,
|
subtotal,
|
||||||
|
discountAmount,
|
||||||
|
discountCode: input.discount?.code,
|
||||||
total,
|
total,
|
||||||
paidAmount: 0,
|
paidAmount: 0,
|
||||||
dueAmount: total,
|
dueAmount: total,
|
||||||
dueDate: input.dueDate,
|
dueDate: input.dueDate,
|
||||||
metadata: input.metadata,
|
metadata: input.discount
|
||||||
|
? { ...(input.metadata || {}), discountId: input.discount.discountId }
|
||||||
|
: input.metadata,
|
||||||
lines,
|
lines,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,6 +437,21 @@ export class BillingService {
|
|||||||
invoice.paidAt = dueAmount <= 0 ? new Date() : invoice.paidAt;
|
invoice.paidAt = dueAmount <= 0 ? new Date() : invoice.paidAt;
|
||||||
invoice.gatewayTrackingCode = gatewayTrackingCode || invoice.gatewayTrackingCode;
|
invoice.gatewayTrackingCode = gatewayTrackingCode || invoice.gatewayTrackingCode;
|
||||||
invoice.gatewayReference = gatewayReference || invoice.gatewayReference;
|
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);
|
return this.invoiceRepo.save(invoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,6 +1225,7 @@ export class BillingService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
dto: CalculateCostDto,
|
dto: CalculateCostDto,
|
||||||
cycle: BillingCycle,
|
cycle: BillingCycle,
|
||||||
|
couponCode?: string,
|
||||||
) {
|
) {
|
||||||
const costs = await this.calculateCost(dto);
|
const costs = await this.calculateCost(dto);
|
||||||
const fullAmount = this.amountForCycle(costs, cycle);
|
const fullAmount = this.amountForCycle(costs, cycle);
|
||||||
@@ -1152,7 +1238,13 @@ export class BillingService {
|
|||||||
} as CalculateCostDto);
|
} as CalculateCostDto);
|
||||||
const credit = await this.findApplicableCredit(userId, config);
|
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) {
|
if (!credit) {
|
||||||
|
const couponDiscount = await couponFor(fullAmount);
|
||||||
return {
|
return {
|
||||||
...costs,
|
...costs,
|
||||||
cycle,
|
cycle,
|
||||||
@@ -1163,6 +1255,9 @@ export class BillingService {
|
|||||||
extrasBreakdown: [],
|
extrasBreakdown: [],
|
||||||
creditApplied: null,
|
creditApplied: null,
|
||||||
prepaidCreditUsed: false,
|
prepaidCreditUsed: false,
|
||||||
|
couponDiscount,
|
||||||
|
amountDueAfterDiscount:
|
||||||
|
fullAmount - (couponDiscount && couponDiscount.valid ? couponDiscount.discountAmount : 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1170,6 +1265,7 @@ export class BillingService {
|
|||||||
await this.calculateExtrasBeyondCredit(config, credit, cycle);
|
await this.calculateExtrasBeyondCredit(config, credit, cycle);
|
||||||
const waivedAmount = Math.max(0, fullAmount - extrasDue);
|
const waivedAmount = Math.max(0, fullAmount - extrasDue);
|
||||||
const prorate = this.getCreditProrateFactor(credit);
|
const prorate = this.getCreditProrateFactor(credit);
|
||||||
|
const couponDiscount = await couponFor(extrasDue);
|
||||||
return {
|
return {
|
||||||
...costs,
|
...costs,
|
||||||
cycle,
|
cycle,
|
||||||
@@ -1182,6 +1278,37 @@ export class BillingService {
|
|||||||
prepaidCreditUsed: waivedAmount > 0,
|
prepaidCreditUsed: waivedAmount > 0,
|
||||||
prorateRemainingDays: prorate.remainingDays,
|
prorateRemainingDays: prorate.remainingDays,
|
||||||
proratePeriodDays: prorate.periodDays,
|
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' })
|
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
|
||||||
@IsEnum(BillingCycle)
|
@IsEnum(BillingCycle)
|
||||||
cycle: 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 ─────────────────────────────────────────
|
// ─── Renewal & Upgrade DTOs ─────────────────────────────────────────
|
||||||
@@ -180,6 +196,11 @@ export class RenewApplicationDto {
|
|||||||
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
|
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
|
||||||
@IsEnum(BillingCycle)
|
@IsEnum(BillingCycle)
|
||||||
cycle: BillingCycle;
|
cycle: BillingCycle;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'NOWRUZ1403', description: 'Coupon discount code' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
couponCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpgradeResourcesDto {
|
export class UpgradeResourcesDto {
|
||||||
@@ -236,6 +257,11 @@ export class UpgradeResourcesDto {
|
|||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => OptionalServiceResourcesDto)
|
@Type(() => OptionalServiceResourcesDto)
|
||||||
rabbitmqResources?: OptionalServiceResourcesDto;
|
rabbitmqResources?: OptionalServiceResourcesDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'NOWRUZ1403', description: 'Coupon discount code' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
couponCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}
|
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 })
|
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
|
||||||
subtotal: number;
|
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 })
|
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
|
||||||
total: number;
|
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. */
|
/** All application runtimes — new enum values appear in billing automatically. */
|
||||||
export function getAllBillingRuntimes(): AppRuntime[] {
|
export function getAllBillingRuntimes(): AppRuntime[] {
|
||||||
|
|||||||
@@ -15,14 +15,20 @@ import {
|
|||||||
} from '../common/enums';
|
} from '../common/enums';
|
||||||
import { CalculateCostDto } from './dto/billing.dto';
|
import { CalculateCostDto } from './dto/billing.dto';
|
||||||
import {
|
import {
|
||||||
|
CUSTOM_DOMAIN_SERVICE_KEY,
|
||||||
|
DISCOUNTABLE_MANAGED_PRODUCTS,
|
||||||
FLUENT_BIT_SIDECAR,
|
FLUENT_BIT_SIDECAR,
|
||||||
getAllBillingRuntimes,
|
getAllBillingRuntimes,
|
||||||
getAllOptionalServices,
|
getAllOptionalServices,
|
||||||
getBillableAddonResourceTypes,
|
getBillableAddonResourceTypes,
|
||||||
|
MANAGED_PRODUCT_LABELS,
|
||||||
OPTIONAL_SERVICE_BILLING_RESOURCES,
|
OPTIONAL_SERVICE_BILLING_RESOURCES,
|
||||||
OPTIONAL_SERVICE_DEPLOY_SPECS,
|
OPTIONAL_SERVICE_DEPLOY_SPECS,
|
||||||
OPTIONAL_SERVICE_LABELS,
|
OPTIONAL_SERVICE_LABELS,
|
||||||
|
optionalServiceKey,
|
||||||
|
productServiceKey,
|
||||||
RESOURCE_LABELS,
|
RESOURCE_LABELS,
|
||||||
|
runtimeServiceKey,
|
||||||
RUNTIME_DISPLAY_LABELS,
|
RUNTIME_DISPLAY_LABELS,
|
||||||
RUNTIME_PRICING_RESOURCES,
|
RUNTIME_PRICING_RESOURCES,
|
||||||
} from './pricing-catalog.constants';
|
} from './pricing-catalog.constants';
|
||||||
@@ -81,12 +87,20 @@ export interface CatalogOptionalServiceOption {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Service keys (with labels) a coupon discount can target. */
|
||||||
|
export interface DiscountServiceOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
group: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingCatalogResponse {
|
export interface PricingCatalogResponse {
|
||||||
runtimes: Record<string, PricingRateRow[]>;
|
runtimes: Record<string, PricingRateRow[]>;
|
||||||
optionalServices: Record<string, OptionalServiceCatalogEntry>;
|
optionalServices: Record<string, OptionalServiceCatalogEntry>;
|
||||||
customDomain: CustomDomainCatalogRow;
|
customDomain: CustomDomainCatalogRow;
|
||||||
runtimeOptions: CatalogRuntimeOption[];
|
runtimeOptions: CatalogRuntimeOption[];
|
||||||
optionalServiceOptions: CatalogOptionalServiceOption[];
|
optionalServiceOptions: CatalogOptionalServiceOption[];
|
||||||
|
discountServiceOptions: DiscountServiceOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CostBreakdownLine {
|
export interface CostBreakdownLine {
|
||||||
@@ -94,6 +108,8 @@ export interface CostBreakdownLine {
|
|||||||
hourly: number;
|
hourly: number;
|
||||||
monthly: number;
|
monthly: number;
|
||||||
yearly: number;
|
yearly: number;
|
||||||
|
/** Canonical service key for discount scoping (e.g. "runtime:nodejs", "optional:redis"). */
|
||||||
|
serviceKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptionalBillingContext {
|
export interface OptionalBillingContext {
|
||||||
@@ -228,9 +244,42 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
value,
|
value,
|
||||||
label: OPTIONAL_SERVICE_LABELS[value] ?? 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> {
|
async updateCatalog(dto: UpdatePricingCatalogDto): Promise<PricingCatalogResponse> {
|
||||||
if (dto.runtimes) {
|
if (dto.runtimes) {
|
||||||
for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) {
|
for (const [runtimeKey, rows] of Object.entries(dto.runtimes)) {
|
||||||
@@ -345,6 +394,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
|
|
||||||
const lines: CostBreakdownLine[] = [];
|
const lines: CostBreakdownLine[] = [];
|
||||||
const quantities = this.getQuantities(dto);
|
const quantities = this.getQuantities(dto);
|
||||||
|
const runtimeKey = runtimeServiceKey(dto.runtime);
|
||||||
|
|
||||||
for (const rate of rates) {
|
for (const rate of rates) {
|
||||||
const qty = quantities.get(rate.resourceType) ?? 0;
|
const qty = quantities.get(rate.resourceType) ?? 0;
|
||||||
@@ -357,6 +407,8 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
Number(rate.yearlyPrice),
|
Number(rate.yearlyPrice),
|
||||||
rate.resourceType,
|
rate.resourceType,
|
||||||
dto,
|
dto,
|
||||||
|
false,
|
||||||
|
runtimeKey,
|
||||||
);
|
);
|
||||||
if (line) lines.push(line);
|
if (line) lines.push(line);
|
||||||
}
|
}
|
||||||
@@ -371,6 +423,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
): CostBreakdownLine[] {
|
): CostBreakdownLine[] {
|
||||||
const lines: CostBreakdownLine[] = [];
|
const lines: CostBreakdownLine[] = [];
|
||||||
const quantities = this.getManagedDatabaseQuantities(dto);
|
const quantities = this.getManagedDatabaseQuantities(dto);
|
||||||
|
const serviceKey = productServiceKey(ProductType.MANAGED_DATABASE);
|
||||||
const allowed = new Set([
|
const allowed = new Set([
|
||||||
PricingResourceType.DATABASE_ADDON,
|
PricingResourceType.DATABASE_ADDON,
|
||||||
PricingResourceType.CPU_PER_CORE,
|
PricingResourceType.CPU_PER_CORE,
|
||||||
@@ -390,6 +443,8 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
Number(rate.yearlyPrice),
|
Number(rate.yearlyPrice),
|
||||||
rate.resourceType,
|
rate.resourceType,
|
||||||
dto,
|
dto,
|
||||||
|
false,
|
||||||
|
serviceKey,
|
||||||
);
|
);
|
||||||
if (line) lines.push(line);
|
if (line) lines.push(line);
|
||||||
}
|
}
|
||||||
@@ -419,6 +474,19 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
const hasDatabase =
|
const hasDatabase =
|
||||||
dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
|
dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
|
||||||
const logging = !!dto.enableElasticsearch;
|
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) =>
|
const profileFor = (service: OptionalService) =>
|
||||||
optional.profiles.find((p) => p.service === service);
|
optional.profiles.find((p) => p.service === service);
|
||||||
@@ -439,6 +507,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
`${workloadLabel} log shipper`,
|
`${workloadLabel} log shipper`,
|
||||||
esRates,
|
esRates,
|
||||||
this.getLogShipperQuantities(shipCpu, shipMem),
|
this.getLogShipperQuantities(shipCpu, shipMem),
|
||||||
|
esKey,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -455,6 +524,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
OPTIONAL_SERVICE_LABELS[OptionalService.REDIS],
|
OPTIONAL_SERVICE_LABELS[OptionalService.REDIS],
|
||||||
profile,
|
profile,
|
||||||
ratesFor(OptionalService.REDIS),
|
ratesFor(OptionalService.REDIS),
|
||||||
|
redisKey,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (logging) addLogShipper('Redis');
|
if (logging) addLogShipper('Redis');
|
||||||
@@ -473,6 +543,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
OPTIONAL_SERVICE_LABELS[OptionalService.RABBITMQ],
|
OPTIONAL_SERVICE_LABELS[OptionalService.RABBITMQ],
|
||||||
profile,
|
profile,
|
||||||
ratesFor(OptionalService.RABBITMQ),
|
ratesFor(OptionalService.RABBITMQ),
|
||||||
|
rabbitmqKey,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (logging) addLogShipper('RabbitMQ');
|
if (logging) addLogShipper('RabbitMQ');
|
||||||
@@ -494,6 +565,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
PricingResourceType.CUSTOM_DOMAIN_ADDON,
|
PricingResourceType.CUSTOM_DOMAIN_ADDON,
|
||||||
dto,
|
dto,
|
||||||
true,
|
true,
|
||||||
|
CUSTOM_DOMAIN_SERVICE_KEY,
|
||||||
);
|
);
|
||||||
if (line) lines.push(line);
|
if (line) lines.push(line);
|
||||||
}
|
}
|
||||||
@@ -505,11 +577,13 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
serviceLabel: string,
|
serviceLabel: string,
|
||||||
profile: OptionalServiceProfile,
|
profile: OptionalServiceProfile,
|
||||||
rates: OptionalServiceRate[],
|
rates: OptionalServiceRate[],
|
||||||
|
serviceKey?: string,
|
||||||
): CostBreakdownLine[] {
|
): CostBreakdownLine[] {
|
||||||
return this.linesForResourceSlice(
|
return this.linesForResourceSlice(
|
||||||
serviceLabel,
|
serviceLabel,
|
||||||
rates,
|
rates,
|
||||||
this.getOptionalServiceQuantities(profile),
|
this.getOptionalServiceQuantities(profile),
|
||||||
|
serviceKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +591,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
prefix: string,
|
prefix: string,
|
||||||
rates: OptionalServiceRate[],
|
rates: OptionalServiceRate[],
|
||||||
quantities: Map<PricingResourceType, number>,
|
quantities: Map<PricingResourceType, number>,
|
||||||
|
serviceKey?: string,
|
||||||
): CostBreakdownLine[] {
|
): CostBreakdownLine[] {
|
||||||
const lines: CostBreakdownLine[] = [];
|
const lines: CostBreakdownLine[] = [];
|
||||||
for (const rate of rates) {
|
for (const rate of rates) {
|
||||||
@@ -533,6 +608,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
rate.resourceType,
|
rate.resourceType,
|
||||||
{} as CalculateCostDto,
|
{} as CalculateCostDto,
|
||||||
true,
|
true,
|
||||||
|
serviceKey,
|
||||||
);
|
);
|
||||||
if (line) lines.push(line);
|
if (line) lines.push(line);
|
||||||
}
|
}
|
||||||
@@ -618,6 +694,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
resourceType: PricingResourceType,
|
resourceType: PricingResourceType,
|
||||||
dto: CalculateCostDto,
|
dto: CalculateCostDto,
|
||||||
useFixedLabel = false,
|
useFixedLabel = false,
|
||||||
|
serviceKey?: string,
|
||||||
): CostBreakdownLine | null {
|
): CostBreakdownLine | null {
|
||||||
const hourly = Math.round(quantity * hourlyUnit);
|
const hourly = Math.round(quantity * hourlyUnit);
|
||||||
const monthly = Math.round(quantity * monthlyUnit);
|
const monthly = Math.round(quantity * monthlyUnit);
|
||||||
@@ -627,7 +704,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
const label = useFixedLabel
|
const label = useFixedLabel
|
||||||
? baseLabel
|
? baseLabel
|
||||||
: this.describeLine(baseLabel, resourceType, quantity, dto);
|
: this.describeLine(baseLabel, resourceType, quantity, dto);
|
||||||
return { label, hourly, monthly, yearly };
|
return { label, hourly, monthly, yearly, serviceKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
private describeLine(
|
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';
|
} from '@/types';
|
||||||
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
|
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
|
import DiscountsSection from './DiscountsSection';
|
||||||
|
|
||||||
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
||||||
|
|
||||||
@@ -711,6 +712,8 @@ export default function AdminBillingPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DiscountsSection />
|
||||||
|
|
||||||
<LifecycleSettingsSection />
|
<LifecycleSettingsSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export default function AppDetailPage() {
|
|||||||
const [snapshotTab, setSnapshotTab] = useState<'revisions' | 'snapshots'>('revisions');
|
const [snapshotTab, setSnapshotTab] = useState<'revisions' | 'snapshots'>('revisions');
|
||||||
const [showRenewalModal, setShowRenewalModal] = useState(false);
|
const [showRenewalModal, setShowRenewalModal] = useState(false);
|
||||||
const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly');
|
const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly');
|
||||||
|
const [renewCoupon, setRenewCoupon] = useState('');
|
||||||
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false);
|
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false);
|
||||||
const [upgradeCostData, setUpgradeCostData] = useState<{
|
const [upgradeCostData, setUpgradeCostData] = useState<{
|
||||||
proratedAmount: number;
|
proratedAmount: number;
|
||||||
@@ -262,12 +263,17 @@ export default function AppDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renewMutation = useMutation({
|
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) => {
|
onSuccess: (res) => {
|
||||||
notify.success(res.data.message || 'Application renewed successfully!');
|
notify.success(res.data.message || 'Application renewed successfully!');
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||||
setShowRenewalModal(false);
|
setShowRenewalModal(false);
|
||||||
|
setRenewCoupon('');
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
notify.error(err, 'Failed to renew application');
|
notify.error(err, 'Failed to renew application');
|
||||||
@@ -276,7 +282,12 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
const createRenewalInvoiceMutation = useMutation({
|
const createRenewalInvoiceMutation = useMutation({
|
||||||
mutationFn: (cycle: string) =>
|
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) => {
|
onSuccess: (invoice) => {
|
||||||
notify.success(ad.invoiceCreated);
|
notify.success(ad.invoiceCreated);
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
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 */}
|
{/* Actions */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<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"
|
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>
|
>{t.common.cancel}</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -297,6 +297,8 @@ export default function DeployPage() {
|
|||||||
const [isWpDragging, setIsWpDragging] = useState(false);
|
const [isWpDragging, setIsWpDragging] = useState(false);
|
||||||
const wpFileInputRef = useRef<HTMLInputElement>(null);
|
const wpFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
|
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
|
||||||
|
const [couponCode, setCouponCode] = useState('');
|
||||||
|
const [appliedCoupon, setAppliedCoupon] = useState('');
|
||||||
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
||||||
const [isPaid, setIsPaid] = useState(false);
|
const [isPaid, setIsPaid] = useState(false);
|
||||||
|
|
||||||
@@ -388,6 +390,7 @@ export default function DeployPage() {
|
|||||||
form.runtime !== 'wordpress' && form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
|
form.runtime !== 'wordpress' && form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
|
||||||
enableCustomDomain,
|
enableCustomDomain,
|
||||||
cycle: selectedCycle,
|
cycle: selectedCycle,
|
||||||
|
couponCode: appliedCoupon || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cost calculation for the review step (includes prepaid resource credits)
|
// Cost calculation for the review step (includes prepaid resource credits)
|
||||||
@@ -404,7 +407,10 @@ export default function DeployPage() {
|
|||||||
enabled: step >= 2,
|
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 fullPrice = costData?.fullAmount ?? 0;
|
||||||
const coveredAmount = costData?.coveredAmount ?? 0;
|
const coveredAmount = costData?.coveredAmount ?? 0;
|
||||||
const extrasBreakdown = costData?.extrasBreakdown ?? [];
|
const extrasBreakdown = costData?.extrasBreakdown ?? [];
|
||||||
@@ -455,7 +461,10 @@ export default function DeployPage() {
|
|||||||
|
|
||||||
// Deduct from wallet
|
// Deduct from wallet
|
||||||
setDeployStage('paying');
|
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;
|
return res;
|
||||||
},
|
},
|
||||||
@@ -534,7 +543,10 @@ export default function DeployPage() {
|
|||||||
|
|
||||||
// Deduct from the wallet (which was just charged by gateway)
|
// Deduct from the wallet (which was just charged by gateway)
|
||||||
setDeployStage('paying');
|
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;
|
return res;
|
||||||
},
|
},
|
||||||
@@ -2596,6 +2608,52 @@ export default function DeployPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Payment Method */}
|
||||||
{costData && costData.monthly > 0 && !requiresPayment && (
|
{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>
|
<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>
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
|
<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.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.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>
|
<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',
|
title: 'Invoices',
|
||||||
subtitle: 'Review what each payment was for and pay open invoices.',
|
subtitle: 'Review what each payment was for and pay open invoices.',
|
||||||
|
subtotal: 'Subtotal',
|
||||||
walletBalance: 'Wallet balance',
|
walletBalance: 'Wallet balance',
|
||||||
toman: 'Toman',
|
toman: 'Toman',
|
||||||
filterAll: 'All',
|
filterAll: 'All',
|
||||||
@@ -1102,6 +1103,69 @@ const en: Dictionary = {
|
|||||||
saveFailedShort: 'Failed to save',
|
saveFailedShort: 'Failed to save',
|
||||||
hours: 'hours',
|
hours: 'hours',
|
||||||
days: 'days',
|
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: {
|
servicesNew: {
|
||||||
steps: ['Service type', 'Configuration', 'Review & pay'],
|
steps: ['Service type', 'Configuration', 'Review & pay'],
|
||||||
|
|||||||
@@ -654,6 +654,7 @@ const fa = {
|
|||||||
},
|
},
|
||||||
title: 'فاکتورها',
|
title: 'فاکتورها',
|
||||||
subtitle: 'ببین هر پرداخت بابت چه بوده و فاکتورهای باز را پرداخت کن.',
|
subtitle: 'ببین هر پرداخت بابت چه بوده و فاکتورهای باز را پرداخت کن.',
|
||||||
|
subtotal: 'جمع جزء',
|
||||||
walletBalance: 'موجودی کیفپول',
|
walletBalance: 'موجودی کیفپول',
|
||||||
toman: 'تومان',
|
toman: 'تومان',
|
||||||
filterAll: 'همه',
|
filterAll: 'همه',
|
||||||
@@ -1101,6 +1102,69 @@ const fa = {
|
|||||||
saveFailedShort: 'ذخیره ناموفق بود',
|
saveFailedShort: 'ذخیره ناموفق بود',
|
||||||
hours: 'ساعت',
|
hours: 'ساعت',
|
||||||
days: 'روز',
|
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: {
|
servicesNew: {
|
||||||
steps: ['نوع سرویس', 'پیکربندی', 'بررسی و پرداخت'],
|
steps: ['نوع سرویس', 'پیکربندی', 'بررسی و پرداخت'],
|
||||||
|
|||||||
@@ -704,12 +704,49 @@ export interface CatalogOptionalServiceOption {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscountServiceOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
group: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingCatalog {
|
export interface PricingCatalog {
|
||||||
runtimes: Record<string, PricingRateRow[]>;
|
runtimes: Record<string, PricingRateRow[]>;
|
||||||
optionalServices: Record<string, OptionalServiceCatalogEntry>;
|
optionalServices: Record<string, OptionalServiceCatalogEntry>;
|
||||||
customDomain: CustomDomainCatalogRow;
|
customDomain: CustomDomainCatalogRow;
|
||||||
runtimeOptions: CatalogRuntimeOption[];
|
runtimeOptions: CatalogRuntimeOption[];
|
||||||
optionalServiceOptions: CatalogOptionalServiceOption[];
|
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 {
|
export interface WalletBalance {
|
||||||
@@ -753,6 +790,8 @@ export interface Invoice {
|
|||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
paymentMethod?: PaymentMethod;
|
paymentMethod?: PaymentMethod;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
|
discountAmount?: number;
|
||||||
|
discountCode?: string;
|
||||||
total: number;
|
total: number;
|
||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
dueAmount: number;
|
dueAmount: number;
|
||||||
@@ -794,6 +833,15 @@ export interface DeployExtraChargeLine {
|
|||||||
fullPeriodAmount?: number;
|
fullPeriodAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CouponDiscountPreview {
|
||||||
|
valid: boolean;
|
||||||
|
reason?: string;
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
percentOff?: number;
|
||||||
|
discountAmount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeployCostPreview extends CostBreakdown {
|
export interface DeployCostPreview extends CostBreakdown {
|
||||||
cycle: BillingCycle;
|
cycle: BillingCycle;
|
||||||
fullAmount: number;
|
fullAmount: number;
|
||||||
@@ -805,6 +853,8 @@ export interface DeployCostPreview extends CostBreakdown {
|
|||||||
prepaidCreditUsed: boolean;
|
prepaidCreditUsed: boolean;
|
||||||
prorateRemainingDays?: number;
|
prorateRemainingDays?: number;
|
||||||
proratePeriodDays?: number;
|
proratePeriodDays?: number;
|
||||||
|
couponDiscount?: CouponDiscountPreview | null;
|
||||||
|
amountDueAfterDiscount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Snapshot / Rollback types ──────────────────────
|
// ─── Snapshot / Rollback types ──────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user