Add invoice payment management.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-18 21:34:47 +03:30
parent 880521c576
commit 8b197e69bc
15 changed files with 1616 additions and 58 deletions
+52
View File
@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"invoiceNumber" VARCHAR NOT NULL UNIQUE,
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"applicationId" UUID REFERENCES applications(id) ON DELETE SET NULL,
reason VARCHAR NOT NULL DEFAULT 'manual',
status VARCHAR NOT NULL DEFAULT 'issued',
"paymentMethod" VARCHAR,
subtotal DECIMAL(14, 2) NOT NULL DEFAULT 0,
total DECIMAL(14, 2) NOT NULL DEFAULT 0,
"paidAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0,
"dueAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0,
"dueDate" TIMESTAMPTZ,
"paidAt" TIMESTAMPTZ,
"gatewayTrackingCode" VARCHAR,
"gatewayReference" VARCHAR,
"adminNote" VARCHAR,
"statusReason" VARCHAR,
metadata JSONB,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS invoice_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"invoiceId" UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
label VARCHAR NOT NULL,
description VARCHAR,
quantity INT NOT NULL DEFAULT 1,
"unitAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0,
amount DECIMAL(14, 2) NOT NULL DEFAULT 0,
metadata JSONB,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE wallet_transactions
ADD COLUMN IF NOT EXISTS "invoiceId" UUID REFERENCES invoices(id) ON DELETE SET NULL;
ALTER TABLE wallet_transactions
ADD COLUMN IF NOT EXISTS "gatewayTrackingCode" VARCHAR;
CREATE INDEX IF NOT EXISTS idx_invoices_user_status_created
ON invoices ("userId", status, "createdAt" DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_application
ON invoices ("applicationId");
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice
ON invoice_lines ("invoiceId");
CREATE INDEX IF NOT EXISTS idx_wallet_transactions_invoice
ON wallet_transactions ("invoiceId");
+356 -27
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,
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,
`Renewal for ${app.name} (${dto.cycle})`,
app.id,
);
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,
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,
`Renewal by ${req.user.role} for ${app.name} (${cycle})`,
app.id,
);
metadata: { cycle, initiatedBy: req.user.role },
},
],
metadata: { action: 'renew', cycle, initiatedBy: req.user.role },
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
return {
success: true,
transaction: tx,
transaction: paid.transaction,
invoice: paid.invoice,
application: {
id: renewedApp.id,
name: renewedApp.name,
@@ -424,6 +616,49 @@ export class BillingController {
};
}
@Post('applications/:applicationId/upgrade/invoice')
@ApiOperation({ summary: 'Create an unpaid upgrade invoice for choosing wallet/gateway/mixed payment' })
async createUpgradeInvoice(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() dto: UpgradeResourcesDto,
) {
const app = await this.getAppWithAccess(req.user, applicationId);
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
throw new BadRequestException(
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`,
);
}
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
if (costResult.proratedAmount <= 0) {
throw new BadRequestException('This change does not require a paid invoice');
}
return this.billingService.createInvoice({
userId: app.userId,
applicationId: app.id,
reason: InvoiceReason.UPGRADE,
lines: [
{
label: `Resource upgrade for ${app.name}`,
description: `Prorated for ${costResult.remainingHours} hours`,
amount: costResult.proratedAmount,
metadata: {
remainingHours: costResult.remainingHours,
currentCost: costResult.currentCost,
newCost: costResult.newCost,
},
},
],
metadata: {
action: 'upgrade',
resources: dto,
remainingHours: costResult.remainingHours,
},
});
}
@Post('applications/:applicationId/upgrade')
@ApiOperation({ summary: 'Upgrade application resources (with payment)' })
async upgradeResources(
@@ -442,6 +677,7 @@ export class BillingController {
// Calculate upgrade cost
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
let paidInvoice = null;
// If upgrading (positive difference), require payment
if (costResult.proratedAmount > 0) {
@@ -449,12 +685,30 @@ export class BillingController {
? app.userId
: req.user.id;
await this.billingService.deductWallet(
walletUserId,
costResult.proratedAmount,
`Resource upgrade for ${app.name}: prorated ${costResult.remainingHours}h`,
app.id,
);
const invoice = await this.billingService.createInvoice({
userId: walletUserId,
applicationId: app.id,
reason: InvoiceReason.UPGRADE,
lines: [
{
label: `Resource upgrade for ${app.name}`,
description: `Prorated for ${costResult.remainingHours} hours`,
amount: costResult.proratedAmount,
metadata: {
remainingHours: costResult.remainingHours,
currentCost: costResult.currentCost,
newCost: costResult.newCost,
},
},
],
metadata: {
action: 'upgrade',
resources: dto,
remainingHours: costResult.remainingHours,
},
});
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
paidInvoice = paid.invoice;
}
// Apply the resource changes
@@ -490,6 +744,7 @@ export class BillingController {
return {
success: true,
paidAmount: costResult.proratedAmount,
invoice: paidInvoice,
application: {
id: updatedApp.id,
name: updatedApp.name,
@@ -509,6 +764,80 @@ export class BillingController {
// ─── Helper Methods ───────────────────────────────────────────────
private async completePaidInvoiceEffect(invoice: any) {
if (invoice.status !== InvoiceStatus.PAID) return null;
if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null;
const action = invoice.metadata?.action;
if (!action || !invoice.applicationId) return null;
if (action === 'renew' || action === 'activate') {
const cycle = invoice.metadata?.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) return null;
const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle);
const result = {
action,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
billingCycle: activated.billingCycle,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
if (action === 'upgrade') {
const app = await this.applicationsService.findOne(invoice.applicationId);
const resources = invoice.metadata?.resources || {};
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
cpuRequest: resources.cpuRequest || app.cpuRequest,
cpuLimit: resources.cpuLimit || app.cpuLimit,
memoryRequest: resources.memoryRequest || app.memoryRequest,
memoryLimit: resources.memoryLimit || app.memoryLimit,
replicas: resources.replicas ?? app.replicas,
dbStorageSize: resources.dbStorageSize || app.dbStorageSize,
appStorageSize: resources.appStorageSize || app.appStorageSize,
});
try {
await this.kubernetesService.updateResources(updatedApp, {
cpuRequest: resources.cpuRequest,
cpuLimit: resources.cpuLimit,
memoryRequest: resources.memoryRequest,
memoryLimit: resources.memoryLimit,
replicas: resources.replicas,
});
if (resources.appStorageSize && resources.appStorageSize !== app.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(updatedApp, resources.appStorageSize);
}
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
const result = {
action,
application: {
id: updatedApp.id,
name: updatedApp.name,
cpuRequest: updatedApp.cpuRequest,
cpuLimit: updatedApp.cpuLimit,
memoryRequest: updatedApp.memoryRequest,
memoryLimit: updatedApp.memoryLimit,
replicas: updatedApp.replicas,
},
};
await this.billingService.markInvoiceEffectCompleted(invoice.id, result);
return result;
}
return null;
}
private async getAppWithAccess(user: any, applicationId: string) {
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
+4
View File
@@ -10,6 +10,8 @@ import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
import { Invoice } from './entities/invoice.entity';
import { InvoiceLine } from './entities/invoice-line.entity';
import { LifecycleModule } from '../lifecycle/lifecycle.module';
import { ApplicationsModule } from '../applications/applications.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
@@ -24,6 +26,8 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
Wallet,
WalletTransaction,
ResourceCredit,
Invoice,
InvoiceLine,
]),
forwardRef(() => LifecycleModule),
forwardRef(() => ApplicationsModule),
+340 -4
View File
@@ -1,9 +1,19 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, MoreThan } from 'typeorm';
import { Repository, IsNull, MoreThan, FindOptionsWhere } from 'typeorm';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { TransactionType, BillingCycle, DatabaseType } from '../common/enums';
import { Invoice } from './entities/invoice.entity';
import { InvoiceLine } from './entities/invoice-line.entity';
import {
TransactionType,
BillingCycle,
DatabaseType,
InvoiceReason,
InvoiceStatus,
PaymentMethod,
UserRole,
} from '../common/enums';
import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { Application } from '../applications/entities/application.entity';
@@ -19,6 +29,8 @@ export class BillingService {
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
@InjectRepository(Invoice) private invoiceRepo: Repository<Invoice>,
@InjectRepository(InvoiceLine) private invoiceLineRepo: Repository<InvoiceLine>,
) {}
// ─── Pricing catalog (Admin) ──────────────────────────────────────
@@ -85,7 +97,12 @@ export class BillingService {
return { balance: Number(wallet.balance) };
}
async chargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
async chargeWallet(
userId: string,
amount: number,
description?: string,
invoiceId?: string,
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId);
@@ -98,6 +115,7 @@ export class BillingService {
amount,
balanceAfter: wallet.balance,
description: description || 'Wallet charge',
invoiceId,
});
const saved = await this.txRepo.save(tx);
@@ -110,6 +128,7 @@ export class BillingService {
amount: number,
description?: string,
applicationId?: string,
invoiceId?: string,
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
@@ -128,6 +147,7 @@ export class BillingService {
balanceAfter: wallet.balance,
description: description || 'Service payment',
applicationId,
invoiceId,
});
const saved = await this.txRepo.save(tx);
@@ -139,11 +159,36 @@ export class BillingService {
const wallet = await this.getOrCreateWallet(userId);
return this.txRepo.find({
where: { walletId: wallet.id },
relations: ['invoice'],
order: { createdAt: 'DESC' },
take: limit,
});
}
async recordGatewayPayment(
userId: string,
amount: number,
description: string,
applicationId?: string,
invoiceId?: string,
gatewayTrackingCode?: string,
): Promise<WalletTransaction> {
if (amount <= 0) throw new BadRequestException('Amount must be positive');
const wallet = await this.getOrCreateWallet(userId);
const tx = this.txRepo.create({
walletId: wallet.id,
type: TransactionType.GATEWAY_PAYMENT,
amount,
balanceAfter: wallet.balance,
description,
applicationId,
invoiceId,
gatewayTrackingCode,
});
return this.txRepo.save(tx);
}
// Admin: charge any user's wallet
async adminChargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
return this.chargeWallet(userId, amount, description || 'Admin charge');
@@ -154,6 +199,297 @@ export class BillingService {
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
}
// ─── Invoices ─────────────────────────────────────────────────────
private generateInvoiceNumber(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const suffix = Math.random().toString(36).slice(2, 8).toUpperCase();
return `INV-${date}-${suffix}`;
}
private normalizeAmount(amount: number): number {
return Math.max(0, Math.round(Number(amount || 0) * 100) / 100);
}
async createInvoice(input: {
userId: string;
applicationId?: string;
reason: InvoiceReason;
lines: { label: string; description?: string; quantity?: number; unitAmount?: number; amount: number; metadata?: Record<string, any> }[];
dueDate?: Date;
metadata?: Record<string, any>;
}): Promise<Invoice> {
const lines = input.lines
.filter((line) => this.normalizeAmount(line.amount) > 0)
.map((line) => {
const quantity = line.quantity || 1;
const amount = this.normalizeAmount(line.amount);
return this.invoiceLineRepo.create({
label: line.label,
description: line.description,
quantity,
unitAmount: this.normalizeAmount(line.unitAmount ?? amount / quantity),
amount,
metadata: line.metadata,
});
});
const total = this.normalizeAmount(lines.reduce((sum, line) => sum + Number(line.amount), 0));
if (total <= 0) {
throw new BadRequestException('Invoice total must be positive');
}
const invoice = this.invoiceRepo.create({
invoiceNumber: this.generateInvoiceNumber(),
userId: input.userId,
applicationId: input.applicationId,
reason: input.reason,
status: InvoiceStatus.ISSUED,
subtotal: total,
total,
paidAmount: 0,
dueAmount: total,
dueDate: input.dueDate,
metadata: input.metadata,
lines,
});
return this.invoiceRepo.save(invoice);
}
async listInvoices(
user: { id: string; role?: UserRole },
filters: {
status?: InvoiceStatus;
applicationId?: string;
userId?: string;
paymentMethod?: PaymentMethod;
search?: string;
limit?: number;
} = {},
): Promise<Invoice[]> {
const isAdmin = user.role === UserRole.ADMIN;
const where: FindOptionsWhere<Invoice> = {};
if (!isAdmin) where.userId = user.id;
if (isAdmin && filters.userId) where.userId = filters.userId;
if (filters.status) where.status = filters.status;
if (filters.applicationId) where.applicationId = filters.applicationId;
if (filters.paymentMethod) where.paymentMethod = filters.paymentMethod;
const qb = this.invoiceRepo
.createQueryBuilder('invoice')
.leftJoinAndSelect('invoice.user', 'user')
.leftJoinAndSelect('invoice.application', 'application')
.leftJoinAndSelect('invoice.lines', 'lines')
.where(where)
.orderBy('invoice.createdAt', 'DESC')
.take(Math.min(filters.limit || 100, 200));
if (filters.search) {
qb.andWhere(
'(invoice."invoiceNumber" ILIKE :search OR user.email ILIKE :search OR application.name ILIKE :search)',
{ search: `%${filters.search}%` },
);
}
return qb.getMany();
}
async getInvoiceForUser(invoiceId: string, user: { id: string; role?: UserRole }): Promise<Invoice> {
const invoice = await this.invoiceRepo.findOne({
where: { id: invoiceId },
relations: ['user', 'application', 'lines', 'transactions'],
order: { lines: { createdAt: 'ASC' }, transactions: { createdAt: 'DESC' } },
});
if (!invoice) throw new NotFoundException('Invoice not found');
if (user.role !== UserRole.ADMIN && invoice.userId !== user.id) {
throw new ForbiddenException('You do not have access to this invoice');
}
return invoice;
}
private ensureInvoicePayable(invoice: Invoice) {
if ([InvoiceStatus.PAID, InvoiceStatus.VOID].includes(invoice.status)) {
throw new BadRequestException(`Invoice is ${invoice.status}`);
}
if (Number(invoice.dueAmount) <= 0) {
throw new BadRequestException('Invoice has no due amount');
}
}
private async applyInvoicePayment(
invoice: Invoice,
amount: number,
paymentMethod: PaymentMethod,
gatewayTrackingCode?: string,
gatewayReference?: string,
): Promise<Invoice> {
const paidAmount = this.normalizeAmount(Number(invoice.paidAmount) + amount);
const dueAmount = this.normalizeAmount(Number(invoice.total) - paidAmount);
invoice.paidAmount = paidAmount;
invoice.dueAmount = dueAmount;
invoice.paymentMethod = invoice.paymentMethod && invoice.paymentMethod !== paymentMethod
? PaymentMethod.MIXED
: paymentMethod;
invoice.status = dueAmount <= 0 ? InvoiceStatus.PAID : InvoiceStatus.PARTIALLY_PAID;
invoice.paidAt = dueAmount <= 0 ? new Date() : invoice.paidAt;
invoice.gatewayTrackingCode = gatewayTrackingCode || invoice.gatewayTrackingCode;
invoice.gatewayReference = gatewayReference || invoice.gatewayReference;
return this.invoiceRepo.save(invoice);
}
async payInvoiceWithWallet(invoiceId: string, user: { id: string; role?: UserRole }) {
const invoice = await this.getInvoiceForUser(invoiceId, user);
this.ensureInvoicePayable(invoice);
const amount = Number(invoice.dueAmount);
const tx = await this.deductWallet(
invoice.userId,
amount,
`Invoice ${invoice.invoiceNumber}: ${invoice.reason}`,
invoice.applicationId,
invoice.id,
);
const updated = await this.applyInvoicePayment(invoice, amount, PaymentMethod.WALLET);
return { invoice: updated, transaction: tx };
}
async initiateInvoiceGatewayPayment(
invoiceId: string,
user: { id: string; role?: UserRole },
callbackUrl: string,
) {
const invoice = await this.getInvoiceForUser(invoiceId, user);
this.ensureInvoicePayable(invoice);
const amount = Number(invoice.dueAmount);
const trackingCode = `INV-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
invoice.paymentMethod = invoice.paymentMethod && invoice.paymentMethod !== PaymentMethod.GATEWAY
? PaymentMethod.MIXED
: PaymentMethod.GATEWAY;
invoice.gatewayTrackingCode = trackingCode;
await this.invoiceRepo.save(invoice);
return {
success: true,
invoice,
amount,
trackingCode,
gatewayUrl: `${callbackUrl}?invoiceId=${invoice.id}&trackingCode=${trackingCode}&amount=${amount}&status=success`,
};
}
async initiateInvoiceMixedPayment(
invoiceId: string,
user: { id: string; role?: UserRole },
callbackUrl: string,
) {
const invoice = await this.getInvoiceForUser(invoiceId, user);
this.ensureInvoicePayable(invoice);
const wallet = await this.getOrCreateWallet(invoice.userId);
const walletAmount = Math.min(Number(wallet.balance), Number(invoice.dueAmount));
let updatedInvoice = invoice;
let walletTransaction: WalletTransaction | null = null;
if (walletAmount > 0) {
walletTransaction = await this.deductWallet(
invoice.userId,
walletAmount,
`Partial wallet payment for invoice ${invoice.invoiceNumber}`,
invoice.applicationId,
invoice.id,
);
updatedInvoice = await this.applyInvoicePayment(invoice, walletAmount, PaymentMethod.MIXED);
} else {
invoice.paymentMethod = PaymentMethod.MIXED;
updatedInvoice = await this.invoiceRepo.save(invoice);
}
if (Number(updatedInvoice.dueAmount) <= 0) {
return {
success: true,
invoice: updatedInvoice,
walletAmount,
gatewayAmount: 0,
walletTransaction,
message: 'Invoice paid from wallet balance',
};
}
const trackingCode = `INV-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
updatedInvoice.gatewayTrackingCode = trackingCode;
updatedInvoice.paymentMethod = PaymentMethod.MIXED;
updatedInvoice = await this.invoiceRepo.save(updatedInvoice);
return {
success: true,
invoice: updatedInvoice,
walletAmount,
gatewayAmount: Number(updatedInvoice.dueAmount),
walletTransaction,
trackingCode,
gatewayUrl: `${callbackUrl}?invoiceId=${updatedInvoice.id}&trackingCode=${trackingCode}&amount=${updatedInvoice.dueAmount}&status=success`,
};
}
async verifyInvoiceGatewayPayment(
invoiceId: string,
user: { id: string; role?: UserRole },
trackingCode: string,
amount: number,
) {
const invoice = await this.getInvoiceForUser(invoiceId, user);
this.ensureInvoicePayable(invoice);
if (invoice.gatewayTrackingCode && invoice.gatewayTrackingCode !== trackingCode) {
throw new BadRequestException('Invalid gateway tracking code');
}
const payableAmount = Math.min(Number(invoice.dueAmount), this.normalizeAmount(amount));
const tx = await this.recordGatewayPayment(
invoice.userId,
payableAmount,
`Gateway payment for invoice ${invoice.invoiceNumber}`,
invoice.applicationId,
invoice.id,
trackingCode,
);
const updated = await this.applyInvoicePayment(
invoice,
payableAmount,
invoice.paymentMethod === PaymentMethod.MIXED ? PaymentMethod.MIXED : PaymentMethod.GATEWAY,
trackingCode,
`REF-${trackingCode}`,
);
return { invoice: updated, transaction: tx };
}
async updateInvoiceStatus(
invoiceId: string,
status: InvoiceStatus,
reason: string,
): Promise<Invoice> {
const invoice = await this.getInvoiceForUser(invoiceId, { id: '', role: UserRole.ADMIN });
invoice.status = status;
invoice.statusReason = reason;
if (status === InvoiceStatus.VOID || status === InvoiceStatus.FAILED) {
invoice.dueAmount = Math.max(0, Number(invoice.total) - Number(invoice.paidAmount));
}
return this.invoiceRepo.save(invoice);
}
async markInvoiceEffectCompleted(invoiceId: string, result: Record<string, any>) {
const invoice = await this.getInvoiceForUser(invoiceId, { id: '', role: UserRole.ADMIN });
invoice.metadata = {
...(invoice.metadata || {}),
completedAt: new Date().toISOString(),
completionResult: result,
};
return this.invoiceRepo.save(invoice);
}
/**
* Calculate cost for an existing Application entity.
* Used by lifecycle service for auto-renew.
+30 -1
View File
@@ -1,7 +1,7 @@
import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNested, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BillingCycle, PricingResourceType, AppRuntime } from '../../common/enums';
import { BillingCycle, PricingResourceType, AppRuntime, InvoiceStatus } from '../../common/enums';
import { OptionalServiceResourcesDto } from './optional-service-resources.dto';
export class CreatePricingRuleDto {
@@ -210,6 +210,35 @@ export class UpgradeResourcesDto {
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}
// ─── Invoice DTOs ───────────────────────────────────────────────────
export class InitiateInvoicePaymentDto {
@ApiProperty({ example: 'https://app.example.com/dashboard/invoices' })
@IsString()
callbackUrl: string;
}
export class VerifyInvoiceGatewayDto {
@ApiProperty({ example: 'INV-1710000000000-ABC123' })
@IsString()
trackingCode: string;
@ApiProperty({ example: 50000 })
@IsNumber()
@Min(1)
amount: number;
}
export class UpdateInvoiceStatusDto {
@ApiProperty({ enum: InvoiceStatus })
@IsEnum(InvoiceStatus)
status: InvoiceStatus;
@ApiProperty({ example: 'Manual reconciliation by finance team' })
@IsString()
reason: string;
}
// ─── Platform optional services pricing (Admin) ─────────────────────
export class OptionalServiceCyclePricesDto {
@@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Invoice } from './invoice.entity';
@Entity('invoice_lines')
export class InvoiceLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
invoiceId: string;
@ManyToOne(() => Invoice, (invoice) => invoice.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoiceId' })
invoice: Invoice;
@Column()
label: string;
@Column({ nullable: true })
description: string;
@Column({ type: 'int', default: 1 })
quantity: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
unitAmount: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
amount: number;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,92 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Application } from '../../applications/entities/application.entity';
import { InvoiceLine } from './invoice-line.entity';
import { WalletTransaction } from './wallet-transaction.entity';
import { InvoiceReason, InvoiceStatus, PaymentMethod } from '../../common/enums';
@Entity('invoices')
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
invoiceNumber: string;
@Column()
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column({ nullable: true })
applicationId: string;
@ManyToOne(() => Application, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'applicationId' })
application: Application;
@Column({ type: 'enum', enum: InvoiceReason, default: InvoiceReason.MANUAL })
reason: InvoiceReason;
@Column({ type: 'enum', enum: InvoiceStatus, default: InvoiceStatus.ISSUED })
status: InvoiceStatus;
@Column({ type: 'enum', enum: PaymentMethod, nullable: true })
paymentMethod: PaymentMethod;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
subtotal: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
total: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
paidAmount: number;
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
dueAmount: number;
@Column({ type: 'timestamptz', nullable: true })
dueDate: Date;
@Column({ type: 'timestamptz', nullable: true })
paidAt: Date;
@Column({ nullable: true })
gatewayTrackingCode: string;
@Column({ nullable: true })
gatewayReference: string;
@Column({ nullable: true })
adminNote: string;
@Column({ nullable: true })
statusReason: string;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@OneToMany(() => InvoiceLine, (line) => line.invoice, { cascade: true })
lines: InvoiceLine[];
@OneToMany(() => WalletTransaction, (tx) => tx.invoice)
transactions: WalletTransaction[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -7,6 +7,7 @@ import {
CreateDateColumn,
} from 'typeorm';
import { Wallet } from './wallet.entity';
import { Invoice } from './invoice.entity';
import { TransactionType } from '../../common/enums';
@Entity('wallet_transactions')
@@ -29,6 +30,13 @@ export class WalletTransaction {
@Column({ nullable: true })
applicationId: string; // Linked application (for deductions)
@Column({ nullable: true })
invoiceId: string;
@ManyToOne(() => Invoice, (invoice: Invoice) => invoice.transactions, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'invoiceId' })
invoice: Invoice;
@ManyToOne(() => Wallet, (wallet: Wallet) => wallet.transactions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'walletId' })
wallet: Wallet;
@@ -36,6 +44,9 @@ export class WalletTransaction {
@Column()
walletId: string;
@Column({ nullable: true })
gatewayTrackingCode: string;
@CreateDateColumn()
createdAt: Date;
}
+24
View File
@@ -111,6 +111,30 @@ export enum TransactionType {
CHARGE = 'charge', // Top-up / deposit
DEDUCTION = 'deduction', // Payment for service
REFUND = 'refund', // Refund
GATEWAY_PAYMENT = 'gateway_payment', // Direct payment through a gateway
}
export enum InvoiceStatus {
DRAFT = 'draft',
ISSUED = 'issued',
PARTIALLY_PAID = 'partially_paid',
PAID = 'paid',
VOID = 'void',
FAILED = 'failed',
}
export enum PaymentMethod {
WALLET = 'wallet',
GATEWAY = 'gateway',
MIXED = 'mixed',
}
export enum InvoiceReason {
DEPLOY = 'deploy',
RENEWAL = 'renewal',
UPGRADE = 'upgrade',
WALLET_TOPUP = 'wallet_topup',
MANUAL = 'manual',
}
// ── Custom Domain ────────────────────────────────────
@@ -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<InvoiceStatus, string> = {
draft: 'Draft',
issued: 'Unpaid',
partially_paid: 'Partially paid',
paid: 'Paid',
void: 'Void',
failed: 'Failed',
};
const statusClasses: Record<InvoiceStatus, string> = {
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<string | null>(null);
const [statusReason, setStatusReason] = useState('');
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['admin-invoices', status, paymentMethod, search],
queryFn: () => {
const params: Record<string, string> = { 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<Invoice>({
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 (
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in">
<div>
<h1 className="page-title flex items-center gap-2">
<FileText className="w-6 h-6" /> Invoice Management
</h1>
<p className="page-subtitle">Track all user invoices, payments, gateway refs, and wallet transactions.</p>
</div>
<div className="card grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="relative md:col-span-2">
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search invoice, email, or application"
className="input-field pl-9 w-full"
/>
</div>
<select value={status} onChange={(e) => setStatus(e.target.value as any)} className="input-field">
<option value="all">All statuses</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value as any)} className="input-field">
<option value="all">All methods</option>
<option value="wallet">Wallet</option>
<option value="gateway">Gateway</option>
<option value="mixed">Mixed</option>
</select>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 card p-0 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-100">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Invoice</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">User</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Method</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase">Due</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{isLoading ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">Loading...</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">No invoices found</td></tr>
) : invoices.map((invoice) => (
<tr
key={invoice.id}
onClick={() => setSelectedId(invoice.id)}
className={`cursor-pointer hover:bg-gray-50 ${selectedId === invoice.id ? 'bg-primary-50' : ''}`}
>
<td className="px-4 py-3">
<p className="text-sm font-semibold text-gray-900">{invoice.invoiceNumber}</p>
<p className="text-xs text-gray-500">{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-900">{invoice.user?.email || invoice.userId}</p>
</td>
<td className="px-4 py-3">
<span className={statusClasses[invoice.status]}>{statusLabels[invoice.status]}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{invoice.paymentMethod || '-'}</td>
<td className="px-4 py-3 text-right">
<p className="text-sm font-bold text-gray-900">{formatPrice(invoice.dueAmount)} T</p>
<p className="text-xs text-gray-400">Total {formatPrice(invoice.total)} T</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
{!selectedInvoice ? (
<div className="text-center py-12 text-gray-400">
<FileText className="w-10 h-10 mx-auto mb-3" />
Select an invoice
</div>
) : (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
</div>
<span className={statusClasses[selectedInvoice.status]}>{statusLabels[selectedInvoice.status]}</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-500">Total</p>
<p className="font-bold text-gray-900">{formatPrice(selectedInvoice.total)} T</p>
</div>
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-500">Due</p>
<p className="font-bold text-primary-700">{formatPrice(selectedInvoice.dueAmount)} T</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-gray-700">
<User className="w-4 h-4 text-gray-400" />
{selectedInvoice.user?.email || selectedInvoice.userId}
</div>
<div className="flex items-center gap-2 text-gray-700">
<CreditCard className="w-4 h-4 text-gray-400" />
Tracking: {selectedInvoice.gatewayTrackingCode || '-'}
</div>
<div className="flex items-center gap-2 text-gray-700">
<Wallet className="w-4 h-4 text-gray-400" />
Method: {selectedInvoice.paymentMethod || '-'}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Line items</h3>
<div className="space-y-2">
{(selectedInvoice.lines || []).map((line) => (
<div key={line.id} className="border border-gray-100 rounded-xl p-3 flex justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-900">{line.label}</p>
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
</div>
<span className="text-sm font-bold">{formatPrice(line.amount)} T</span>
</div>
))}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Transactions</h3>
{(selectedInvoice.transactions || []).length === 0 ? (
<p className="text-sm text-gray-400">No linked transactions yet</p>
) : (
<div className="space-y-2">
{(selectedInvoice.transactions || []).map((tx) => (
<div key={tx.id} className="border border-gray-100 rounded-xl p-3">
<div className="flex justify-between gap-3">
<span className="text-sm text-gray-700">{tx.description || tx.type}</span>
<span className="text-sm font-bold">{formatPrice(tx.amount)} T</span>
</div>
<p className="text-xs text-gray-400 mt-1">{tx.type} · {formatDate(tx.createdAt)}</p>
</div>
))}
</div>
)}
</div>
<div className="border-t border-gray-100 pt-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-900">Manual status change</h3>
<textarea
value={statusReason}
onChange={(e) => setStatusReason(e.target.value)}
className="input-field w-full min-h-[80px]"
placeholder="Reason is required for audit visibility"
/>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleStatusUpdate('failed')}
disabled={updateStatusMutation.isPending}
className="btn-secondary text-red-600 flex items-center justify-center gap-2 disabled:opacity-50"
>
<XCircle className="w-4 h-4" /> Failed
</button>
<button
type="button"
onClick={() => handleStatusUpdate('void')}
disabled={updateStatusMutation.isPending}
className="btn-secondary disabled:opacity-50"
>
Void
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+48 -21
View File
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, Invoice } from '@/types';
import { useState, useRef, useCallback, useEffect } from 'react';
import NextLink from 'next/link';
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react';
@@ -259,6 +259,20 @@ export default function AppDetailPage() {
},
});
const createRenewalInvoiceMutation = useMutation({
mutationFn: (cycle: string) =>
api.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data),
onSuccess: (invoice) => {
toast.success('Invoice created. Choose how you want to pay.');
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowRenewalModal(false);
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
},
onError: (err: any) => {
toast.error(err.response?.data?.message || 'Failed to create renewal invoice');
},
});
// Check if app needs renewal (expired or suspended)
const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000);
@@ -631,6 +645,19 @@ export default function AppDetailPage() {
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to calculate upgrade cost'),
});
const createUpgradeInvoiceMutation = useMutation({
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
api.post<Invoice>(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data),
onSuccess: (invoice) => {
toast.success('Invoice created. Choose how you want to pay.');
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create upgrade invoice'),
});
// Handler: app uses billing upgrade path when subscribed; other workloads patch directly.
const handleScaleResources = () => {
if (scaleWorkload !== 'app') {
@@ -1110,11 +1137,10 @@ export default function AppDetailPage() {
: renewalCostData.costs.yearly;
if (walletData.balance < cost) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
<p className="text-sm text-red-700">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<p className="text-sm text-amber-700">
<AlertTriangle className="w-4 h-4 inline mr-1" />
Insufficient balance. Please charge your wallet first.
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
Wallet is short by {(cost - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
</p>
</div>
);
@@ -1132,18 +1158,14 @@ export default function AppDetailPage() {
Cancel
</button>
<button
onClick={() => renewMutation.mutate(selectedCycle)}
disabled={renewMutation.isPending || !renewalCostData?.costs || (walletData && renewalCostData?.costs && (
(selectedCycle === 'hourly' && walletData.balance < renewalCostData.costs.hourly) ||
(selectedCycle === 'monthly' && walletData.balance < renewalCostData.costs.monthly) ||
(selectedCycle === 'yearly' && walletData.balance < renewalCostData.costs.yearly)
))}
onClick={() => createRenewalInvoiceMutation.mutate(selectedCycle)}
disabled={createRenewalInvoiceMutation.isPending || !renewalCostData?.costs}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{renewMutation.isPending ? (
{createRenewalInvoiceMutation.isPending ? (
<><Clock className="w-4 h-4 animate-spin" /> Processing...</>
) : (
<><CreditCard className="w-4 h-4" /> Pay & Renew</>
<><CreditCard className="w-4 h-4" /> Create Invoice & Pay</>
)}
</button>
</div>
@@ -1203,11 +1225,10 @@ export default function AppDetailPage() {
{/* Insufficient Balance Warning */}
{walletData && upgradeCostData.proratedAmount > walletData.balance && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
<p className="text-sm text-red-700">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<p className="text-sm text-amber-700">
<AlertTriangle className="w-4 h-4 inline mr-1" />
Insufficient balance. Please charge your wallet first.
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
Wallet is short by {(upgradeCostData.proratedAmount - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
</p>
</div>
)}
@@ -1224,14 +1245,20 @@ export default function AppDetailPage() {
Cancel
</button>
<button
onClick={() => scaleMutation.mutate(resourceForm)}
disabled={scaleMutation.isPending || (walletData && upgradeCostData.proratedAmount > walletData.balance)}
onClick={() => {
if (upgradeCostData.proratedAmount > 0) {
createUpgradeInvoiceMutation.mutate(resourceForm);
} else {
scaleMutation.mutate(resourceForm);
}
}}
disabled={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{scaleMutation.isPending ? (
{scaleMutation.isPending || createUpgradeInvoiceMutation.isPending ? (
<><Clock className="w-4 h-4 animate-spin" /> Applying...</>
) : upgradeCostData.proratedAmount > 0 ? (
<><CreditCard className="w-4 h-4" /> Pay & Upgrade</>
<><CreditCard className="w-4 h-4" /> Create Invoice & Pay</>
) : (
<><CheckCircle className="w-4 h-4" /> Apply Changes</>
)}
@@ -0,0 +1,281 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, ExternalLink } from 'lucide-react';
import { toast } from 'react-toastify';
import api from '@/lib/api';
import type { Invoice, InvoiceStatus } from '@/types';
const statusLabels: Record<InvoiceStatus, string> = {
draft: 'Draft',
issued: 'Unpaid',
partially_paid: 'Partially paid',
paid: 'Paid',
void: 'Void',
failed: 'Failed',
};
const statusClasses: Record<InvoiceStatus, string> = {
draft: 'badge-gray',
issued: 'badge-yellow',
partially_paid: 'badge-blue',
paid: 'badge-green',
void: 'badge-gray',
failed: 'badge-red',
};
export default function InvoicesPage() {
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const [statusFilter, setStatusFilter] = useState<'all' | 'unpaid' | InvoiceStatus>('all');
const [selectedId, setSelectedId] = useState<string | null>(searchParams.get('invoice'));
useEffect(() => {
const invoiceId = searchParams.get('invoice');
if (invoiceId) setSelectedId(invoiceId);
}, [searchParams]);
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet-balance'],
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices', statusFilter],
queryFn: () => {
const params: Record<string, string> = {};
if (statusFilter !== 'all' && statusFilter !== 'unpaid') params.status = statusFilter;
return api.get('/billing/invoices', { params }).then((r) => r.data);
},
});
const visibleInvoices = invoices.filter((invoice) => {
if (statusFilter === 'unpaid') return invoice.status === 'issued' || invoice.status === 'partially_paid';
return true;
});
const { data: selectedInvoice } = useQuery<Invoice>({
queryKey: ['invoice', selectedId],
queryFn: () => api.get(`/billing/invoices/${selectedId}`).then((r) => r.data),
enabled: !!selectedId,
});
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoice', selectedId] });
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
};
const walletPayMutation = useMutation({
mutationFn: (invoiceId: string) => api.post(`/billing/invoices/${invoiceId}/pay/wallet`).then((r) => r.data),
onSuccess: (data) => {
toast.success(data.effect ? 'Invoice paid and service updated' : 'Invoice paid');
refresh();
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Wallet payment failed'),
});
const gatewayPayMutation = useMutation({
mutationFn: async ({ invoiceId, mixed }: { invoiceId: string; mixed: boolean }) => {
const callbackUrl = `${window.location.origin}/dashboard/invoices`;
const { data } = await api.post(
`/billing/invoices/${invoiceId}/pay/${mixed ? 'mixed' : 'gateway'}`,
{ callbackUrl },
);
if (data.gatewayAmount === 0) return data;
const amount = data.gatewayAmount ?? data.amount;
const trackingCode = data.trackingCode;
if (!trackingCode || !amount) return data;
const verified = await api.post(`/billing/invoices/${invoiceId}/gateway/verify`, {
trackingCode,
amount,
});
return verified.data;
},
onSuccess: (data) => {
toast.success(data.effect ? 'Payment complete and service updated' : 'Payment complete');
refresh();
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Gateway payment failed'),
});
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
const formatDate = (value?: string) => value ? new Date(value).toLocaleString('en-US') : '-';
const dueAmount = Number(selectedInvoice?.dueAmount || 0);
const walletBalance = Number(walletData?.balance || 0);
const isPayable = selectedInvoice?.status === 'issued' || selectedInvoice?.status === 'partially_paid';
return (
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="page-title flex items-center gap-2">
<FileText className="w-6 h-6" /> Invoices
</h1>
<p className="page-subtitle">Review what each payment was for and pay open invoices.</p>
</div>
<div className="card py-3 px-4 flex items-center gap-3">
<Wallet className="w-5 h-5 text-primary-600" />
<div>
<p className="text-xs text-gray-500">Wallet balance</p>
<p className="font-bold text-gray-900">{formatPrice(walletBalance)} Toman</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{(['all', 'unpaid', 'paid', 'failed', 'void'] as const).map((status) => (
<button
key={status}
type="button"
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border ${
statusFilter === status
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
}`}
>
{status === 'all' ? 'All' : status === 'unpaid' ? 'Unpaid' : statusLabels[status]}
</button>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<div className="lg:col-span-3 card p-0 overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-gray-400">Loading invoices...</div>
) : visibleInvoices.length === 0 ? (
<div className="p-8 text-center text-gray-400">No invoices found</div>
) : (
<div className="divide-y divide-gray-100">
{visibleInvoices.map((invoice) => (
<button
key={invoice.id}
type="button"
onClick={() => setSelectedId(invoice.id)}
className={`w-full text-left p-4 hover:bg-gray-50 transition-colors ${
selectedId === invoice.id ? 'bg-primary-50' : 'bg-white'
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold text-gray-900">{invoice.invoiceNumber}</p>
<p className="text-sm text-gray-500">
{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}
</p>
</div>
<span className={statusClasses[invoice.status]}>{statusLabels[invoice.status]}</span>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="text-gray-500">Total {formatPrice(invoice.total)} Toman</span>
<span className="font-semibold text-gray-900">Due {formatPrice(invoice.dueAmount)} Toman</span>
</div>
</button>
))}
</div>
)}
</div>
<div className="lg:col-span-2 card">
{!selectedInvoice ? (
<div className="text-center py-12 text-gray-400">
<FileText className="w-10 h-10 mx-auto mb-3" />
Select an invoice to view details
</div>
) : (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
</div>
<span className={statusClasses[selectedInvoice.status]}>{statusLabels[selectedInvoice.status]}</span>
</div>
<div className="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
<div className="flex justify-between"><span className="text-gray-500">Total</span><span className="font-semibold">{formatPrice(selectedInvoice.total)} Toman</span></div>
<div className="flex justify-between"><span className="text-gray-500">Paid</span><span className="font-semibold text-green-600">{formatPrice(selectedInvoice.paidAmount)} Toman</span></div>
<div className="flex justify-between"><span className="text-gray-500">Due</span><span className="font-semibold text-primary-700">{formatPrice(selectedInvoice.dueAmount)} Toman</span></div>
<div className="flex justify-between"><span className="text-gray-500">Method</span><span>{selectedInvoice.paymentMethod || '-'}</span></div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Line items</h3>
<div className="space-y-2">
{(selectedInvoice.lines || []).map((line) => (
<div key={line.id} className="border border-gray-100 rounded-xl p-3">
<div className="flex justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-900">{line.label}</p>
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
</div>
<span className="text-sm font-bold text-gray-900">{formatPrice(line.amount)} T</span>
</div>
</div>
))}
</div>
</div>
{isPayable && (
<div className="space-y-3 border-t border-gray-100 pt-4">
<h3 className="text-sm font-semibold text-gray-900">Payment options</h3>
<button
type="button"
onClick={() => walletPayMutation.mutate(selectedInvoice.id)}
disabled={walletPayMutation.isPending || walletBalance < dueAmount}
className="w-full btn-primary flex items-center justify-center gap-2 disabled:opacity-50"
>
<Wallet className="w-4 h-4" />
Pay from wallet
</button>
{walletBalance < dueAmount && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-2">
Wallet is short by {formatPrice(dueAmount - walletBalance)} Toman. Use mixed payment to cover the delta by gateway.
</p>
)}
<button
type="button"
onClick={() => gatewayPayMutation.mutate({ invoiceId: selectedInvoice.id, mixed: false })}
disabled={gatewayPayMutation.isPending}
className="w-full btn-secondary flex items-center justify-center gap-2 disabled:opacity-50"
>
<CreditCard className="w-4 h-4" />
Pay directly by gateway
</button>
<button
type="button"
onClick={() => gatewayPayMutation.mutate({ invoiceId: selectedInvoice.id, mixed: true })}
disabled={gatewayPayMutation.isPending}
className="w-full btn-secondary flex items-center justify-center gap-2 disabled:opacity-50"
>
<ExternalLink className="w-4 h-4" />
Use wallet + gateway delta
</button>
</div>
)}
{selectedInvoice.status === 'paid' && (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-100 rounded-xl p-3">
<CheckCircle className="w-4 h-4" /> Paid on {formatDate(selectedInvoice.paidAt)}
</div>
)}
{selectedInvoice.status === 'failed' && (
<div className="flex items-center gap-2 text-sm text-red-700 bg-red-50 border border-red-100 rounded-xl p-3">
<XCircle className="w-4 h-4" /> {selectedInvoice.statusReason || 'Payment failed'}
</div>
)}
{selectedInvoice.status === 'partially_paid' && (
<div className="flex items-center gap-2 text-sm text-blue-700 bg-blue-50 border border-blue-100 rounded-xl p-3">
<Clock className="w-4 h-4" /> Waiting for the remaining payment.
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
+3
View File
@@ -25,6 +25,7 @@ import {
Wallet,
CreditCard,
ScrollText,
FileText,
} from 'lucide-react';
type NavItem = { href: string; label: string; icon: ReactNode };
@@ -35,6 +36,7 @@ const userNavItems: NavItem[] = [
{ href: '/dashboard/logs', label: 'Logs', icon: <ScrollText className="w-4 h-4" /> },
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
{ href: '/dashboard/wallet', label: 'Wallet', icon: <Wallet className="w-4 h-4" /> },
{ href: '/dashboard/invoices', label: 'Invoices', icon: <FileText className="w-4 h-4" /> },
{ href: '/dashboard/tickets', label: 'Tickets', icon: <Ticket className="w-4 h-4" /> },
];
@@ -42,6 +44,7 @@ const adminNavItems: NavItem[] = [
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
{ href: '/dashboard/admin/billing', label: 'Billing Plans', icon: <CreditCard className="w-4 h-4" /> },
{ href: '/dashboard/admin/invoices', label: 'Invoices', icon: <FileText className="w-4 h-4" /> },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: <ClipboardList className="w-4 h-4" /> },
+15 -2
View File
@@ -5,24 +5,28 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { WalletTransaction, TransactionType } from '@/types';
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock, CreditCard } from 'lucide-react';
import Link from 'next/link';
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock, CreditCard, FileText } from 'lucide-react';
const txTypeLabels: Record<TransactionType, string> = {
charge: 'Deposit',
deduction: 'Payment',
refund: 'Refund',
gateway_payment: 'Gateway payment',
};
const txTypeColors: Record<TransactionType, string> = {
charge: 'text-green-600',
deduction: 'text-red-600',
refund: 'text-blue-600',
gateway_payment: 'text-purple-600',
};
const txTypeIcons: Record<TransactionType, React.ReactNode> = {
charge: <ArrowDownCircle className="w-4 h-4 text-green-500" />,
deduction: <ArrowUpCircle className="w-4 h-4 text-red-500" />,
refund: <RotateCcw className="w-4 h-4 text-blue-500" />,
gateway_payment: <CreditCard className="w-4 h-4 text-purple-500" />,
};
export default function WalletPage() {
@@ -194,11 +198,20 @@ export default function WalletPage() {
{tx.description && <span className="text-gray-500 font-normal"> {tx.description}</span>}
</p>
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
{tx.invoiceId && (
<Link
href={`/dashboard/invoices?invoice=${tx.invoiceId}`}
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700"
>
<FileText className="w-3 h-3" />
Invoice {tx.invoice?.invoiceNumber || ''}
</Link>
)}
</div>
</div>
<div className="text-right">
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
{tx.type === 'deduction' ? '' : '+'}{formatPrice(tx.amount)} T
{tx.type === 'deduction' ? '' : tx.type === 'gateway_payment' ? '' : '+'}{formatPrice(tx.amount)} T
</p>
<p className="text-xs text-gray-400">Balance: {formatPrice(tx.balanceAfter)} T</p>
</div>
+46 -1
View File
@@ -387,7 +387,10 @@ export interface TicketStats {
export type BillingCycle = 'hourly' | 'monthly' | 'yearly';
export type PricingResourceType = 'base_fee' | 'cpu_per_core' | 'memory_per_gb' | 'storage_per_gb' | 'database_addon' | 'redis_addon' | 'rabbitmq_addon' | 'elasticsearch_addon' | 'custom_domain_addon';
export type TransactionType = 'charge' | 'deduction' | 'refund';
export type TransactionType = 'charge' | 'deduction' | 'refund' | 'gateway_payment';
export type InvoiceStatus = 'draft' | 'issued' | 'partially_paid' | 'paid' | 'void' | 'failed';
export type PaymentMethod = 'wallet' | 'gateway' | 'mixed';
export type InvoiceReason = 'deploy' | 'renewal' | 'upgrade' | 'wallet_topup' | 'manual';
export interface PricingRateRow {
resourceType: PricingResourceType;
@@ -456,10 +459,52 @@ export interface WalletTransaction {
balanceAfter: number;
description?: string;
applicationId?: string;
invoiceId?: string;
invoice?: Invoice;
gatewayTrackingCode?: string;
walletId: string;
createdAt: string;
}
export interface InvoiceLine {
id: string;
invoiceId: string;
label: string;
description?: string;
quantity: number;
unitAmount: number;
amount: number;
metadata?: Record<string, any>;
createdAt: string;
}
export interface Invoice {
id: string;
invoiceNumber: string;
userId: string;
user?: User;
applicationId?: string;
application?: Application;
reason: InvoiceReason;
status: InvoiceStatus;
paymentMethod?: PaymentMethod;
subtotal: number;
total: number;
paidAmount: number;
dueAmount: number;
dueDate?: string;
paidAt?: string;
gatewayTrackingCode?: string;
gatewayReference?: string;
adminNote?: string;
statusReason?: string;
metadata?: Record<string, any>;
lines?: InvoiceLine[];
transactions?: WalletTransaction[];
createdAt: string;
updatedAt: string;
}
export interface OptionalServiceCyclePrices {
hourly: number;
monthly: number;