Add invoice payment management.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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");
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user