Add invoice payment management.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"invoiceNumber" VARCHAR NOT NULL UNIQUE,
|
||||||
|
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
"applicationId" UUID REFERENCES applications(id) ON DELETE SET NULL,
|
||||||
|
reason VARCHAR NOT NULL DEFAULT 'manual',
|
||||||
|
status VARCHAR NOT NULL DEFAULT 'issued',
|
||||||
|
"paymentMethod" VARCHAR,
|
||||||
|
subtotal DECIMAL(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
total DECIMAL(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
"paidAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
"dueAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
"dueDate" TIMESTAMPTZ,
|
||||||
|
"paidAt" TIMESTAMPTZ,
|
||||||
|
"gatewayTrackingCode" VARCHAR,
|
||||||
|
"gatewayReference" VARCHAR,
|
||||||
|
"adminNote" VARCHAR,
|
||||||
|
"statusReason" VARCHAR,
|
||||||
|
metadata JSONB,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"invoiceId" UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
label VARCHAR NOT NULL,
|
||||||
|
description VARCHAR,
|
||||||
|
quantity INT NOT NULL DEFAULT 1,
|
||||||
|
"unitAmount" DECIMAL(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
amount DECIMAL(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
metadata JSONB,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE wallet_transactions
|
||||||
|
ADD COLUMN IF NOT EXISTS "invoiceId" UUID REFERENCES invoices(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE wallet_transactions
|
||||||
|
ADD COLUMN IF NOT EXISTS "gatewayTrackingCode" VARCHAR;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_user_status_created
|
||||||
|
ON invoices ("userId", status, "createdAt" DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_application
|
||||||
|
ON invoices ("applicationId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice
|
||||||
|
ON invoice_lines ("invoiceId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallet_transactions_invoice
|
||||||
|
ON wallet_transactions ("invoiceId");
|
||||||
@@ -28,11 +28,14 @@ import {
|
|||||||
RenewApplicationDto,
|
RenewApplicationDto,
|
||||||
UpgradeResourcesDto,
|
UpgradeResourcesDto,
|
||||||
CalculateUpgradeCostDto,
|
CalculateUpgradeCostDto,
|
||||||
|
InitiateInvoicePaymentDto,
|
||||||
|
VerifyInvoiceGatewayDto,
|
||||||
|
UpdateInvoiceStatusDto,
|
||||||
} from './dto/billing.dto';
|
} from './dto/billing.dto';
|
||||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
import { Roles } from '../common/decorators/roles.decorator';
|
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')
|
@ApiTags('Billing')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -142,6 +145,76 @@ export class BillingController {
|
|||||||
return credits.map((c) => this.billingService.formatCreditForApi(c));
|
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')
|
@Post('wallet/pay/:applicationId')
|
||||||
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
|
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
|
||||||
async payForApplication(
|
async payForApplication(
|
||||||
@@ -162,14 +235,37 @@ export class BillingController {
|
|||||||
cycle,
|
cycle,
|
||||||
);
|
);
|
||||||
|
|
||||||
let tx = null;
|
let invoice = null;
|
||||||
if (payment.amountDue > 0) {
|
if (payment.amountDue > 0) {
|
||||||
tx = await this.billingService.deductWallet(
|
invoice = await this.billingService.createInvoice({
|
||||||
req.user.id,
|
userId: req.user.id,
|
||||||
payment.amountDue,
|
applicationId: app.id,
|
||||||
`Payment for app ${applicationId} (${cycle})`,
|
reason: InvoiceReason.DEPLOY,
|
||||||
applicationId,
|
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(
|
const activated = await this.lifecycleService.activateApp(
|
||||||
@@ -180,6 +276,7 @@ export class BillingController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
transaction: tx,
|
transaction: tx,
|
||||||
|
invoice,
|
||||||
creditApplied: payment.creditId || null,
|
creditApplied: payment.creditId || null,
|
||||||
waivedAmount: payment.waivedAmount,
|
waivedAmount: payment.waivedAmount,
|
||||||
paidAmount: payment.amountDue,
|
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 ─────────────────────────────────────────────────
|
// ─── Wallet Admin ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get('admin/wallets')
|
@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')
|
@Post('applications/:applicationId/renew')
|
||||||
@ApiOperation({ summary: 'Renew an application (user pays from wallet)' })
|
@ApiOperation({ summary: 'Renew an application (user pays from wallet)' })
|
||||||
async renewApplication(
|
async renewApplication(
|
||||||
@@ -298,24 +469,34 @@ export class BillingController {
|
|||||||
throw new BadRequestException('Invalid cost calculation — no pricing rules found');
|
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
|
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
|
||||||
? app.userId
|
? app.userId
|
||||||
: req.user.id;
|
: req.user.id;
|
||||||
|
|
||||||
const tx = await this.billingService.deductWallet(
|
const invoice = await this.billingService.createInvoice({
|
||||||
walletUserId,
|
userId: walletUserId,
|
||||||
|
applicationId: app.id,
|
||||||
|
reason: InvoiceReason.RENEWAL,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
label: `Renewal for ${app.name}`,
|
||||||
|
description: `Billing cycle: ${dto.cycle}`,
|
||||||
amount,
|
amount,
|
||||||
`Renewal for ${app.name} (${dto.cycle})`,
|
metadata: { cycle: dto.cycle },
|
||||||
app.id,
|
},
|
||||||
);
|
],
|
||||||
|
metadata: { action: 'renew', cycle: dto.cycle },
|
||||||
|
});
|
||||||
|
|
||||||
|
const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user);
|
||||||
|
|
||||||
// Activate the application
|
// Activate the application
|
||||||
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
|
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
transaction: tx,
|
transaction: paid.transaction,
|
||||||
|
invoice: paid.invoice,
|
||||||
application: {
|
application: {
|
||||||
id: renewedApp.id,
|
id: renewedApp.id,
|
||||||
name: renewedApp.name,
|
name: renewedApp.name,
|
||||||
@@ -365,18 +546,29 @@ export class BillingController {
|
|||||||
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||||
: costs.yearly;
|
: costs.yearly;
|
||||||
|
|
||||||
const tx = await this.billingService.deductWallet(
|
const invoice = await this.billingService.createInvoice({
|
||||||
app.userId,
|
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,
|
amount,
|
||||||
`Renewal by ${req.user.role} for ${app.name} (${cycle})`,
|
metadata: { cycle, initiatedBy: req.user.role },
|
||||||
app.id,
|
},
|
||||||
);
|
],
|
||||||
|
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);
|
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
transaction: tx,
|
transaction: paid.transaction,
|
||||||
|
invoice: paid.invoice,
|
||||||
application: {
|
application: {
|
||||||
id: renewedApp.id,
|
id: renewedApp.id,
|
||||||
name: renewedApp.name,
|
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')
|
@Post('applications/:applicationId/upgrade')
|
||||||
@ApiOperation({ summary: 'Upgrade application resources (with payment)' })
|
@ApiOperation({ summary: 'Upgrade application resources (with payment)' })
|
||||||
async upgradeResources(
|
async upgradeResources(
|
||||||
@@ -442,6 +677,7 @@ export class BillingController {
|
|||||||
|
|
||||||
// Calculate upgrade cost
|
// Calculate upgrade cost
|
||||||
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
|
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
|
||||||
|
let paidInvoice = null;
|
||||||
|
|
||||||
// If upgrading (positive difference), require payment
|
// If upgrading (positive difference), require payment
|
||||||
if (costResult.proratedAmount > 0) {
|
if (costResult.proratedAmount > 0) {
|
||||||
@@ -449,12 +685,30 @@ export class BillingController {
|
|||||||
? app.userId
|
? app.userId
|
||||||
: req.user.id;
|
: req.user.id;
|
||||||
|
|
||||||
await this.billingService.deductWallet(
|
const invoice = await this.billingService.createInvoice({
|
||||||
walletUserId,
|
userId: walletUserId,
|
||||||
costResult.proratedAmount,
|
applicationId: app.id,
|
||||||
`Resource upgrade for ${app.name}: prorated ${costResult.remainingHours}h`,
|
reason: InvoiceReason.UPGRADE,
|
||||||
app.id,
|
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
|
// Apply the resource changes
|
||||||
@@ -490,6 +744,7 @@ export class BillingController {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
paidAmount: costResult.proratedAmount,
|
paidAmount: costResult.proratedAmount,
|
||||||
|
invoice: paidInvoice,
|
||||||
application: {
|
application: {
|
||||||
id: updatedApp.id,
|
id: updatedApp.id,
|
||||||
name: updatedApp.name,
|
name: updatedApp.name,
|
||||||
@@ -509,6 +764,80 @@ export class BillingController {
|
|||||||
|
|
||||||
// ─── Helper Methods ───────────────────────────────────────────────
|
// ─── 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) {
|
private async getAppWithAccess(user: any, applicationId: string) {
|
||||||
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
|||||||
import { Wallet } from './entities/wallet.entity';
|
import { Wallet } from './entities/wallet.entity';
|
||||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||||
import { ResourceCredit } from './entities/resource-credit.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 { LifecycleModule } from '../lifecycle/lifecycle.module';
|
||||||
import { ApplicationsModule } from '../applications/applications.module';
|
import { ApplicationsModule } from '../applications/applications.module';
|
||||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
@@ -24,6 +26,8 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
|||||||
Wallet,
|
Wallet,
|
||||||
WalletTransaction,
|
WalletTransaction,
|
||||||
ResourceCredit,
|
ResourceCredit,
|
||||||
|
Invoice,
|
||||||
|
InvoiceLine,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => LifecycleModule),
|
forwardRef(() => LifecycleModule),
|
||||||
forwardRef(() => ApplicationsModule),
|
forwardRef(() => ApplicationsModule),
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, IsNull, MoreThan } from 'typeorm';
|
import { Repository, IsNull, MoreThan, FindOptionsWhere } from 'typeorm';
|
||||||
import { Wallet } from './entities/wallet.entity';
|
import { Wallet } from './entities/wallet.entity';
|
||||||
import { WalletTransaction } from './entities/wallet-transaction.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 { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
|
||||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||||
import { Application } from '../applications/entities/application.entity';
|
import { Application } from '../applications/entities/application.entity';
|
||||||
@@ -19,6 +29,8 @@ export class BillingService {
|
|||||||
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||||
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
|
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
|
||||||
|
@InjectRepository(Invoice) private invoiceRepo: Repository<Invoice>,
|
||||||
|
@InjectRepository(InvoiceLine) private invoiceLineRepo: Repository<InvoiceLine>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
||||||
@@ -85,7 +97,12 @@ export class BillingService {
|
|||||||
return { balance: Number(wallet.balance) };
|
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');
|
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||||
|
|
||||||
const wallet = await this.getOrCreateWallet(userId);
|
const wallet = await this.getOrCreateWallet(userId);
|
||||||
@@ -98,6 +115,7 @@ export class BillingService {
|
|||||||
amount,
|
amount,
|
||||||
balanceAfter: wallet.balance,
|
balanceAfter: wallet.balance,
|
||||||
description: description || 'Wallet charge',
|
description: description || 'Wallet charge',
|
||||||
|
invoiceId,
|
||||||
});
|
});
|
||||||
const saved = await this.txRepo.save(tx);
|
const saved = await this.txRepo.save(tx);
|
||||||
|
|
||||||
@@ -110,6 +128,7 @@ export class BillingService {
|
|||||||
amount: number,
|
amount: number,
|
||||||
description?: string,
|
description?: string,
|
||||||
applicationId?: string,
|
applicationId?: string,
|
||||||
|
invoiceId?: string,
|
||||||
): Promise<WalletTransaction> {
|
): Promise<WalletTransaction> {
|
||||||
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||||
|
|
||||||
@@ -128,6 +147,7 @@ export class BillingService {
|
|||||||
balanceAfter: wallet.balance,
|
balanceAfter: wallet.balance,
|
||||||
description: description || 'Service payment',
|
description: description || 'Service payment',
|
||||||
applicationId,
|
applicationId,
|
||||||
|
invoiceId,
|
||||||
});
|
});
|
||||||
const saved = await this.txRepo.save(tx);
|
const saved = await this.txRepo.save(tx);
|
||||||
|
|
||||||
@@ -139,11 +159,36 @@ export class BillingService {
|
|||||||
const wallet = await this.getOrCreateWallet(userId);
|
const wallet = await this.getOrCreateWallet(userId);
|
||||||
return this.txRepo.find({
|
return this.txRepo.find({
|
||||||
where: { walletId: wallet.id },
|
where: { walletId: wallet.id },
|
||||||
|
relations: ['invoice'],
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
take: limit,
|
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
|
// Admin: charge any user's wallet
|
||||||
async adminChargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
async adminChargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
||||||
return this.chargeWallet(userId, amount, description || 'Admin charge');
|
return this.chargeWallet(userId, amount, description || 'Admin charge');
|
||||||
@@ -154,6 +199,297 @@ export class BillingService {
|
|||||||
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
|
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.
|
* Calculate cost for an existing Application entity.
|
||||||
* Used by lifecycle service for auto-renew.
|
* Used by lifecycle service for auto-renew.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNested, Min } from 'class-validator';
|
import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNested, Min } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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';
|
import { OptionalServiceResourcesDto } from './optional-service-resources.dto';
|
||||||
|
|
||||||
export class CreatePricingRuleDto {
|
export class CreatePricingRuleDto {
|
||||||
@@ -210,6 +210,35 @@ export class UpgradeResourcesDto {
|
|||||||
|
|
||||||
export class CalculateUpgradeCostDto extends 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) ─────────────────────
|
// ─── Platform optional services pricing (Admin) ─────────────────────
|
||||||
|
|
||||||
export class OptionalServiceCyclePricesDto {
|
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,
|
CreateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Wallet } from './wallet.entity';
|
import { Wallet } from './wallet.entity';
|
||||||
|
import { Invoice } from './invoice.entity';
|
||||||
import { TransactionType } from '../../common/enums';
|
import { TransactionType } from '../../common/enums';
|
||||||
|
|
||||||
@Entity('wallet_transactions')
|
@Entity('wallet_transactions')
|
||||||
@@ -29,6 +30,13 @@ export class WalletTransaction {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
applicationId: string; // Linked application (for deductions)
|
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' })
|
@ManyToOne(() => Wallet, (wallet: Wallet) => wallet.transactions, { onDelete: 'CASCADE' })
|
||||||
@JoinColumn({ name: 'walletId' })
|
@JoinColumn({ name: 'walletId' })
|
||||||
wallet: Wallet;
|
wallet: Wallet;
|
||||||
@@ -36,6 +44,9 @@ export class WalletTransaction {
|
|||||||
@Column()
|
@Column()
|
||||||
walletId: string;
|
walletId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
gatewayTrackingCode: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,30 @@ export enum TransactionType {
|
|||||||
CHARGE = 'charge', // Top-up / deposit
|
CHARGE = 'charge', // Top-up / deposit
|
||||||
DEDUCTION = 'deduction', // Payment for service
|
DEDUCTION = 'deduction', // Payment for service
|
||||||
REFUND = 'refund', // Refund
|
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 ────────────────────────────────────
|
// ── 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
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 { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import NextLink from 'next/link';
|
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';
|
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)
|
// Check if app needs renewal (expired or suspended)
|
||||||
const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
|
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);
|
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'),
|
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.
|
// Handler: app uses billing upgrade path when subscribed; other workloads patch directly.
|
||||||
const handleScaleResources = () => {
|
const handleScaleResources = () => {
|
||||||
if (scaleWorkload !== 'app') {
|
if (scaleWorkload !== 'app') {
|
||||||
@@ -1110,11 +1137,10 @@ export default function AppDetailPage() {
|
|||||||
: renewalCostData.costs.yearly;
|
: renewalCostData.costs.yearly;
|
||||||
if (walletData.balance < cost) {
|
if (walletData.balance < cost) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||||
<p className="text-sm text-red-700">
|
<p className="text-sm text-amber-700">
|
||||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||||
Insufficient balance. Please charge your wallet first.
|
Wallet is short by {(cost - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
|
||||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1132,18 +1158,14 @@ export default function AppDetailPage() {
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => renewMutation.mutate(selectedCycle)}
|
onClick={() => createRenewalInvoiceMutation.mutate(selectedCycle)}
|
||||||
disabled={renewMutation.isPending || !renewalCostData?.costs || (walletData && renewalCostData?.costs && (
|
disabled={createRenewalInvoiceMutation.isPending || !renewalCostData?.costs}
|
||||||
(selectedCycle === 'hourly' && walletData.balance < renewalCostData.costs.hourly) ||
|
|
||||||
(selectedCycle === 'monthly' && walletData.balance < renewalCostData.costs.monthly) ||
|
|
||||||
(selectedCycle === 'yearly' && walletData.balance < renewalCostData.costs.yearly)
|
|
||||||
))}
|
|
||||||
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"
|
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...</>
|
<><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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1203,11 +1225,10 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
{/* Insufficient Balance Warning */}
|
{/* Insufficient Balance Warning */}
|
||||||
{walletData && upgradeCostData.proratedAmount > walletData.balance && (
|
{walletData && upgradeCostData.proratedAmount > walletData.balance && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||||
<p className="text-sm text-red-700">
|
<p className="text-sm text-amber-700">
|
||||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||||
Insufficient balance. Please charge your wallet first.
|
Wallet is short by {(upgradeCostData.proratedAmount - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
|
||||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1224,14 +1245,20 @@ export default function AppDetailPage() {
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => scaleMutation.mutate(resourceForm)}
|
onClick={() => {
|
||||||
disabled={scaleMutation.isPending || (walletData && upgradeCostData.proratedAmount > walletData.balance)}
|
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"
|
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...</>
|
<><Clock className="w-4 h-4 animate-spin" /> Applying...</>
|
||||||
) : upgradeCostData.proratedAmount > 0 ? (
|
) : 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</>
|
<><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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type NavItem = { href: string; label: string; icon: ReactNode };
|
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/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/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/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" /> },
|
{ 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/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/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/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/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/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" /> },
|
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: <ClipboardList className="w-4 h-4" /> },
|
||||||
|
|||||||
@@ -5,24 +5,28 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { WalletTransaction, TransactionType } from '@/types';
|
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> = {
|
const txTypeLabels: Record<TransactionType, string> = {
|
||||||
charge: 'Deposit',
|
charge: 'Deposit',
|
||||||
deduction: 'Payment',
|
deduction: 'Payment',
|
||||||
refund: 'Refund',
|
refund: 'Refund',
|
||||||
|
gateway_payment: 'Gateway payment',
|
||||||
};
|
};
|
||||||
|
|
||||||
const txTypeColors: Record<TransactionType, string> = {
|
const txTypeColors: Record<TransactionType, string> = {
|
||||||
charge: 'text-green-600',
|
charge: 'text-green-600',
|
||||||
deduction: 'text-red-600',
|
deduction: 'text-red-600',
|
||||||
refund: 'text-blue-600',
|
refund: 'text-blue-600',
|
||||||
|
gateway_payment: 'text-purple-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
const txTypeIcons: Record<TransactionType, React.ReactNode> = {
|
const txTypeIcons: Record<TransactionType, React.ReactNode> = {
|
||||||
charge: <ArrowDownCircle className="w-4 h-4 text-green-500" />,
|
charge: <ArrowDownCircle className="w-4 h-4 text-green-500" />,
|
||||||
deduction: <ArrowUpCircle className="w-4 h-4 text-red-500" />,
|
deduction: <ArrowUpCircle className="w-4 h-4 text-red-500" />,
|
||||||
refund: <RotateCcw className="w-4 h-4 text-blue-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() {
|
export default function WalletPage() {
|
||||||
@@ -194,11 +198,20 @@ export default function WalletPage() {
|
|||||||
{tx.description && <span className="text-gray-500 font-normal"> — {tx.description}</span>}
|
{tx.description && <span className="text-gray-500 font-normal"> — {tx.description}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</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>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
|
<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>
|
||||||
<p className="text-xs text-gray-400">Balance: {formatPrice(tx.balanceAfter)} T</p>
|
<p className="text-xs text-gray-400">Balance: {formatPrice(tx.balanceAfter)} T</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -387,7 +387,10 @@ export interface TicketStats {
|
|||||||
|
|
||||||
export type BillingCycle = 'hourly' | 'monthly' | 'yearly';
|
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 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 {
|
export interface PricingRateRow {
|
||||||
resourceType: PricingResourceType;
|
resourceType: PricingResourceType;
|
||||||
@@ -456,10 +459,52 @@ export interface WalletTransaction {
|
|||||||
balanceAfter: number;
|
balanceAfter: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
applicationId?: string;
|
applicationId?: string;
|
||||||
|
invoiceId?: string;
|
||||||
|
invoice?: Invoice;
|
||||||
|
gatewayTrackingCode?: string;
|
||||||
walletId: string;
|
walletId: string;
|
||||||
createdAt: 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 {
|
export interface OptionalServiceCyclePrices {
|
||||||
hourly: number;
|
hourly: number;
|
||||||
monthly: number;
|
monthly: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user