diff --git a/backend/migrations/010_invoices.sql b/backend/migrations/010_invoices.sql new file mode 100644 index 0000000..5c3ca6e --- /dev/null +++ b/backend/migrations/010_invoices.sql @@ -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"); diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index 897bc1e..c03685a 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -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; diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts index a9da3ba..155736d 100644 --- a/backend/src/billing/billing.module.ts +++ b/backend/src/billing/billing.module.ts @@ -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), diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 2d9aaa7..f4749a5 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -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, @InjectRepository(WalletTransaction) private txRepo: Repository, @InjectRepository(ResourceCredit) private creditRepo: Repository, + @InjectRepository(Invoice) private invoiceRepo: Repository, + @InjectRepository(InvoiceLine) private invoiceLineRepo: Repository, ) {} // ─── Pricing catalog (Admin) ────────────────────────────────────── @@ -85,7 +97,12 @@ export class BillingService { return { balance: Number(wallet.balance) }; } - async chargeWallet(userId: string, amount: number, description?: string): Promise { + async chargeWallet( + userId: string, + amount: number, + description?: string, + invoiceId?: string, + ): Promise { 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 { 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 { + 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 { 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 }[]; + dueDate?: Date; + metadata?: Record; + }): Promise { + 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 { + const isAdmin = user.role === UserRole.ADMIN; + const where: FindOptionsWhere = {}; + 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 { + 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 { + 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 { + 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) { + 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. diff --git a/backend/src/billing/dto/billing.dto.ts b/backend/src/billing/dto/billing.dto.ts index 21a82ed..23b35cb 100644 --- a/backend/src/billing/dto/billing.dto.ts +++ b/backend/src/billing/dto/billing.dto.ts @@ -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 { diff --git a/backend/src/billing/entities/invoice-line.entity.ts b/backend/src/billing/entities/invoice-line.entity.ts new file mode 100644 index 0000000..27c714d --- /dev/null +++ b/backend/src/billing/entities/invoice-line.entity.ts @@ -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; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/billing/entities/invoice.entity.ts b/backend/src/billing/entities/invoice.entity.ts new file mode 100644 index 0000000..72e6d98 --- /dev/null +++ b/backend/src/billing/entities/invoice.entity.ts @@ -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; + + @OneToMany(() => InvoiceLine, (line) => line.invoice, { cascade: true }) + lines: InvoiceLine[]; + + @OneToMany(() => WalletTransaction, (tx) => tx.invoice) + transactions: WalletTransaction[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/billing/entities/wallet-transaction.entity.ts b/backend/src/billing/entities/wallet-transaction.entity.ts index 541f69a..13cdb6b 100644 --- a/backend/src/billing/entities/wallet-transaction.entity.ts +++ b/backend/src/billing/entities/wallet-transaction.entity.ts @@ -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; } diff --git a/backend/src/common/enums.ts b/backend/src/common/enums.ts index a5adbba..2745a98 100644 --- a/backend/src/common/enums.ts +++ b/backend/src/common/enums.ts @@ -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 ──────────────────────────────────── diff --git a/frontend/src/app/dashboard/admin/invoices/page.tsx b/frontend/src/app/dashboard/admin/invoices/page.tsx new file mode 100644 index 0000000..3548730 --- /dev/null +++ b/frontend/src/app/dashboard/admin/invoices/page.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { FileText, Search, User, Wallet, CreditCard, XCircle } from 'lucide-react'; +import { toast } from 'react-toastify'; +import api from '@/lib/api'; +import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types'; + +const statusLabels: Record = { + draft: 'Draft', + issued: 'Unpaid', + partially_paid: 'Partially paid', + paid: 'Paid', + void: 'Void', + failed: 'Failed', +}; + +const statusClasses: Record = { + draft: 'badge-gray', + issued: 'badge-yellow', + partially_paid: 'badge-blue', + paid: 'badge-green', + void: 'badge-gray', + failed: 'badge-red', +}; + +export default function AdminInvoicesPage() { + const queryClient = useQueryClient(); + const [status, setStatus] = useState<'all' | InvoiceStatus>('all'); + const [paymentMethod, setPaymentMethod] = useState<'all' | PaymentMethod>('all'); + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [statusReason, setStatusReason] = useState(''); + + const { data: invoices = [], isLoading } = useQuery({ + queryKey: ['admin-invoices', status, paymentMethod, search], + queryFn: () => { + const params: Record = { limit: '200' }; + if (status !== 'all') params.status = status; + if (paymentMethod !== 'all') params.paymentMethod = paymentMethod; + if (search.trim()) params.search = search.trim(); + return api.get('/billing/admin/invoices', { params }).then((r) => r.data); + }, + }); + + const { data: selectedInvoice } = useQuery({ + queryKey: ['admin-invoice', selectedId], + queryFn: () => api.get(`/billing/admin/invoices/${selectedId}`).then((r) => r.data), + enabled: !!selectedId, + }); + + const updateStatusMutation = useMutation({ + mutationFn: ({ invoiceId, nextStatus, reason }: { invoiceId: string; nextStatus: InvoiceStatus; reason: string }) => + api.patch(`/billing/admin/invoices/${invoiceId}/status`, { status: nextStatus, reason }).then((r) => r.data), + onSuccess: () => { + toast.success('Invoice status updated'); + setStatusReason(''); + queryClient.invalidateQueries({ queryKey: ['admin-invoices'] }); + queryClient.invalidateQueries({ queryKey: ['admin-invoice', selectedId] }); + }, + onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update invoice status'), + }); + + const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US'); + const formatDate = (value?: string) => value ? new Date(value).toLocaleString('en-US') : '-'; + + const handleStatusUpdate = (nextStatus: InvoiceStatus) => { + if (!selectedInvoice) return; + if (!statusReason.trim()) { + toast.error('Reason is required for manual status changes'); + return; + } + updateStatusMutation.mutate({ + invoiceId: selectedInvoice.id, + nextStatus, + reason: statusReason.trim(), + }); + }; + + return ( +
+
+

