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
+52
View File
@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"invoiceNumber" VARCHAR NOT NULL UNIQUE,
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"applicationId" UUID REFERENCES applications(id) ON DELETE SET NULL,
reason VARCHAR NOT NULL DEFAULT 'manual',
status VARCHAR NOT NULL DEFAULT 'issued',
"paymentMethod" VARCHAR,
subtotal DECIMAL(14, 2) NOT NULL DEFAULT 0,
total DECIMAL(14, 2) NOT NULL DEFAULT 0,
"paidAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0,
"dueAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0,
"dueDate" TIMESTAMPTZ,
"paidAt" TIMESTAMPTZ,
"gatewayTrackingCode" VARCHAR,
"gatewayReference" VARCHAR,
"adminNote" VARCHAR,
"statusReason" VARCHAR,
metadata JSONB,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS invoice_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"invoiceId" UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
description VARCHAR,
quantity INT NOT NULL DEFAULT 1,
"unitAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0,
amount DECIMAL(14, 2) NOT NULL DEFAULT 0,
metadata JSONB,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE wallet_transactions
ADD COLUMN IF NOT EXISTS "invoiceId" UUID REFERENCES invoices(id) ON DELETE SET NULL;
ALTER TABLE wallet_transactions
ADD COLUMN IF NOT EXISTS "gatewayTrackingCode" VARCHAR;
CREATE INDEX IF NOT EXISTS idx_invoices_user_status_created
ON invoices ("userId", status, "createdAt" DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_application
ON invoices ("applicationId");
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice
ON invoice_lines ("invoiceId");
CREATE INDEX IF NOT EXISTS idx_wallet_transactions_invoice
ON wallet_transactions ("invoiceId");
+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;
}
+24
View File
@@ -111,6 +111,30 @@ export enum TransactionType {
CHARGE = 'charge', // Top-up / deposit
DEDUCTION = 'deduction', // Payment for service
REFUND = 'refund', // Refund
GATEWAY_PAYMENT = 'gateway_payment', // Direct payment through a gateway
}
export enum InvoiceStatus {
DRAFT = 'draft',
ISSUED = 'issued',
PARTIALLY_PAID = 'partially_paid',
PAID = 'paid',
VOID = 'void',
FAILED = 'failed',
}
export enum PaymentMethod {
WALLET = 'wallet',
GATEWAY = 'gateway',
MIXED = 'mixed',
}
export enum InvoiceReason {
DEPLOY = 'deploy',
RENEWAL = 'renewal',
UPGRADE = 'upgrade',
WALLET_TOPUP = 'wallet_topup',
MANUAL = 'manual',
}
// ── Custom Domain ────────────────────────────────────