22359be40e
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>
1428 lines
50 KiB
TypeScript
1428 lines
50 KiB
TypeScript
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 (0–100). */
|
||
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,
|
||
};
|
||
}
|
||
}
|