Add invoice payment management.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-18 21:34:47 +03:30
parent 880521c576
commit 8b197e69bc
15 changed files with 1616 additions and 58 deletions
+358 -29
View File
@@ -28,11 +28,14 @@ import {
RenewApplicationDto,
UpgradeResourcesDto,
CalculateUpgradeCostDto,
InitiateInvoicePaymentDto,
VerifyInvoiceGatewayDto,
UpdateInvoiceStatusDto,
} from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole, BillingCycle, AppLifecycleStatus } from '../common/enums';
import { UserRole, BillingCycle, AppLifecycleStatus, InvoiceReason, InvoiceStatus, PaymentMethod } from '../common/enums';
@ApiTags('Billing')
@ApiBearerAuth()
@@ -142,6 +145,76 @@ export class BillingController {
return credits.map((c) => this.billingService.formatCreditForApi(c));
}
// ─── Invoices ─────────────────────────────────────────────────────
@Get('invoices')
@ApiOperation({ summary: 'List my invoices' })
async listMyInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('applicationId') applicationId?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
applicationId,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('invoices/:id')
@ApiOperation({ summary: 'Get one invoice with line items and transactions' })
async getInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Post('invoices/:id/pay/wallet')
@ApiOperation({ summary: 'Pay invoice from wallet balance' })
async payInvoiceWallet(@Request() req: any, @Param('id') id: string) {
const result = await this.billingService.payInvoiceWithWallet(id, req.user);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('invoices/:id/pay/gateway')
@ApiOperation({ summary: 'Initiate direct gateway payment for invoice' })
async initiateInvoiceGateway(
@Request() req: any,
@Param('id') id: string,
@Body() dto: InitiateInvoicePaymentDto,
) {
return this.billingService.initiateInvoiceGatewayPayment(id, req.user, dto.callbackUrl);
}
@Post('invoices/:id/pay/mixed')
@ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' })
async initiateInvoiceMixed(
@Request() req: any,
@Param('id') id: string,
@Body() dto: InitiateInvoicePaymentDto,
) {
const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('invoices/:id/gateway/verify')
@ApiOperation({ summary: 'Verify invoice gateway payment' })
async verifyInvoiceGateway(
@Request() req: any,
@Param('id') id: string,
@Body() dto: VerifyInvoiceGatewayDto,
) {
const result = await this.billingService.verifyInvoiceGatewayPayment(
id,
req.user,
dto.trackingCode,
dto.amount,
);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('wallet/pay/:applicationId')
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
async payForApplication(
@@ -162,14 +235,37 @@ export class BillingController {
cycle,
);
let tx = null;
let invoice = null;
if (payment.amountDue > 0) {
tx = await this.billingService.deductWallet(
req.user.id,
payment.amountDue,
`Payment for app ${applicationId} (${cycle})`,
applicationId,
);
invoice = await this.billingService.createInvoice({
userId: req.user.id,
applicationId: app.id,
reason: InvoiceReason.DEPLOY,
lines: [
{
label: `Application payment: ${app.name}`,
description: `Billing cycle: ${cycle}`,
amount: payment.amountDue,
metadata: {
cycle,
waivedAmount: payment.waivedAmount,
creditApplied: payment.creditId || null,
},
},
],
metadata: {
action: 'activate',
cycle,
planExpiresAt: payment.planExpiresAt?.toISOString(),
},
});
}
let tx = null;
if (invoice) {
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
tx = paid.transaction;
invoice = paid.invoice;
}
const activated = await this.lifecycleService.activateApp(
@@ -180,6 +276,7 @@ export class BillingController {
return {
transaction: tx,
invoice,
creditApplied: payment.creditId || null,
waivedAmount: payment.waivedAmount,
paidAmount: payment.amountDue,
@@ -238,6 +335,47 @@ export class BillingController {
};
}
// ─── Invoice Admin ────────────────────────────────────────────────
@Get('admin/invoices')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all invoices (Admin)' })
async listAdminInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('userId') userId?: string,
@Query('applicationId') applicationId?: string,
@Query('paymentMethod') paymentMethod?: PaymentMethod,
@Query('search') search?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
userId,
applicationId,
paymentMethod,
search,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('admin/invoices/:id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get invoice details (Admin)' })
async getAdminInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Patch('admin/invoices/:id/status')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update invoice status with reason (Admin)' })
async updateAdminInvoiceStatus(
@Param('id') id: string,
@Body() dto: UpdateInvoiceStatusDto,
) {
return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason);
}
// ─── Wallet Admin ─────────────────────────────────────────────────
@Get('admin/wallets')
@@ -278,6 +416,39 @@ export class BillingController {
};
}
@Post('applications/:applicationId/renew/invoice')
@ApiOperation({ summary: 'Create an unpaid renewal invoice for choosing wallet/gateway/mixed payment' })
async createRenewalInvoice(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() dto: RenewApplicationDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const costs = await this.billingService.calculateRenewalCost(app);
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
if (amount <= 0) {
throw new BadRequestException('Invalid cost calculation — no pricing rules found');
}
return this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
reason: InvoiceReason.RENEWAL,
lines: [
{
label: `Renewal for ${app.name}`,
description: `Billing cycle: ${dto.cycle}`,
amount,
metadata: { cycle: dto.cycle },
},
],
metadata: { action: 'renew', cycle: dto.cycle },
});
}
@Post('applications/:applicationId/renew')
@ApiOperation({ summary: 'Renew an application (user pays from wallet)' })
async renewApplication(
@@ -298,24 +469,34 @@ export class BillingController {
throw new BadRequestException('Invalid cost calculation — no pricing rules found');
}
// Deduct from wallet (user's wallet for user, app owner's wallet for admin action)
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
? app.userId
: req.user.id;
const tx = await this.billingService.deductWallet(
walletUserId,
amount,
`Renewal for ${app.name} (${dto.cycle})`,
app.id,
);
const invoice = await this.billingService.createInvoice({
userId: walletUserId,
applicationId: app.id,
reason: InvoiceReason.RENEWAL,
lines: [
{
label: `Renewal for ${app.name}`,
description: `Billing cycle: ${dto.cycle}`,
amount,
metadata: { cycle: dto.cycle },
},
],
metadata: { action: 'renew', cycle: dto.cycle },
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
// Activate the application
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
return {
success: true,
transaction: tx,
transaction: paid.transaction,
invoice: paid.invoice,
application: {
id: renewedApp.id,
name: renewedApp.name,
@@ -365,18 +546,29 @@ export class BillingController {
: cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
const tx = await this.billingService.deductWallet(
app.userId,
amount,
`Renewal by ${req.user.role} for ${app.name} (${cycle})`,
app.id,
);
const invoice = await this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
reason: InvoiceReason.RENEWAL,
lines: [
{
label: `Renewal for ${app.name}`,
description: `Billing cycle: ${cycle}; initiated by ${req.user.role}`,
amount,
metadata: { cycle, initiatedBy: req.user.role },
},
],
metadata: { action: 'renew', cycle, initiatedBy: req.user.role },
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
return {
success: true,
transaction: tx,
transaction: paid.transaction,
invoice: paid.invoice,
application: {
id: renewedApp.id,
name: renewedApp.name,
@@ -424,6 +616,49 @@ export class BillingController {
};
}
@Post('applications/:applicationId/upgrade/invoice')
@ApiOperation({ summary: 'Create an unpaid upgrade invoice for choosing wallet/gateway/mixed payment' })
async createUpgradeInvoice(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() dto: UpgradeResourcesDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
throw new BadRequestException(
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`,
);
}
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
if (costResult.proratedAmount <= 0) {
throw new BadRequestException('This change does not require a paid invoice');
}
return this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
reason: InvoiceReason.UPGRADE,
lines: [
{
label: `Resource upgrade for ${app.name}`,
description: `Prorated for ${costResult.remainingHours} hours`,
amount: costResult.proratedAmount,
metadata: {
remainingHours: costResult.remainingHours,
currentCost: costResult.currentCost,
newCost: costResult.newCost,
},
},
],
metadata: {
action: 'upgrade',
resources: dto,
remainingHours: costResult.remainingHours,
},
});
}
@Post('applications/:applicationId/upgrade')
@ApiOperation({ summary: 'Upgrade application resources (with payment)' })
async upgradeResources(
@@ -442,6 +677,7 @@ export class BillingController {
// Calculate upgrade cost
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
let paidInvoice = null;
// If upgrading (positive difference), require payment
if (costResult.proratedAmount > 0) {
@@ -449,12 +685,30 @@ export class BillingController {
? app.userId
: req.user.id;
await this.billingService.deductWallet(
walletUserId,
costResult.proratedAmount,
`Resource upgrade for ${app.name}: prorated ${costResult.remainingHours}h`,
app.id,
);
const invoice = await this.billingService.createInvoice({
userId: walletUserId,
applicationId: app.id,
reason: InvoiceReason.UPGRADE,
lines: [
{
label: `Resource upgrade for ${app.name}`,
description: `Prorated for ${costResult.remainingHours} hours`,
amount: costResult.proratedAmount,
metadata: {
remainingHours: costResult.remainingHours,
currentCost: costResult.currentCost,
newCost: costResult.newCost,
},
},
],
metadata: {
action: 'upgrade',
resources: dto,
remainingHours: costResult.remainingHours,
},
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
paidInvoice = paid.invoice;
}
// Apply the resource changes
@@ -490,6 +744,7 @@ export class BillingController {
return {
success: true,
paidAmount: costResult.proratedAmount,
invoice: paidInvoice,
application: {
id: updatedApp.id,
name: updatedApp.name,
@@ -509,6 +764,80 @@ export class BillingController {
// ─── Helper Methods ───────────────────────────────────────────────
private async completePaidInvoiceEffect(invoice: any) {
if (invoice.status !== InvoiceStatus.PAID) return null;
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
const action = invoice.metadata?.action;
if (!action || !invoice.applicationId) return null;
if (action === 'renew' || action === 'activate') {
const cycle = invoice.metadata?.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) return null;
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
const result = {
action,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
billingCycle: activated.billingCycle,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
if (action === 'upgrade') {
const app = await this.applicationsService.findOne(invoice.applicationId);
const resources = invoice.metadata?.resources || {};
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
cpuRequest: resources.cpuRequest || app.cpuRequest,
cpuLimit: resources.cpuLimit || app.cpuLimit,
memoryRequest: resources.memoryRequest || app.memoryRequest,
memoryLimit: resources.memoryLimit || app.memoryLimit,
replicas: resources.replicas ?? app.replicas,
dbStorageSize: resources.dbStorageSize || app.dbStorageSize,
appStorageSize: resources.appStorageSize || app.appStorageSize,
});
try {
await this.kubernetesService.updateResources(updatedApp, {
cpuRequest: resources.cpuRequest,
cpuLimit: resources.cpuLimit,
memoryRequest: resources.memoryRequest,
memoryLimit: resources.memoryLimit,
replicas: resources.replicas,
});
if (resources.appStorageSize && resources.appStorageSize !== app.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(updatedApp, resources.appStorageSize);
}
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
const result = {
action,
application: {
id: updatedApp.id,
name: updatedApp.name,
cpuRequest: updatedApp.cpuRequest,
cpuLimit: updatedApp.cpuLimit,
memoryRequest: updatedApp.memoryRequest,
memoryLimit: updatedApp.memoryLimit,
replicas: updatedApp.replicas,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
return null;
}
private async getAppWithAccess(user: any, applicationId: string) {
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
+4
View File
@@ -10,6 +10,8 @@ import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
import { Invoice } from './entities/invoice.entity';
import { InvoiceLine } from './entities/invoice-line.entity';
import { LifecycleModule } from '../lifecycle/lifecycle.module';
import { ApplicationsModule } from '../applications/applications.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
@@ -24,6 +26,8 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
Wallet,
WalletTransaction,
ResourceCredit,
Invoice,
InvoiceLine,
]),
forwardRef(() => LifecycleModule),
forwardRef(() => ApplicationsModule),
+340 -4
View File
@@ -1,9 +1,19 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, MoreThan } from 'typeorm';
import { Repository, IsNull, MoreThan, FindOptionsWhere } from 'typeorm';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { TransactionType, BillingCycle, DatabaseType } from '../common/enums';
import { Invoice } from './entities/invoice.entity';
import { InvoiceLine } from './entities/invoice-line.entity';
import {
TransactionType,
BillingCycle,
DatabaseType,
InvoiceReason,
InvoiceStatus,
PaymentMethod,
UserRole,
} from '../common/enums';
import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { Application } from '../applications/entities/application.entity';
@@ -19,6 +29,8 @@ export class BillingService {
@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) ──────────────────────────────────────
@@ -85,7 +97,12 @@ export class BillingService {
return { balance: Number(wallet.balance) };
}
async chargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
async chargeWallet(
userId: string,
amount: number,
description?: string,
invoiceId?: string,
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId);
@@ -98,6 +115,7 @@ export class BillingService {
amount,
balanceAfter: wallet.balance,
description: description || 'Wallet charge',
invoiceId,
});
const saved = await this.txRepo.save(tx);
@@ -110,6 +128,7 @@ export class BillingService {
amount: number,
description?: string,
applicationId?: string,
invoiceId?: string,
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
@@ -128,6 +147,7 @@ export class BillingService {
balanceAfter: wallet.balance,
description: description || 'Service payment',
applicationId,
invoiceId,
});
const saved = await this.txRepo.save(tx);
@@ -139,11 +159,36 @@ export class BillingService {
const wallet = await this.getOrCreateWallet(userId);
return this.txRepo.find({
where: { walletId: wallet.id },
relations: ['invoice'],
order: { createdAt: 'DESC' },
take: limit,
});
}
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');
@@ -154,6 +199,297 @@ export class BillingService {
return this.walletRepo.find({ relations: ['user'], 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>;
}): 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 total = this.normalizeAmount(lines.reduce((sum, line) => sum + Number(line.amount), 0));
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: total,
total,
paidAmount: 0,
dueAmount: total,
dueDate: input.dueDate,
metadata: 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', 'application', 'lines', 'transactions'],
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;
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.
+30 -1
View File
@@ -1,7 +1,7 @@
import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNested, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BillingCycle, PricingResourceType, AppRuntime } from '../../common/enums';
import { BillingCycle, PricingResourceType, AppRuntime, InvoiceStatus } from '../../common/enums';
import { OptionalServiceResourcesDto } from './optional-service-resources.dto';
export class CreatePricingRuleDto {
@@ -210,6 +210,35 @@ export class UpgradeResourcesDto {
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}
// ─── Invoice DTOs ───────────────────────────────────────────────────
export class InitiateInvoicePaymentDto {
@ApiProperty({ example: 'https://app.example.com/dashboard/invoices' })
@IsString()
callbackUrl: string;
}
export class VerifyInvoiceGatewayDto {
@ApiProperty({ example: 'INV-1710000000000-ABC123' })
@IsString()
trackingCode: string;
@ApiProperty({ example: 50000 })
@IsNumber()
@Min(1)
amount: number;
}
export class UpdateInvoiceStatusDto {
@ApiProperty({ enum: InvoiceStatus })
@IsEnum(InvoiceStatus)
status: InvoiceStatus;
@ApiProperty({ example: 'Manual reconciliation by finance team' })
@IsString()
reason: string;
}
// ─── Platform optional services pricing (Admin) ─────────────────────
export class OptionalServiceCyclePricesDto {
@@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Invoice } from './invoice.entity';
@Entity('invoice_lines')
export class InvoiceLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
invoiceId: string;
@ManyToOne(() => Invoice, (invoice) => invoice.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoiceId' })
invoice: Invoice;
@Column()
label: string;
@Column({ nullable: true })
description: string;
@Column({ type: 'int', default: 1 })
quantity: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
unitAmount: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
amount: number;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,92 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Application } from '../../applications/entities/application.entity';
import { InvoiceLine } from './invoice-line.entity';
import { WalletTransaction } from './wallet-transaction.entity';
import { InvoiceReason, InvoiceStatus, PaymentMethod } from '../../common/enums';
@Entity('invoices')
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
invoiceNumber: string;
@Column()
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column({ nullable: true })
applicationId: string;
@ManyToOne(() => Application, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'applicationId' })
application: Application;
@Column({ type: 'enum', enum: InvoiceReason, default: InvoiceReason.MANUAL })
reason: InvoiceReason;
@Column({ type: 'enum', enum: InvoiceStatus, default: InvoiceStatus.ISSUED })
status: InvoiceStatus;
@Column({ type: 'enum', enum: PaymentMethod, nullable: true })
paymentMethod: PaymentMethod;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
subtotal: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
total: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
paidAmount: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
dueAmount: number;
@Column({ type: 'timestamptz', nullable: true })
dueDate: Date;
@Column({ type: 'timestamptz', nullable: true })
paidAt: Date;
@Column({ nullable: true })
gatewayTrackingCode: string;
@Column({ nullable: true })
gatewayReference: string;
@Column({ nullable: true })
adminNote: string;
@Column({ nullable: true })
statusReason: string;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@OneToMany(() => InvoiceLine, (line) => line.invoice, { cascade: true })
lines: InvoiceLine[];
@OneToMany(() => WalletTransaction, (tx) => tx.invoice)
transactions: WalletTransaction[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -7,6 +7,7 @@ import {
CreateDateColumn,
} from 'typeorm';
import { Wallet } from './wallet.entity';
import { Invoice } from './invoice.entity';
import { TransactionType } from '../../common/enums';
@Entity('wallet_transactions')
@@ -29,6 +30,13 @@ export class WalletTransaction {
@Column({ nullable: true })
applicationId: string; // Linked application (for deductions)
@Column({ nullable: true })
invoiceId: string;
@ManyToOne(() => Invoice, (invoice: Invoice) => invoice.transactions, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'invoiceId' })
invoice: Invoice;
@ManyToOne(() => Wallet, (wallet: Wallet) => wallet.transactions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'walletId' })
wallet: Wallet;
@@ -36,6 +44,9 @@ export class WalletTransaction {
@Column()
walletId: string;
@Column({ nullable: true })
gatewayTrackingCode: string;
@CreateDateColumn()
createdAt: Date;
}