Files
cloud-host/backend/src/billing/billing.service.ts
T
keyhan 22359be40e fix(platform): apply production hardening from audit plan
Close billing, tenancy, migration, build, and CI/CD gaps identified in the
audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with
base schema, stateful service stability, safer Dockerfiles/git builds, and
platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:35:07 +03:30

1428 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, MoreThan, FindOptionsWhere, EntityManager } from 'typeorm';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { Invoice } from './entities/invoice.entity';
import { InvoiceLine } from './entities/invoice-line.entity';
import {
TransactionType,
BillingCycle,
DatabaseType,
InvoiceReason,
InvoiceStatus,
PaymentMethod,
UserRole,
ProductType,
isManagedProductType,
} from '../common/enums';
import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { Application } from '../applications/entities/application.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
import { CostBreakdownLine, PricingCatalogService } from './pricing-catalog.service';
import { DiscountService } from './discount.service';
import { productServiceKey, runtimeServiceKey } from './pricing-catalog.constants';
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
constructor(
private readonly pricingCatalog: PricingCatalogService,
private readonly discountService: DiscountService,
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
@InjectRepository(Invoice) private invoiceRepo: Repository<Invoice>,
@InjectRepository(InvoiceLine) private invoiceLineRepo: Repository<InvoiceLine>,
) {}
// ─── Pricing catalog (Admin) ──────────────────────────────────────
getPricingCatalog() {
return this.pricingCatalog.getCatalog();
}
updatePricingCatalog(dto: UpdatePricingCatalogDto) {
return this.pricingCatalog.updateCatalog(dto);
}
// ─── Global (platform-wide) discount ──────────────────────────────
/** Current platform-wide discount percentage (0100). */
async getGlobalDiscount(): Promise<{ percentOff: number }> {
return { percentOff: await this.pricingCatalog.getGlobalDiscountPercent(true) };
}
/** Set the platform-wide discount percentage (Admin). */
async setGlobalDiscount(percentOff: number): Promise<{ percentOff: number }> {
return { percentOff: await this.pricingCatalog.setGlobalDiscountPercent(percentOff) };
}
// ─── Cost Calculation ─────────────────────────────────────────────
/**
* Calculate cost from the usage pricing catalog.
* Each billing cycle uses its own price column — no cross-cycle conversion.
*/
async calculateCost(dto: CalculateCostDto): Promise<{
hourly: number;
monthly: number;
yearly: number;
breakdown: CostBreakdownLine[];
}> {
return this.pricingCatalog.computeTotalsFromDb(dto);
}
// ─── Coupon discounts ─────────────────────────────────────────────
/** Service-tagged cost breakdown for a deploy config (used for coupon scoping). */
async getDeployBreakdown(dto: CalculateCostDto): Promise<CostBreakdownLine[]> {
return (await this.pricingCatalog.computeTotalsFromDb(dto)).breakdown;
}
/** Service-tagged cost breakdown for an existing app (renewal). */
async getAppChargeBreakdown(app: Application): Promise<CostBreakdownLine[]> {
const config = this.appToResourceConfig(app, { enableCustomDomain: !!app.customDomain });
return (await this.pricingCatalog.computeTotalsFromDb(this.toCalculateDto(config))).breakdown;
}
/** Single-line breakdown for an upgrade, tagged with the app's service key. */
async getUpgradeBreakdown(
app: Application,
dto: UpgradeResourcesDto,
): Promise<CostBreakdownLine[]> {
const cost = await this.calculateUpgradeCost(app, dto);
const amount = cost.proratedAmount;
if (amount <= 0) return [];
const productType = app.productType ?? ProductType.APPLICATION;
const serviceKey =
productType === ProductType.APPLICATION
? runtimeServiceKey(app.runtime)
: productServiceKey(productType);
return [{ label: 'Resource upgrade', hourly: amount, monthly: amount, yearly: amount, serviceKey }];
}
/** Preview a coupon without charging — returns the evaluation for UI. */
previewCoupon(
userId: string,
code: string,
breakdown: CostBreakdownLine[],
cycle: BillingCycle,
) {
return this.discountService.evaluate(code, userId, breakdown, cycle);
}
/**
* Resolve a coupon at payment time. Returns the discount to attach to an
* invoice (capped to the payable amount), or null when no code is supplied.
* Throws if the code is supplied but cannot be applied.
*/
async resolveCoupon(
userId: string,
couponCode: string | undefined,
breakdown: CostBreakdownLine[],
cycle: BillingCycle,
payableAmount: number,
): Promise<{ discountId: string; code: string; amount: number } | null> {
if (!couponCode) return null;
const result = await this.discountService.resolveForCharge(couponCode, userId, breakdown, cycle);
const amount = Math.min(result.discountAmount, Math.max(0, Math.round(payableAmount)));
if (amount <= 0) return null;
return { discountId: result.discount.id, code: result.discount.code, amount };
}
// ─── Optional services & custom domain (delegates to catalog) ─────
getOptionalServicesPricing() {
return this.pricingCatalog.getOptionalServicesPricing();
}
setOptionalServicesPricing(pricing: {
redis: { hourly: number; monthly: number; yearly: number };
rabbitmq: { hourly: number; monthly: number; yearly: number };
elasticsearch: { hourly: number; monthly: number; yearly: number };
}) {
return this.pricingCatalog.setOptionalServicesPricing(pricing);
}
getCustomDomainPrice() {
return this.pricingCatalog.getCustomDomainPrice();
}
setCustomDomainPrice(monthlyPrice: number) {
return this.pricingCatalog.setCustomDomainPrice(monthlyPrice);
}
// ─── Wallet ───────────────────────────────────────────────────────
async getOrCreateWallet(userId: string): Promise<Wallet> {
let wallet = await this.walletRepo.findOne({ where: { userId } });
if (!wallet) {
wallet = this.walletRepo.create({ userId, balance: 0 });
wallet = await this.walletRepo.save(wallet);
this.logger.log(`Created wallet for user ${userId}`);
}
return wallet;
}
async getBalance(userId: string): Promise<{ balance: number }> {
const wallet = await this.getOrCreateWallet(userId);
return { balance: Number(wallet.balance) };
}
/**
* Load the user's wallet inside a transaction with a row-level lock
* (SELECT ... FOR UPDATE) so concurrent charge/deduct operations serialize
* instead of racing on read-modify-write.
*/
private async lockWallet(em: EntityManager, userId: string): Promise<Wallet> {
let wallet = await em.getRepository(Wallet).findOne({
where: { userId },
lock: { mode: 'pessimistic_write' },
});
if (!wallet) {
// First-time wallet creation may race; the unique userId column makes
// one insert win — re-read with the lock afterwards.
try {
await em.getRepository(Wallet).insert({ userId, balance: 0 });
} catch {
/* concurrent insert won — fall through to locked re-read */
}
wallet = await em.getRepository(Wallet).findOneOrFail({
where: { userId },
lock: { mode: 'pessimistic_write' },
});
}
return wallet;
}
async chargeWallet(
userId: string,
amount: number,
description?: string,
invoiceId?: string,
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const saved = await this.walletRepo.manager.transaction(async (em) => {
const wallet = await this.lockWallet(em, userId);
wallet.balance = Number(wallet.balance) + amount;
await em.getRepository(Wallet).save(wallet);
const tx = em.getRepository(WalletTransaction).create({
walletId: wallet.id,
type: TransactionType.CHARGE,
amount,
balanceAfter: wallet.balance,
description: description || 'Wallet charge',
invoiceId,
});
return em.getRepository(WalletTransaction).save(tx);
});
this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${saved.balanceAfter}`);
return saved;
}
async deductWallet(
userId: string,
amount: number,
description?: string,
applicationId?: string,
invoiceId?: string,
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const saved = await this.walletRepo.manager.transaction(async (em) => {
const wallet = await this.lockWallet(em, userId);
if (Number(wallet.balance) < amount) {
throw new BadRequestException('Insufficient wallet balance');
}
wallet.balance = Number(wallet.balance) - amount;
await em.getRepository(Wallet).save(wallet);
const tx = em.getRepository(WalletTransaction).create({
walletId: wallet.id,
type: TransactionType.DEDUCTION,
amount,
balanceAfter: wallet.balance,
description: description || 'Service payment',
applicationId,
invoiceId,
});
return em.getRepository(WalletTransaction).save(tx);
});
this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${saved.balanceAfter}`);
return saved;
}
async getTransactions(userId: string, limit = 50): Promise<WalletTransaction[]> {
const wallet = await this.getOrCreateWallet(userId);
return this.txRepo.find({
where: { walletId: wallet.id },
relations: { invoice: true },
order: { createdAt: 'DESC' },
take: limit,
});
}
/**
* Aggregate wallet movement for a user, grouped by transaction type. Used by
* the admin user-detail dashboard. "revenue" is what the user actually spent
* on services (deductions + direct gateway payments) — i.e. platform income —
* as opposed to "charged" which is just money topped up into the wallet.
*/
async getRevenueSummary(
userId: string,
): Promise<{ revenue: number; charged: number; refunded: number }> {
const wallet = await this.getOrCreateWallet(userId);
const rows = await this.txRepo
.createQueryBuilder('tx')
.select('tx.type', 'type')
.addSelect('COALESCE(SUM(tx.amount), 0)', 'total')
.where('tx.walletId = :walletId', { walletId: wallet.id })
.groupBy('tx.type')
.getRawMany<{ type: TransactionType; total: string }>();
const totals = new Map(rows.map((r) => [r.type, Number(r.total)]));
const revenue =
(totals.get(TransactionType.DEDUCTION) ?? 0) +
(totals.get(TransactionType.GATEWAY_PAYMENT) ?? 0);
return {
revenue,
charged: totals.get(TransactionType.CHARGE) ?? 0,
refunded: totals.get(TransactionType.REFUND) ?? 0,
};
}
async recordGatewayPayment(
userId: string,
amount: number,
description: string,
applicationId?: string,
invoiceId?: string,
gatewayTrackingCode?: string,
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId);
const tx = this.txRepo.create({
walletId: wallet.id,
type: TransactionType.GATEWAY_PAYMENT,
amount,
balanceAfter: wallet.balance,
description,
applicationId,
invoiceId,
gatewayTrackingCode,
});
return this.txRepo.save(tx);
}
// Admin: charge any user's wallet
async adminChargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
return this.chargeWallet(userId, amount, description || 'Admin charge');
}
// Admin: get all wallets
async getAllWallets(): Promise<Wallet[]> {
return this.walletRepo.find({ relations: { user: true }, order: { balance: 'DESC' } });
}
// ─── Invoices ─────────────────────────────────────────────────────
private generateInvoiceNumber(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const suffix = Math.random().toString(36).slice(2, 8).toUpperCase();
return `INV-${date}-${suffix}`;
}
private normalizeAmount(amount: number): number {
return Math.max(0, Math.round(Number(amount || 0) * 100) / 100);
}
async createInvoice(input: {
userId: string;
applicationId?: string;
reason: InvoiceReason;
lines: { label: string; description?: string; quantity?: number; unitAmount?: number; amount: number; metadata?: Record<string, any> }[];
dueDate?: Date;
metadata?: Record<string, any>;
discount?: { discountId: string; code: string; amount: number };
}): Promise<Invoice> {
const lines = input.lines
.filter((line) => this.normalizeAmount(line.amount) > 0)
.map((line) => {
const quantity = line.quantity || 1;
const amount = this.normalizeAmount(line.amount);
return this.invoiceLineRepo.create({
label: line.label,
description: line.description,
quantity,
unitAmount: this.normalizeAmount(line.unitAmount ?? amount / quantity),
amount,
metadata: line.metadata,
});
});
const subtotal = this.normalizeAmount(lines.reduce((sum, line) => sum + Number(line.amount), 0));
const discountAmount = input.discount
? Math.min(this.normalizeAmount(input.discount.amount), subtotal)
: 0;
const total = this.normalizeAmount(subtotal - discountAmount);
if (total <= 0) {
throw new BadRequestException('Invoice total must be positive');
}
const invoice = this.invoiceRepo.create({
invoiceNumber: this.generateInvoiceNumber(),
userId: input.userId,
applicationId: input.applicationId,
reason: input.reason,
status: InvoiceStatus.ISSUED,
subtotal,
discountAmount,
discountCode: input.discount?.code,
total,
paidAmount: 0,
dueAmount: total,
dueDate: input.dueDate,
metadata: input.discount
? { ...(input.metadata || {}), discountId: input.discount.discountId }
: input.metadata,
lines,
});
return this.invoiceRepo.save(invoice);
}
async listInvoices(
user: { id: string; role?: UserRole },
filters: {
status?: InvoiceStatus;
applicationId?: string;
userId?: string;
paymentMethod?: PaymentMethod;
search?: string;
limit?: number;
} = {},
): Promise<Invoice[]> {
const isAdmin = user.role === UserRole.ADMIN;
const where: FindOptionsWhere<Invoice> = {};
if (!isAdmin) where.userId = user.id;
if (isAdmin && filters.userId) where.userId = filters.userId;
if (filters.status) where.status = filters.status;
if (filters.applicationId) where.applicationId = filters.applicationId;
if (filters.paymentMethod) where.paymentMethod = filters.paymentMethod;
const qb = this.invoiceRepo
.createQueryBuilder('invoice')
.leftJoinAndSelect('invoice.user', 'user')
.leftJoinAndSelect('invoice.application', 'application')
.leftJoinAndSelect('invoice.lines', 'lines')
.where(where)
.orderBy('invoice.createdAt', 'DESC')
.take(Math.min(filters.limit || 100, 200));
if (filters.search) {
qb.andWhere(
'(invoice."invoiceNumber" ILIKE :search OR user.email ILIKE :search OR application.name ILIKE :search)',
{ search: `%${filters.search}%` },
);
}
return qb.getMany();
}
async getInvoiceForUser(invoiceId: string, user: { id: string; role?: UserRole }): Promise<Invoice> {
const invoice = await this.invoiceRepo.findOne({
where: { id: invoiceId },
relations: { user: true, application: true, lines: true, transactions: true },
order: { lines: { createdAt: 'ASC' }, transactions: { createdAt: 'DESC' } },
});
if (!invoice) throw new NotFoundException('Invoice not found');
if (user.role !== UserRole.ADMIN && invoice.userId !== user.id) {
throw new ForbiddenException('You do not have access to this invoice');
}
return invoice;
}
private ensureInvoicePayable(invoice: Invoice) {
if ([InvoiceStatus.PAID, InvoiceStatus.VOID].includes(invoice.status)) {
throw new BadRequestException(`Invoice is ${invoice.status}`);
}
if (Number(invoice.dueAmount) <= 0) {
throw new BadRequestException('Invoice has no due amount');
}
}
private async applyInvoicePayment(
invoice: Invoice,
amount: number,
paymentMethod: PaymentMethod,
gatewayTrackingCode?: string,
gatewayReference?: string,
): Promise<Invoice> {
const paidAmount = this.normalizeAmount(Number(invoice.paidAmount) + amount);
const dueAmount = this.normalizeAmount(Number(invoice.total) - paidAmount);
invoice.paidAmount = paidAmount;
invoice.dueAmount = dueAmount;
invoice.paymentMethod = invoice.paymentMethod && invoice.paymentMethod !== paymentMethod
? PaymentMethod.MIXED
: paymentMethod;
invoice.status = dueAmount <= 0 ? InvoiceStatus.PAID : InvoiceStatus.PARTIALLY_PAID;
invoice.paidAt = dueAmount <= 0 ? new Date() : invoice.paidAt;
invoice.gatewayTrackingCode = gatewayTrackingCode || invoice.gatewayTrackingCode;
invoice.gatewayReference = gatewayReference || invoice.gatewayReference;
// Record the coupon redemption exactly once, when the invoice is fully paid.
const discountId = invoice.metadata?.discountId;
const alreadyRedeemed = invoice.metadata?.discountRedeemed;
if (invoice.status === InvoiceStatus.PAID && discountId && !alreadyRedeemed) {
invoice.metadata = { ...(invoice.metadata || {}), discountRedeemed: true };
const saved = await this.invoiceRepo.save(invoice);
await this.discountService.recordRedemption(
discountId,
invoice.userId,
invoice.id,
Number(invoice.discountAmount) || 0,
);
return saved;
}
return this.invoiceRepo.save(invoice);
}
async payInvoiceWithWallet(invoiceId: string, user: { id: string; role?: UserRole }) {
const invoice = await this.getInvoiceForUser(invoiceId, user);
this.ensureInvoicePayable(invoice);
const amount = Number(invoice.dueAmount);
const tx = await this.deductWallet(
invoice.userId,
amount,
`Invoice ${invoice.invoiceNumber}: ${invoice.reason}`,
invoice.applicationId,
invoice.id,
);
const updated = await this.applyInvoicePayment(invoice, amount, PaymentMethod.WALLET);
return { invoice: updated, transaction: tx };
}
async initiateInvoiceGatewayPayment(
invoiceId: string,
user: { id: string; role?: UserRole },
callbackUrl: string,
) {
const invoice = await this.getInvoiceForUser(invoiceId, user);
this.ensureInvoicePayable(invoice);
const amount = Number(invoice.dueAmount);
const trackingCode = `INV-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
invoice.paymentMethod = invoice.paymentMethod && invoice.paymentMethod !== PaymentMethod.GATEWAY
? PaymentMethod.MIXED
: PaymentMethod.GATEWAY;
invoice.gatewayTrackingCode = trackingCode;
await this.invoiceRepo.save(invoice);
return {
success: true,
invoice,
amount,
trackingCode,
gatewayUrl: `${callbackUrl}?invoiceId=${invoice.id}&trackingCode=${trackingCode}&amount=${amount}&status=success`,
};
}
async initiateInvoiceMixedPayment(
invoiceId: string,
user: { id: string; role?: UserRole },
callbackUrl: string,
) {
const invoice = await this.getInvoiceForUser(invoiceId, user);
this.ensureInvoicePayable(invoice);
const wallet = await this.getOrCreateWallet(invoice.userId);
const walletAmount = Math.min(Number(wallet.balance), Number(invoice.dueAmount));
let updatedInvoice = invoice;
let walletTransaction: WalletTransaction | null = null;
if (walletAmount > 0) {
walletTransaction = await this.deductWallet(
invoice.userId,
walletAmount,
`Partial wallet payment for invoice ${invoice.invoiceNumber}`,
invoice.applicationId,
invoice.id,
);
updatedInvoice = await this.applyInvoicePayment(invoice, walletAmount, PaymentMethod.MIXED);
} else {
invoice.paymentMethod = PaymentMethod.MIXED;
updatedInvoice = await this.invoiceRepo.save(invoice);
}
if (Number(updatedInvoice.dueAmount) <= 0) {
return {
success: true,
invoice: updatedInvoice,
walletAmount,
gatewayAmount: 0,
walletTransaction,
message: 'Invoice paid from wallet balance',
};
}
const trackingCode = `INV-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
updatedInvoice.gatewayTrackingCode = trackingCode;
updatedInvoice.paymentMethod = PaymentMethod.MIXED;
updatedInvoice = await this.invoiceRepo.save(updatedInvoice);
return {
success: true,
invoice: updatedInvoice,
walletAmount,
gatewayAmount: Number(updatedInvoice.dueAmount),
walletTransaction,
trackingCode,
gatewayUrl: `${callbackUrl}?invoiceId=${updatedInvoice.id}&trackingCode=${trackingCode}&amount=${updatedInvoice.dueAmount}&status=success`,
};
}
async verifyInvoiceGatewayPayment(
invoiceId: string,
user: { id: string; role?: UserRole },
trackingCode: string,
amount: number,
) {
const invoice = await this.getInvoiceForUser(invoiceId, user);
this.ensureInvoicePayable(invoice);
if (invoice.gatewayTrackingCode && invoice.gatewayTrackingCode !== trackingCode) {
throw new BadRequestException('Invalid gateway tracking code');
}
const payableAmount = Math.min(Number(invoice.dueAmount), this.normalizeAmount(amount));
const tx = await this.recordGatewayPayment(
invoice.userId,
payableAmount,
`Gateway payment for invoice ${invoice.invoiceNumber}`,
invoice.applicationId,
invoice.id,
trackingCode,
);
const updated = await this.applyInvoicePayment(
invoice,
payableAmount,
invoice.paymentMethod === PaymentMethod.MIXED ? PaymentMethod.MIXED : PaymentMethod.GATEWAY,
trackingCode,
`REF-${trackingCode}`,
);
return { invoice: updated, transaction: tx };
}
async updateInvoiceStatus(
invoiceId: string,
status: InvoiceStatus,
reason: string,
): Promise<Invoice> {
const invoice = await this.getInvoiceForUser(invoiceId, { id: '', role: UserRole.ADMIN });
invoice.status = status;
invoice.statusReason = reason;
if (status === InvoiceStatus.VOID || status === InvoiceStatus.FAILED) {
invoice.dueAmount = Math.max(0, Number(invoice.total) - Number(invoice.paidAmount));
}
return this.invoiceRepo.save(invoice);
}
async markInvoiceEffectCompleted(invoiceId: string, result: Record<string, any>) {
const invoice = await this.getInvoiceForUser(invoiceId, { id: '', role: UserRole.ADMIN });
invoice.metadata = {
...(invoice.metadata || {}),
completedAt: new Date().toISOString(),
completionResult: result,
};
return this.invoiceRepo.save(invoice);
}
/**
* Calculate cost for an existing Application entity.
* Used by lifecycle service for auto-renew.
*/
async calculateCostForApp(app: {
productType?: ProductType;
runtime: string;
databaseType: string;
cpuLimit: string;
memoryLimit: string;
replicas: number;
dbStorageSize?: string;
appStorageSize?: string;
enableRedis?: boolean;
enableRabbitmq?: boolean;
enableElasticsearch?: boolean;
customDomain?: string;
customDomainStatus?: string;
optionalServiceResources?: Application['optionalServiceResources'];
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
const result = await this.calculateCost(this.toCalculateDto(this.appToResourceConfig(app as Application)));
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
}
// ─── Renewal Cost Calculation ─────────────────────────────────────
/**
* Calculate renewal cost for an application.
* Returns cost for each billing cycle based on current app config.
*/
async calculateRenewalCost(app: Application): Promise<{
hourly: number;
monthly: number;
yearly: number;
currentCycle?: BillingCycle;
currentCycleCost?: number;
}> {
const costs = await this.calculateCostForApp(app);
let currentCycleCost: number | undefined;
if (app.billingCycle) {
currentCycleCost = app.billingCycle === BillingCycle.HOURLY ? costs.hourly
: app.billingCycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
}
return {
...costs,
currentCycle: app.billingCycle,
currentCycleCost,
};
}
// ─── Resource Upgrade Cost Calculation ────────────────────────────
/**
* Calculate the cost difference for a resource upgrade.
* Returns the additional cost per billing cycle.
*/
async calculateUpgradeCost(
app: Application,
newResources: UpgradeResourcesDto,
): Promise<{
currentCost: { hourly: number; monthly: number; yearly: number };
newCost: { hourly: number; monthly: number; yearly: number };
difference: { hourly: number; monthly: number; yearly: number };
proratedAmount: number;
remainingHours: number;
billingCycle: BillingCycle | null;
}> {
const currentCost = await this.calculateCostForApp(app);
const base = this.appToResourceConfig(app);
const merged = {
...base,
...(newResources.cpuLimit && { cpuLimit: newResources.cpuLimit }),
...(newResources.memoryLimit && { memoryLimit: newResources.memoryLimit }),
replicas: newResources.replicas ?? base.replicas,
...(newResources.dbStorageSize && { dbStorageSize: newResources.dbStorageSize }),
...(newResources.appStorageSize && { appStorageSize: newResources.appStorageSize }),
...(newResources.databaseResources && {
databaseResources: {
...base.databaseResources,
...newResources.databaseResources,
storageGi:
newResources.databaseResources.storageGi ??
base.databaseResources?.storageGi ??
1,
},
}),
...(newResources.redisResources && {
redisResources: {
...base.redisResources,
...newResources.redisResources,
storageGi:
newResources.redisResources.storageGi ??
base.redisResources?.storageGi ??
1,
},
}),
...(newResources.rabbitmqResources && {
rabbitmqResources: {
...base.rabbitmqResources,
...newResources.rabbitmqResources,
storageGi:
newResources.rabbitmqResources.storageGi ??
base.rabbitmqResources?.storageGi ??
2,
},
}),
};
const newCostResult = await this.calculateCost(this.toCalculateDto(merged));
const newCost = {
hourly: newCostResult.hourly,
monthly: newCostResult.monthly,
yearly: newCostResult.yearly,
};
// Difference
const difference = {
hourly: newCost.hourly - currentCost.hourly,
monthly: newCost.monthly - currentCost.monthly,
yearly: newCost.yearly - currentCost.yearly,
};
// Calculate prorated amount based on remaining time in billing period.
// Use the price difference of the app's own billing cycle scaled by the
// fraction of the cycle that remains — not the hourly rate for all cycles.
let proratedAmount = 0;
let remainingHours = 0;
if (app.planExpiresAt && app.billingCycle) {
const now = new Date();
const expiresAt = new Date(app.planExpiresAt);
remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60));
const cycleDifference = this.amountForCycle(difference, app.billingCycle);
const cycleHours =
app.billingCycle === BillingCycle.HOURLY
? 1
: app.billingCycle === BillingCycle.MONTHLY
? 30 * 24
: 365 * 24;
// Only charge difference if upgrading (not downgrading)
if (cycleDifference > 0) {
const remainingFraction = Math.min(1, remainingHours / cycleHours);
proratedAmount = Math.ceil(cycleDifference * remainingFraction);
}
}
return {
currentCost,
newCost,
difference,
proratedAmount,
remainingHours: Math.round(remainingHours),
billingCycle: app.billingCycle,
};
}
// ─── Resource credits (prepaid resources after app deletion) ───────
private amountForCycle(
costs: { hourly: number; monthly: number; yearly: number },
cycle: BillingCycle,
): number {
switch (cycle) {
case BillingCycle.HOURLY:
return costs.hourly;
case BillingCycle.MONTHLY:
return costs.monthly;
case BillingCycle.YEARLY:
return costs.yearly;
default:
return costs.monthly;
}
}
private storageGi(size?: string, fallback = 1): number {
if (!size) return fallback;
return parseFloat(String(size).replace(/Gi$/i, '')) || fallback;
}
private appToResourceConfig(
app: Application | CalculateCostDto,
options?: { enableCustomDomain?: boolean },
) {
const enableCustomDomain =
options?.enableCustomDomain ??
('enableCustomDomain' in app
? !!(app as CalculateCostDto).enableCustomDomain
: !!(app as Application).customDomain);
const optionalRes =
'optionalServiceResources' in app
? (app as Application).optionalServiceResources
: undefined;
const dtoExtras =
'redisResources' in app || 'databaseResources' in app
? (app as CalculateCostDto)
: undefined;
const productType =
'productType' in app
? ((app as Application).productType ?? ProductType.APPLICATION)
: ((app as CalculateCostDto).productType ?? ProductType.APPLICATION);
const managed = isManagedProductType(productType);
return {
productType,
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: managed ? 0 : (app.replicas ?? 1),
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
enableRedis: !!app.enableRedis,
enableRabbitmq: !!app.enableRabbitmq,
enableElasticsearch: managed ? false : !!app.enableElasticsearch,
enableCustomDomain: managed ? false : enableCustomDomain,
databaseResources: optionalRes?.database ?? dtoExtras?.databaseResources,
redisResources: optionalRes?.redis ?? dtoExtras?.redisResources,
rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources,
};
}
async createCreditFromDeletedApp(app: Application): Promise<ResourceCredit | null> {
if (!app.planExpiresAt) return null;
const expiresAt = new Date(app.planExpiresAt);
if (expiresAt <= new Date()) return null;
const credit = this.creditRepo.create({
userId: app.userId,
sourceAppName: app.name,
productType: app.productType ?? ProductType.APPLICATION,
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize || '1Gi',
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: !!app.enableRedis,
enableRabbitmq: !!app.enableRabbitmq,
enableElasticsearch: !!app.enableElasticsearch,
billingCycle: app.billingCycle || BillingCycle.MONTHLY,
expiresAt,
});
const saved = await this.creditRepo.save(credit);
this.logger.log(`Resource credit created for user ${app.userId} from deleted app ${app.name}`);
return saved;
}
async getActiveCredits(userId: string): Promise<ResourceCredit[]> {
return this.creditRepo.find({
where: {
userId,
consumedAt: IsNull(),
expiresAt: MoreThan(new Date()),
},
order: { expiresAt: 'ASC' },
});
}
formatCreditForApi(credit: ResourceCredit) {
const now = Date.now();
const remainingMs = Math.max(0, new Date(credit.expiresAt).getTime() - now);
const remainingDays = Math.floor(remainingMs / 86400000);
const remainingHours = Math.floor((remainingMs % 86400000) / 3600000);
const remainingMinutes = Math.floor((remainingMs % 3600000) / 60000);
const remainingLabel =
remainingDays > 0
? `${remainingDays}d ${remainingHours}h ${remainingMinutes}m`
: remainingHours > 0
? `${remainingHours}h ${remainingMinutes}m`
: remainingMinutes > 0
? `${remainingMinutes}m`
: 'less than 1m';
return {
id: credit.id,
sourceAppName: credit.sourceAppName,
productType: credit.productType ?? ProductType.APPLICATION,
runtime: credit.runtime,
databaseType: credit.databaseType,
cpuLimit: credit.cpuLimit,
memoryLimit: credit.memoryLimit,
replicas: credit.replicas,
dbStorageSize: credit.dbStorageSize,
appStorageSize: credit.appStorageSize,
enableRedis: credit.enableRedis,
enableRabbitmq: credit.enableRabbitmq,
enableElasticsearch: credit.enableElasticsearch,
billingCycle: credit.billingCycle,
expiresAt: credit.expiresAt,
remainingMs,
remainingDays,
remainingHours,
remainingMinutes,
remainingLabel,
};
}
configWithinCredit(
config: ReturnType<typeof this.appToResourceConfig>,
credit: ResourceCredit,
): boolean {
const configProduct = config.productType ?? ProductType.APPLICATION;
const creditProduct = credit.productType ?? ProductType.APPLICATION;
if (configProduct !== creditProduct) return false;
if (config.runtime !== credit.runtime) return false;
if (
credit.databaseType !== DatabaseType.NONE &&
config.databaseType !== credit.databaseType
) {
return false;
}
if (
this.pricingCatalog.parseCpuToCores(config.cpuLimit) >
this.pricingCatalog.parseCpuToCores(credit.cpuLimit)
) {
return false;
}
if (
this.pricingCatalog.parseMemoryToGb(config.memoryLimit) >
this.pricingCatalog.parseMemoryToGb(credit.memoryLimit)
) {
return false;
}
if (config.replicas > credit.replicas) return false;
if (config.enableRedis && !credit.enableRedis) return false;
if (config.enableRabbitmq && !credit.enableRabbitmq) return false;
if (config.enableElasticsearch && !credit.enableElasticsearch) return false;
if (this.storageGi(config.dbStorageSize, 1) > this.storageGi(credit.dbStorageSize, 1)) {
return false;
}
if (this.storageGi(config.appStorageSize, 2) > this.storageGi(credit.appStorageSize, 2)) {
return false;
}
return true;
}
/** Credit applies when runtime (and DB type, if any) match — upgrades are charged as extras. */
async findApplicableCredit(
userId: string,
config: ReturnType<typeof this.appToResourceConfig>,
): Promise<ResourceCredit | null> {
const credits = await this.getActiveCredits(userId);
return (
credits.find((c) => {
const creditProduct = c.productType ?? ProductType.APPLICATION;
const configProduct = config.productType ?? ProductType.APPLICATION;
if (creditProduct !== configProduct) return false;
return (
c.runtime === config.runtime &&
(c.databaseType === DatabaseType.NONE ||
c.databaseType === config.databaseType)
);
}) ?? null
);
}
private async costDelta(
base: CalculateCostDto,
withExtras: Partial<CalculateCostDto>,
cycle: BillingCycle,
): Promise<number> {
const a = await this.calculateCost({ ...base, ...withExtras });
const b = await this.calculateCost(base);
return Math.max(0, this.amountForCycle(a, cycle) - this.amountForCycle(b, cycle));
}
private toCalculateDto(
config: ReturnType<typeof this.appToResourceConfig>,
): CalculateCostDto {
return {
productType: config.productType,
runtime: config.runtime,
databaseType: config.databaseType,
cpuLimit: config.cpuLimit,
memoryLimit: config.memoryLimit,
replicas: config.replicas,
dbStorageSize: config.dbStorageSize,
appStorageSize: config.appStorageSize,
enableRedis: config.enableRedis,
enableRabbitmq: config.enableRabbitmq,
enableElasticsearch: config.enableElasticsearch,
enableCustomDomain: config.enableCustomDomain,
databaseResources: config.databaseResources,
redisResources: config.redisResources,
rabbitmqResources: config.rabbitmqResources,
};
}
/** Baseline config covered by the prepaid credit (used for isolated add-on pricing). */
private creditBaselineDto(
credit: ResourceCredit,
patch: Partial<CalculateCostDto> = {},
): CalculateCostDto {
return {
productType: credit.productType ?? ProductType.APPLICATION,
runtime: credit.runtime,
databaseType: credit.databaseType,
cpuLimit: credit.cpuLimit,
memoryLimit: credit.memoryLimit,
replicas: credit.replicas,
dbStorageSize: credit.dbStorageSize || '1Gi',
appStorageSize: credit.appStorageSize || '2Gi',
enableRedis: !!credit.enableRedis,
enableRabbitmq: !!credit.enableRabbitmq,
enableElasticsearch: !!credit.enableElasticsearch,
enableCustomDomain: false,
...patch,
};
}
private getCreditProrateFactor(credit: ResourceCredit) {
const created = new Date(credit.createdAt).getTime();
const expires = new Date(credit.expiresAt).getTime();
const now = Date.now();
const totalMs = Math.max(1, expires - created);
const remainingMs = Math.max(0, expires - now);
const factor = Math.min(1, remainingMs / totalMs);
const remainingDays = Math.max(1, Math.ceil(remainingMs / 86400000));
const periodDays = Math.max(1, Math.ceil(totalMs / 86400000));
return { factor, remainingDays, periodDays };
}
private prorateLabel(credit: ResourceCredit): string {
const { remainingDays, periodDays } = this.getCreditProrateFactor(credit);
return `prorated ${remainingDays}/${periodDays} days`;
}
private async addExtraLineProrated(
items: { label: string; amount: number; fullPeriodAmount?: number }[],
from: CalculateCostDto,
to: Partial<CalculateCostDto>,
credit: ResourceCredit,
label: string,
): Promise<void> {
const billCycle = credit.billingCycle || BillingCycle.MONTHLY;
let fullPeriodAmount = await this.costDelta(from, to, billCycle);
if (fullPeriodAmount <= 0) {
fullPeriodAmount = await this.getAddonPriceFromBreakdown(from, to, billCycle);
}
if (fullPeriodAmount <= 0) return;
const { factor } = this.getCreditProrateFactor(credit);
const amount = Math.round(fullPeriodAmount * factor);
if (amount <= 0) return;
items.push({
label: `${label} (${this.prorateLabel(credit)})`,
amount,
fullPeriodAmount,
});
}
/** Fallback: read marginal addon price from cost breakdown labels. */
private async getAddonPriceFromBreakdown(
from: CalculateCostDto,
to: Partial<CalculateCostDto>,
cycle: BillingCycle,
): Promise<number> {
const before = await this.calculateCost(from);
const after = await this.calculateCost({ ...from, ...to });
const labelHints: string[] = [];
if (to.enableRedis) labelHints.push('Redis addon');
if (to.enableRabbitmq) labelHints.push('RabbitMQ addon');
if (to.enableElasticsearch) labelHints.push('Elasticsearch addon');
if (to.enableCustomDomain) labelHints.push('Custom domain + SSL');
if (to.databaseType && to.databaseType !== DatabaseType.NONE) {
labelHints.push('Database addon');
}
let sum = 0;
for (const hint of labelHints) {
const afterLine = after.breakdown.find((b) => b.label === hint);
const beforeLine = before.breakdown.find((b) => b.label === hint);
const afterAmt = afterLine
? this.pricingCatalog.amountForCycleFromLine(afterLine, cycle)
: 0;
const beforeAmt = beforeLine
? this.pricingCatalog.amountForCycleFromLine(beforeLine, cycle)
: 0;
sum += Math.max(0, afterAmt - beforeAmt);
}
return sum;
}
/**
* Line-item charges for anything beyond the prepaid credit bundle (prorated to remaining credit time).
*/
async calculateExtrasBeyondCredit(
config: ReturnType<typeof this.appToResourceConfig>,
credit: ResourceCredit,
_cycle: BillingCycle,
): Promise<{
total: number;
items: { label: string; amount: number; fullPeriodAmount?: number }[];
}> {
const items: { label: string; amount: number; fullPeriodAmount?: number }[] = [];
const baseline = this.creditBaselineDto(credit);
if (
this.pricingCatalog.parseCpuToCores(config.cpuLimit) >
this.pricingCatalog.parseCpuToCores(credit.cpuLimit)
) {
await this.addExtraLineProrated(
items,
baseline,
{ cpuLimit: config.cpuLimit },
credit,
`Extra CPU (${credit.cpuLimit}${config.cpuLimit})`,
);
}
if (
this.pricingCatalog.parseMemoryToGb(config.memoryLimit) >
this.pricingCatalog.parseMemoryToGb(credit.memoryLimit)
) {
await this.addExtraLineProrated(
items,
baseline,
{ memoryLimit: config.memoryLimit },
credit,
`Extra memory (${credit.memoryLimit}${config.memoryLimit})`,
);
}
if (config.replicas > credit.replicas) {
await this.addExtraLineProrated(
items,
baseline,
{ replicas: config.replicas },
credit,
`Extra replicas (${credit.replicas}${config.replicas})`,
);
}
const appDb = this.storageGi(config.dbStorageSize, 1);
const creditDb = this.storageGi(credit.dbStorageSize, 1);
if (appDb > creditDb) {
await this.addExtraLineProrated(
items,
baseline,
{ dbStorageSize: `${appDb}Gi` },
credit,
`Extra database storage (${creditDb}Gi → ${appDb}Gi)`,
);
}
const appSt = this.storageGi(config.appStorageSize, 2);
const creditSt = this.storageGi(credit.appStorageSize, 2);
if (appSt > creditSt) {
await this.addExtraLineProrated(
items,
baseline,
{ appStorageSize: `${appSt}Gi` },
credit,
`Extra app storage (${creditSt}Gi → ${appSt}Gi)`,
);
}
if (config.databaseType !== DatabaseType.NONE && credit.databaseType === DatabaseType.NONE) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, {
databaseType: DatabaseType.NONE,
dbStorageSize: undefined,
}),
{
databaseType: config.databaseType,
dbStorageSize: config.dbStorageSize || '1Gi',
},
credit,
`Database (${config.databaseType})`,
);
}
if (config.enableRedis && !credit.enableRedis) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, { enableRedis: false }),
{ enableRedis: true },
credit,
'Redis',
);
}
if (config.enableRabbitmq && !credit.enableRabbitmq) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, { enableRabbitmq: false }),
{ enableRabbitmq: true },
credit,
'RabbitMQ',
);
}
if (config.enableElasticsearch && !credit.enableElasticsearch) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, { enableElasticsearch: false }),
{ enableElasticsearch: true },
credit,
'Elasticsearch',
);
}
if (config.enableCustomDomain) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, { enableCustomDomain: false }),
{ enableCustomDomain: true },
credit,
'Custom domain + SSL',
);
}
const total = items.reduce((sum, i) => sum + i.amount, 0);
return { total: Math.round(total), items };
}
/**
* Deploy cost preview — applies prepaid resource credits when the config fits.
*/
async calculateDeployPayment(
userId: string,
dto: CalculateCostDto,
cycle: BillingCycle,
couponCode?: string,
) {
const costs = await this.calculateCost(dto);
const fullAmount = this.amountForCycle(costs, cycle);
const config = this.appToResourceConfig({
...dto,
enableRedis: !!dto.enableRedis,
enableRabbitmq: !!dto.enableRabbitmq,
enableElasticsearch: !!dto.enableElasticsearch,
enableCustomDomain: !!dto.enableCustomDomain,
} as CalculateCostDto);
const credit = await this.findApplicableCredit(userId, config);
// Coupon discount is scoped against the full service breakdown but capped
// to whatever is actually payable after prepaid credits.
const couponFor = (amountDue: number) =>
this.previewCouponForResponse(userId, couponCode, costs.breakdown, cycle, amountDue);
if (!credit) {
const couponDiscount = await couponFor(fullAmount);
return {
...costs,
cycle,
fullAmount,
amountDue: fullAmount,
coveredAmount: 0,
waivedAmount: 0,
extrasBreakdown: [],
creditApplied: null,
prepaidCreditUsed: false,
couponDiscount,
amountDueAfterDiscount:
fullAmount - (couponDiscount && couponDiscount.valid ? couponDiscount.discountAmount : 0),
};
}
const { total: extrasDue, items: extrasBreakdown } =
await this.calculateExtrasBeyondCredit(config, credit, cycle);
const waivedAmount = Math.max(0, fullAmount - extrasDue);
const prorate = this.getCreditProrateFactor(credit);
const couponDiscount = await couponFor(extrasDue);
return {
...costs,
cycle,
fullAmount,
amountDue: extrasDue,
coveredAmount: waivedAmount,
waivedAmount,
extrasBreakdown,
creditApplied: this.formatCreditForApi(credit),
prepaidCreditUsed: waivedAmount > 0,
prorateRemainingDays: prorate.remainingDays,
proratePeriodDays: prorate.periodDays,
couponDiscount,
amountDueAfterDiscount:
extrasDue - (couponDiscount && couponDiscount.valid ? couponDiscount.discountAmount : 0),
};
}
/** Evaluate a coupon for a preview response (no throw), capped to amountDue. */
private async previewCouponForResponse(
userId: string,
couponCode: string | undefined,
breakdown: CostBreakdownLine[],
cycle: BillingCycle,
amountDue: number,
): Promise<
| { valid: true; code: string; name: string; percentOff: number; discountAmount: number }
| { valid: false; reason: string }
| null
> {
if (!couponCode) return null;
const result = await this.discountService.evaluate(couponCode, userId, breakdown, cycle);
if (!result.ok) {
return { valid: false, reason: result.reason ?? 'not_found' };
}
const discountAmount = Math.min(result.discountAmount, Math.max(0, Math.round(amountDue)));
if (discountAmount <= 0) return { valid: false, reason: 'no_eligible_services' };
return {
valid: true,
code: result.discount.code,
name: result.discount.name,
percentOff: result.discount.percentOff,
discountAmount,
};
}
/**
* Resolve wallet/gateway payment for an app — consumes a matching credit when applicable.
*/
async resolveAppPayment(
userId: string,
app: Application,
cycle: BillingCycle,
): Promise<{
fullAmount: number;
amountDue: number;
creditId?: string;
planExpiresAt?: Date;
waivedAmount: number;
}> {
const config = this.appToResourceConfig(app, { enableCustomDomain: !!app.customDomain });
const fullCosts = await this.calculateCost(this.toCalculateDto(config));
const fullAmount = this.amountForCycle(fullCosts, cycle);
const credit = await this.findApplicableCredit(userId, config);
if (!credit) {
return { fullAmount, amountDue: fullAmount, waivedAmount: 0 };
}
const { total: extrasDue } = await this.calculateExtrasBeyondCredit(
config,
credit,
cycle,
);
credit.consumedAt = new Date();
credit.appliedApplicationId = app.id;
await this.creditRepo.save(credit);
this.logger.log(
`Applied resource credit ${credit.id} to app ${app.name} — due ${extrasDue} Toman (waived ${fullAmount - extrasDue})`,
);
return {
fullAmount,
amountDue: extrasDue,
creditId: credit.id,
planExpiresAt: credit.expiresAt,
waivedAmount: fullAmount - extrasDue,
};
}
/** @deprecated Use resolveAppPayment */
async applyResourceCredit(
userId: string,
app: Application,
amount: number,
cycle: BillingCycle = BillingCycle.MONTHLY,
): Promise<{ finalAmount: number; creditId?: string; waived: boolean; planExpiresAt?: Date }> {
const resolved = await this.resolveAppPayment(userId, app, cycle);
return {
finalAmount: resolved.amountDue,
creditId: resolved.creditId,
waived: resolved.waivedAmount > 0,
planExpiresAt: resolved.planExpiresAt,
};
}
}