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