Add invoice payment management.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-18 21:34:47 +03:30
parent 880521c576
commit 8b197e69bc
15 changed files with 1616 additions and 58 deletions
+358 -29
View File
@@ -28,11 +28,14 @@ import {
RenewApplicationDto,
UpgradeResourcesDto,
CalculateUpgradeCostDto,
InitiateInvoicePaymentDto,
VerifyInvoiceGatewayDto,
UpdateInvoiceStatusDto,
} from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole, BillingCycle, AppLifecycleStatus } from '../common/enums';
import { UserRole, BillingCycle, AppLifecycleStatus, InvoiceReason, InvoiceStatus, PaymentMethod } from '../common/enums';
@ApiTags('Billing')
@ApiBearerAuth()
@@ -142,6 +145,76 @@ export class BillingController {
return credits.map((c) => this.billingService.formatCreditForApi(c));
}
// ─── Invoices ─────────────────────────────────────────────────────
@Get('invoices')
@ApiOperation({ summary: 'List my invoices' })
async listMyInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('applicationId') applicationId?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
applicationId,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('invoices/:id')
@ApiOperation({ summary: 'Get one invoice with line items and transactions' })
async getInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Post('invoices/:id/pay/wallet')
@ApiOperation({ summary: 'Pay invoice from wallet balance' })
async payInvoiceWallet(@Request() req: any, @Param('id') id: string) {
const result = await this.billingService.payInvoiceWithWallet(id, req.user);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('invoices/:id/pay/gateway')
@ApiOperation({ summary: 'Initiate direct gateway payment for invoice' })
async initiateInvoiceGateway(
@Request() req: any,
@Param('id') id: string,
@Body() dto: InitiateInvoicePaymentDto,
) {
return this.billingService.initiateInvoiceGatewayPayment(id, req.user, dto.callbackUrl);
}
@Post('invoices/:id/pay/mixed')
@ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' })
async initiateInvoiceMixed(
@Request() req: any,
@Param('id') id: string,
@Body() dto: InitiateInvoicePaymentDto,
) {
const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('invoices/:id/gateway/verify')
@ApiOperation({ summary: 'Verify invoice gateway payment' })
async verifyInvoiceGateway(
@Request() req: any,
@Param('id') id: string,
@Body() dto: VerifyInvoiceGatewayDto,
) {
const result = await this.billingService.verifyInvoiceGatewayPayment(
id,
req.user,
dto.trackingCode,
dto.amount,
);
const effect = await this.completePaidInvoiceEffect(result.invoice);
return { ...result, effect };
}
@Post('wallet/pay/:applicationId')
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
async payForApplication(
@@ -162,14 +235,37 @@ export class BillingController {
cycle,
);
let tx = null;
let invoice = null;
if (payment.amountDue > 0) {
tx = await this.billingService.deductWallet(
req.user.id,
payment.amountDue,
`Payment for app ${applicationId} (${cycle})`,
applicationId,
);
invoice = await this.billingService.createInvoice({
userId: req.user.id,
applicationId: app.id,
reason: InvoiceReason.DEPLOY,
lines: [
{
label: `Application payment: ${app.name}`,
description: `Billing cycle: ${cycle}`,
amount: payment.amountDue,
metadata: {
cycle,
waivedAmount: payment.waivedAmount,
creditApplied: payment.creditId || null,
},
},
],
metadata: {
action: 'activate',
cycle,
planExpiresAt: payment.planExpiresAt?.toISOString(),
},
});
}
let tx = null;
if (invoice) {
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
tx = paid.transaction;
invoice = paid.invoice;
}
const activated = await this.lifecycleService.activateApp(
@@ -180,6 +276,7 @@ export class BillingController {
return {
transaction: tx,
invoice,
creditApplied: payment.creditId || null,
waivedAmount: payment.waivedAmount,
paidAmount: payment.amountDue,
@@ -238,6 +335,47 @@ export class BillingController {
};
}
// ─── Invoice Admin ────────────────────────────────────────────────
@Get('admin/invoices')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all invoices (Admin)' })
async listAdminInvoices(
@Request() req: any,
@Query('status') status?: InvoiceStatus,
@Query('userId') userId?: string,
@Query('applicationId') applicationId?: string,
@Query('paymentMethod') paymentMethod?: PaymentMethod,
@Query('search') search?: string,
@Query('limit') limit?: string,
) {
return this.billingService.listInvoices(req.user, {
status,
userId,
applicationId,
paymentMethod,
search,
limit: limit ? parseInt(limit, 10) : undefined,
});
}
@Get('admin/invoices/:id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get invoice details (Admin)' })
async getAdminInvoice(@Request() req: any, @Param('id') id: string) {
return this.billingService.getInvoiceForUser(id, req.user);
}
@Patch('admin/invoices/:id/status')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update invoice status with reason (Admin)' })
async updateAdminInvoiceStatus(
@Param('id') id: string,
@Body() dto: UpdateInvoiceStatusDto,
) {
return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason);
}
// ─── Wallet Admin ─────────────────────────────────────────────────
@Get('admin/wallets')
@@ -278,6 +416,39 @@ export class BillingController {
};
}
@Post('applications/:applicationId/renew/invoice')
@ApiOperation({ summary: 'Create an unpaid renewal invoice for choosing wallet/gateway/mixed payment' })
async createRenewalInvoice(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() dto: RenewApplicationDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
const costs = await this.billingService.calculateRenewalCost(app);
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
if (amount <= 0) {
throw new BadRequestException('Invalid cost calculation — no pricing rules found');
}
return this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
reason: InvoiceReason.RENEWAL,
lines: [
{
label: `Renewal for ${app.name}`,
description: `Billing cycle: ${dto.cycle}`,
amount,
metadata: { cycle: dto.cycle },
},
],
metadata: { action: 'renew', cycle: dto.cycle },
});
}
@Post('applications/:applicationId/renew')
@ApiOperation({ summary: 'Renew an application (user pays from wallet)' })
async renewApplication(
@@ -298,24 +469,34 @@ export class BillingController {
throw new BadRequestException('Invalid cost calculation — no pricing rules found');
}
// Deduct from wallet (user's wallet for user, app owner's wallet for admin action)
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
? app.userId
: req.user.id;
const tx = await this.billingService.deductWallet(
walletUserId,
amount,
`Renewal for ${app.name} (${dto.cycle})`,
app.id,
);
const invoice = await this.billingService.createInvoice({
userId: walletUserId,
applicationId: app.id,
reason: InvoiceReason.RENEWAL,
lines: [
{
label: `Renewal for ${app.name}`,
description: `Billing cycle: ${dto.cycle}`,
amount,
metadata: { cycle: dto.cycle },
},
],
metadata: { action: 'renew', cycle: dto.cycle },
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
// Activate the application
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
return {
success: true,
transaction: tx,
transaction: paid.transaction,
invoice: paid.invoice,
application: {
id: renewedApp.id,
name: renewedApp.name,
@@ -365,18 +546,29 @@ export class BillingController {
: cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
const tx = await this.billingService.deductWallet(
app.userId,
amount,
`Renewal by ${req.user.role} for ${app.name} (${cycle})`,
app.id,
);
const invoice = await this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
reason: InvoiceReason.RENEWAL,
lines: [
{
label: `Renewal for ${app.name}`,
description: `Billing cycle: ${cycle}; initiated by ${req.user.role}`,
amount,
metadata: { cycle, initiatedBy: req.user.role },
},
],
metadata: { action: 'renew', cycle, initiatedBy: req.user.role },
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
return {
success: true,
transaction: tx,
transaction: paid.transaction,
invoice: paid.invoice,
application: {
id: renewedApp.id,
name: renewedApp.name,
@@ -424,6 +616,49 @@ export class BillingController {
};
}
@Post('applications/:applicationId/upgrade/invoice')
@ApiOperation({ summary: 'Create an unpaid upgrade invoice for choosing wallet/gateway/mixed payment' })
async createUpgradeInvoice(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() dto: UpgradeResourcesDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
throw new BadRequestException(
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`,
);
}
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
if (costResult.proratedAmount <= 0) {
throw new BadRequestException('This change does not require a paid invoice');
}
return this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
reason: InvoiceReason.UPGRADE,
lines: [
{
label: `Resource upgrade for ${app.name}`,
description: `Prorated for ${costResult.remainingHours} hours`,
amount: costResult.proratedAmount,
metadata: {
remainingHours: costResult.remainingHours,
currentCost: costResult.currentCost,
newCost: costResult.newCost,
},
},
],
metadata: {
action: 'upgrade',
resources: dto,
remainingHours: costResult.remainingHours,
},
});
}
@Post('applications/:applicationId/upgrade')
@ApiOperation({ summary: 'Upgrade application resources (with payment)' })
async upgradeResources(
@@ -442,6 +677,7 @@ export class BillingController {
// Calculate upgrade cost
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
let paidInvoice = null;
// If upgrading (positive difference), require payment
if (costResult.proratedAmount > 0) {
@@ -449,12 +685,30 @@ export class BillingController {
? app.userId
: req.user.id;
await this.billingService.deductWallet(
walletUserId,
costResult.proratedAmount,
`Resource upgrade for ${app.name}: prorated ${costResult.remainingHours}h`,
app.id,
);
const invoice = await this.billingService.createInvoice({
userId: walletUserId,
applicationId: app.id,
reason: InvoiceReason.UPGRADE,
lines: [
{
label: `Resource upgrade for ${app.name}`,
description: `Prorated for ${costResult.remainingHours} hours`,
amount: costResult.proratedAmount,
metadata: {
remainingHours: costResult.remainingHours,
currentCost: costResult.currentCost,
newCost: costResult.newCost,
},
},
],
metadata: {
action: 'upgrade',
resources: dto,
remainingHours: costResult.remainingHours,
},
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
paidInvoice = paid.invoice;
}
// Apply the resource changes
@@ -490,6 +744,7 @@ export class BillingController {
return {
success: true,
paidAmount: costResult.proratedAmount,
invoice: paidInvoice,
application: {
id: updatedApp.id,
name: updatedApp.name,
@@ -509,6 +764,80 @@ export class BillingController {
// ─── Helper Methods ───────────────────────────────────────────────
private async completePaidInvoiceEffect(invoice: any) {
if (invoice.status !== InvoiceStatus.PAID) return null;
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
const action = invoice.metadata?.action;
if (!action || !invoice.applicationId) return null;
if (action === 'renew' || action === 'activate') {
const cycle = invoice.metadata?.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) return null;
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
const result = {
action,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
billingCycle: activated.billingCycle,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
if (action === 'upgrade') {
const app = await this.applicationsService.findOne(invoice.applicationId);
const resources = invoice.metadata?.resources || {};
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
cpuRequest: resources.cpuRequest || app.cpuRequest,
cpuLimit: resources.cpuLimit || app.cpuLimit,
memoryRequest: resources.memoryRequest || app.memoryRequest,
memoryLimit: resources.memoryLimit || app.memoryLimit,
replicas: resources.replicas ?? app.replicas,
dbStorageSize: resources.dbStorageSize || app.dbStorageSize,
appStorageSize: resources.appStorageSize || app.appStorageSize,
});
try {
await this.kubernetesService.updateResources(updatedApp, {
cpuRequest: resources.cpuRequest,
cpuLimit: resources.cpuLimit,
memoryRequest: resources.memoryRequest,
memoryLimit: resources.memoryLimit,
replicas: resources.replicas,
});
if (resources.appStorageSize && resources.appStorageSize !== app.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(updatedApp, resources.appStorageSize);
}
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
const result = {
action,
application: {
id: updatedApp.id,
name: updatedApp.name,
cpuRequest: updatedApp.cpuRequest,
cpuLimit: updatedApp.cpuLimit,
memoryRequest: updatedApp.memoryRequest,
memoryLimit: updatedApp.memoryLimit,
replicas: updatedApp.replicas,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
return null;
}
private async getAppWithAccess(user: any, applicationId: string) {
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;