+ Invoice Management +

+

Track all user invoices, payments, gateway refs, and wallet transactions.

+
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search invoice, email, or application" + className="input-field pl-9 w-full" + /> +
+ + +
+ +
+
+
+ + + + + + + + + + + + {isLoading ? ( + + ) : invoices.length === 0 ? ( + + ) : invoices.map((invoice) => ( + setSelectedId(invoice.id)} + className={`cursor-pointer hover:bg-gray-50 ${selectedId === invoice.id ? 'bg-primary-50' : ''}`} + > + + + + + + + ))} + +
InvoiceUserStatusMethodDue
Loading...
No invoices found
+

{invoice.invoiceNumber}

+

{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}

+
+

{invoice.user?.email || invoice.userId}

+
+ {statusLabels[invoice.status]} + {invoice.paymentMethod || '-'} +

{formatPrice(invoice.dueAmount)} T

+

Total {formatPrice(invoice.total)} T

+
+
+
+ +
+ {!selectedInvoice ? ( +
+ + Select an invoice +
+ ) : ( +
+
+
+

{selectedInvoice.invoiceNumber}

+

{selectedInvoice.application?.name || selectedInvoice.reason}

+
+ {statusLabels[selectedInvoice.status]} +
+ +
+
+

Total

+

{formatPrice(selectedInvoice.total)} T

+
+
+

Due

+

{formatPrice(selectedInvoice.dueAmount)} T

+
+
+ +
+
+ + {selectedInvoice.user?.email || selectedInvoice.userId} +
+
+ + Tracking: {selectedInvoice.gatewayTrackingCode || '-'} +
+
+ + Method: {selectedInvoice.paymentMethod || '-'} +
+
+ +
+

Line items

+
+ {(selectedInvoice.lines || []).map((line) => ( +
+
+

{line.label}

+ {line.description &&

{line.description}

} +
+ {formatPrice(line.amount)} T +
+ ))} +
+
+ +
+

Transactions

+ {(selectedInvoice.transactions || []).length === 0 ? ( +

No linked transactions yet

+ ) : ( +
+ {(selectedInvoice.transactions || []).map((tx) => ( +
+
+ {tx.description || tx.type} + {formatPrice(tx.amount)} T +
+

{tx.type} · {formatDate(tx.createdAt)}

+
+ ))} +
+ )} +
+ +
+

Manual status change

+