Add invoice payment management.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull, MoreThan } from 'typeorm';
|
||||
import { Repository, IsNull, MoreThan, FindOptionsWhere } from 'typeorm';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { TransactionType, BillingCycle, DatabaseType } from '../common/enums';
|
||||
import { Invoice } from './entities/invoice.entity';
|
||||
import { InvoiceLine } from './entities/invoice-line.entity';
|
||||
import {
|
||||
TransactionType,
|
||||
BillingCycle,
|
||||
DatabaseType,
|
||||
InvoiceReason,
|
||||
InvoiceStatus,
|
||||
PaymentMethod,
|
||||
UserRole,
|
||||
} from '../common/enums';
|
||||
import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
|
||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
@@ -19,6 +29,8 @@ export class BillingService {
|
||||
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
|
||||
@InjectRepository(Invoice) private invoiceRepo: Repository<Invoice>,
|
||||
@InjectRepository(InvoiceLine) private invoiceLineRepo: Repository<InvoiceLine>,
|
||||
) {}
|
||||
|
||||
// ─── Pricing catalog (Admin) ──────────────────────────────────────
|
||||
@@ -85,7 +97,12 @@ export class BillingService {
|
||||
return { balance: Number(wallet.balance) };
|
||||
}
|
||||
|
||||
async chargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
||||
async chargeWallet(
|
||||
userId: string,
|
||||
amount: number,
|
||||
description?: string,
|
||||
invoiceId?: string,
|
||||
): Promise<WalletTransaction> {
|
||||
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||
|
||||
const wallet = await this.getOrCreateWallet(userId);
|
||||
@@ -98,6 +115,7 @@ export class BillingService {
|
||||
amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: description || 'Wallet charge',
|
||||
invoiceId,
|
||||
});
|
||||
const saved = await this.txRepo.save(tx);
|
||||
|
||||
@@ -110,6 +128,7 @@ export class BillingService {
|
||||
amount: number,
|
||||
description?: string,
|
||||
applicationId?: string,
|
||||
invoiceId?: string,
|
||||
): Promise<WalletTransaction> {
|
||||
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||
|
||||
@@ -128,6 +147,7 @@ export class BillingService {
|
||||
balanceAfter: wallet.balance,
|
||||
description: description || 'Service payment',
|
||||
applicationId,
|
||||
invoiceId,
|
||||
});
|
||||
const saved = await this.txRepo.save(tx);
|
||||
|
||||
@@ -139,11 +159,36 @@ export class BillingService {
|
||||
const wallet = await this.getOrCreateWallet(userId);
|
||||
return this.txRepo.find({
|
||||
where: { walletId: wallet.id },
|
||||
relations: ['invoice'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async recordGatewayPayment(
|
||||
userId: string,
|
||||
amount: number,
|
||||
description: string,
|
||||
applicationId?: string,
|
||||
invoiceId?: string,
|
||||
gatewayTrackingCode?: string,
|
||||
): Promise<WalletTransaction> {
|
||||
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||
|
||||
const wallet = await this.getOrCreateWallet(userId);
|
||||
const tx = this.txRepo.create({
|
||||
walletId: wallet.id,
|
||||
type: TransactionType.GATEWAY_PAYMENT,
|
||||
amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description,
|
||||
applicationId,
|
||||
invoiceId,
|
||||
gatewayTrackingCode,
|
||||
});
|
||||
return this.txRepo.save(tx);
|
||||
}
|
||||
|
||||
// Admin: charge any user's wallet
|
||||
async adminChargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
||||
return this.chargeWallet(userId, amount, description || 'Admin charge');
|
||||
@@ -154,6 +199,297 @@ export class BillingService {
|
||||
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
|
||||
}
|
||||
|
||||
// ─── Invoices ─────────────────────────────────────────────────────
|
||||
|
||||
private generateInvoiceNumber(): string {
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `INV-${date}-${suffix}`;
|
||||
}
|
||||
|
||||
private normalizeAmount(amount: number): number {
|
||||
return Math.max(0, Math.round(Number(amount || 0) * 100) / 100);
|
||||
}
|
||||
|
||||
async createInvoice(input: {
|
||||
userId: string;
|
||||
applicationId?: string;
|
||||
reason: InvoiceReason;
|
||||
lines: { label: string; description?: string; quantity?: number; unitAmount?: number; amount: number; metadata?: Record<string, any> }[];
|
||||
dueDate?: Date;
|
||||
metadata?: Record<string, any>;
|
||||
}): Promise<Invoice> {
|
||||
const lines = input.lines
|
||||
.filter((line) => this.normalizeAmount(line.amount) > 0)
|
||||
.map((line) => {
|
||||
const quantity = line.quantity || 1;
|
||||
const amount = this.normalizeAmount(line.amount);
|
||||
return this.invoiceLineRepo.create({
|
||||
label: line.label,
|
||||
description: line.description,
|
||||
quantity,
|
||||
unitAmount: this.normalizeAmount(line.unitAmount ?? amount / quantity),
|
||||
amount,
|
||||
metadata: line.metadata,
|
||||
});
|
||||
});
|
||||
|
||||
const total = this.normalizeAmount(lines.reduce((sum, line) => sum + Number(line.amount), 0));
|
||||
if (total <= 0) {
|
||||
throw new BadRequestException('Invoice total must be positive');
|
||||
}
|
||||
|
||||
const invoice = this.invoiceRepo.create({
|
||||
invoiceNumber: this.generateInvoiceNumber(),
|
||||
userId: input.userId,
|
||||
applicationId: input.applicationId,
|
||||
reason: input.reason,
|
||||
status: InvoiceStatus.ISSUED,
|
||||
subtotal: total,
|
||||
total,
|
||||
paidAmount: 0,
|
||||
dueAmount: total,
|
||||
dueDate: input.dueDate,
|
||||
metadata: input.metadata,
|
||||
lines,
|
||||
});
|
||||
|
||||
return this.invoiceRepo.save(invoice);
|
||||
}
|
||||
|
||||
async listInvoices(
|
||||
user: { id: string; role?: UserRole },
|
||||
filters: {
|
||||
status?: InvoiceStatus;
|
||||
applicationId?: string;
|
||||
userId?: string;
|
||||
paymentMethod?: PaymentMethod;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
} = {},
|
||||
): Promise<Invoice[]> {
|
||||
const isAdmin = user.role === UserRole.ADMIN;
|
||||
const where: FindOptionsWhere<Invoice> = {};
|
||||
if (!isAdmin) where.userId = user.id;
|
||||
if (isAdmin && filters.userId) where.userId = filters.userId;
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.applicationId) where.applicationId = filters.applicationId;
|
||||
if (filters.paymentMethod) where.paymentMethod = filters.paymentMethod;
|
||||
|
||||
const qb = this.invoiceRepo
|
||||
.createQueryBuilder('invoice')
|
||||
.leftJoinAndSelect('invoice.user', 'user')
|
||||
.leftJoinAndSelect('invoice.application', 'application')
|
||||
.leftJoinAndSelect('invoice.lines', 'lines')
|
||||
.where(where)
|
||||
.orderBy('invoice.createdAt', 'DESC')
|
||||
.take(Math.min(filters.limit || 100, 200));
|
||||
|
||||
if (filters.search) {
|
||||
qb.andWhere(
|
||||
'(invoice."invoiceNumber" ILIKE :search OR user.email ILIKE :search OR application.name ILIKE :search)',
|
||||
{ search: `%${filters.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async getInvoiceForUser(invoiceId: string, user: { id: string; role?: UserRole }): Promise<Invoice> {
|
||||
const invoice = await this.invoiceRepo.findOne({
|
||||
where: { id: invoiceId },
|
||||
relations: ['user', 'application', 'lines', 'transactions'],
|
||||
order: { lines: { createdAt: 'ASC' }, transactions: { createdAt: 'DESC' } },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException('Invoice not found');
|
||||
if (user.role !== UserRole.ADMIN && invoice.userId !== user.id) {
|
||||
throw new ForbiddenException('You do not have access to this invoice');
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private ensureInvoicePayable(invoice: Invoice) {
|
||||
if ([InvoiceStatus.PAID, InvoiceStatus.VOID].includes(invoice.status)) {
|
||||
throw new BadRequestException(`Invoice is ${invoice.status}`);
|
||||
}
|
||||
if (Number(invoice.dueAmount) <= 0) {
|
||||
throw new BadRequestException('Invoice has no due amount');
|
||||
}
|
||||
}
|
||||
|
||||
private async applyInvoicePayment(
|
||||
invoice: Invoice,
|
||||
amount: number,
|
||||
paymentMethod: PaymentMethod,
|
||||
gatewayTrackingCode?: string,
|
||||
gatewayReference?: string,
|
||||
): Promise<Invoice> {
|
||||
const paidAmount = this.normalizeAmount(Number(invoice.paidAmount) + amount);
|
||||
const dueAmount = this.normalizeAmount(Number(invoice.total) - paidAmount);
|
||||
|
||||
invoice.paidAmount = paidAmount;
|
||||
invoice.dueAmount = dueAmount;
|
||||
invoice.paymentMethod = invoice.paymentMethod && invoice.paymentMethod !== paymentMethod
|
||||
? PaymentMethod.MIXED
|
||||
: paymentMethod;
|
||||
invoice.status = dueAmount <= 0 ? InvoiceStatus.PAID : InvoiceStatus.PARTIALLY_PAID;
|
||||
invoice.paidAt = dueAmount <= 0 ? new Date() : invoice.paidAt;
|
||||
invoice.gatewayTrackingCode = gatewayTrackingCode || invoice.gatewayTrackingCode;
|
||||
invoice.gatewayReference = gatewayReference || invoice.gatewayReference;
|
||||
return this.invoiceRepo.save(invoice);
|
||||
}
|
||||
|
||||
async payInvoiceWithWallet(invoiceId: string, user: { id: string; role?: UserRole }) {
|
||||
const invoice = await this.getInvoiceForUser(invoiceId, user);
|
||||
this.ensureInvoicePayable(invoice);
|
||||
|
||||
const amount = Number(invoice.dueAmount);
|
||||
const tx = await this.deductWallet(
|
||||
invoice.userId,
|
||||
amount,
|
||||
`Invoice ${invoice.invoiceNumber}: ${invoice.reason}`,
|
||||
invoice.applicationId,
|
||||
invoice.id,
|
||||
);
|
||||
const updated = await this.applyInvoicePayment(invoice, amount, PaymentMethod.WALLET);
|
||||
return { invoice: updated, transaction: tx };
|
||||
}
|
||||
|
||||
async initiateInvoiceGatewayPayment(
|
||||
invoiceId: string,
|
||||
user: { id: string; role?: UserRole },
|
||||
callbackUrl: string,
|
||||
) {
|
||||
const invoice = await this.getInvoiceForUser(invoiceId, user);
|
||||
this.ensureInvoicePayable(invoice);
|
||||
|
||||
const amount = Number(invoice.dueAmount);
|
||||
const trackingCode = `INV-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||
invoice.paymentMethod = invoice.paymentMethod && invoice.paymentMethod !== PaymentMethod.GATEWAY
|
||||
? PaymentMethod.MIXED
|
||||
: PaymentMethod.GATEWAY;
|
||||
invoice.gatewayTrackingCode = trackingCode;
|
||||
await this.invoiceRepo.save(invoice);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
invoice,
|
||||
amount,
|
||||
trackingCode,
|
||||
gatewayUrl: `${callbackUrl}?invoiceId=${invoice.id}&trackingCode=${trackingCode}&amount=${amount}&status=success`,
|
||||
};
|
||||
}
|
||||
|
||||
async initiateInvoiceMixedPayment(
|
||||
invoiceId: string,
|
||||
user: { id: string; role?: UserRole },
|
||||
callbackUrl: string,
|
||||
) {
|
||||
const invoice = await this.getInvoiceForUser(invoiceId, user);
|
||||
this.ensureInvoicePayable(invoice);
|
||||
|
||||
const wallet = await this.getOrCreateWallet(invoice.userId);
|
||||
const walletAmount = Math.min(Number(wallet.balance), Number(invoice.dueAmount));
|
||||
let updatedInvoice = invoice;
|
||||
let walletTransaction: WalletTransaction | null = null;
|
||||
|
||||
if (walletAmount > 0) {
|
||||
walletTransaction = await this.deductWallet(
|
||||
invoice.userId,
|
||||
walletAmount,
|
||||
`Partial wallet payment for invoice ${invoice.invoiceNumber}`,
|
||||
invoice.applicationId,
|
||||
invoice.id,
|
||||
);
|
||||
updatedInvoice = await this.applyInvoicePayment(invoice, walletAmount, PaymentMethod.MIXED);
|
||||
} else {
|
||||
invoice.paymentMethod = PaymentMethod.MIXED;
|
||||
updatedInvoice = await this.invoiceRepo.save(invoice);
|
||||
}
|
||||
|
||||
if (Number(updatedInvoice.dueAmount) <= 0) {
|
||||
return {
|
||||
success: true,
|
||||
invoice: updatedInvoice,
|
||||
walletAmount,
|
||||
gatewayAmount: 0,
|
||||
walletTransaction,
|
||||
message: 'Invoice paid from wallet balance',
|
||||
};
|
||||
}
|
||||
|
||||
const trackingCode = `INV-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||
updatedInvoice.gatewayTrackingCode = trackingCode;
|
||||
updatedInvoice.paymentMethod = PaymentMethod.MIXED;
|
||||
updatedInvoice = await this.invoiceRepo.save(updatedInvoice);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
invoice: updatedInvoice,
|
||||
walletAmount,
|
||||
gatewayAmount: Number(updatedInvoice.dueAmount),
|
||||
walletTransaction,
|
||||
trackingCode,
|
||||
gatewayUrl: `${callbackUrl}?invoiceId=${updatedInvoice.id}&trackingCode=${trackingCode}&amount=${updatedInvoice.dueAmount}&status=success`,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyInvoiceGatewayPayment(
|
||||
invoiceId: string,
|
||||
user: { id: string; role?: UserRole },
|
||||
trackingCode: string,
|
||||
amount: number,
|
||||
) {
|
||||
const invoice = await this.getInvoiceForUser(invoiceId, user);
|
||||
this.ensureInvoicePayable(invoice);
|
||||
if (invoice.gatewayTrackingCode && invoice.gatewayTrackingCode !== trackingCode) {
|
||||
throw new BadRequestException('Invalid gateway tracking code');
|
||||
}
|
||||
|
||||
const payableAmount = Math.min(Number(invoice.dueAmount), this.normalizeAmount(amount));
|
||||
const tx = await this.recordGatewayPayment(
|
||||
invoice.userId,
|
||||
payableAmount,
|
||||
`Gateway payment for invoice ${invoice.invoiceNumber}`,
|
||||
invoice.applicationId,
|
||||
invoice.id,
|
||||
trackingCode,
|
||||
);
|
||||
const updated = await this.applyInvoicePayment(
|
||||
invoice,
|
||||
payableAmount,
|
||||
invoice.paymentMethod === PaymentMethod.MIXED ? PaymentMethod.MIXED : PaymentMethod.GATEWAY,
|
||||
trackingCode,
|
||||
`REF-${trackingCode}`,
|
||||
);
|
||||
|
||||
return { invoice: updated, transaction: tx };
|
||||
}
|
||||
|
||||
async updateInvoiceStatus(
|
||||
invoiceId: string,
|
||||
status: InvoiceStatus,
|
||||
reason: string,
|
||||
): Promise<Invoice> {
|
||||
const invoice = await this.getInvoiceForUser(invoiceId, { id: '', role: UserRole.ADMIN });
|
||||
invoice.status = status;
|
||||
invoice.statusReason = reason;
|
||||
if (status === InvoiceStatus.VOID || status === InvoiceStatus.FAILED) {
|
||||
invoice.dueAmount = Math.max(0, Number(invoice.total) - Number(invoice.paidAmount));
|
||||
}
|
||||
return this.invoiceRepo.save(invoice);
|
||||
}
|
||||
|
||||
async markInvoiceEffectCompleted(invoiceId: string, result: Record<string, any>) {
|
||||
const invoice = await this.getInvoiceForUser(invoiceId, { id: '', role: UserRole.ADMIN });
|
||||
invoice.metadata = {
|
||||
...(invoice.metadata || {}),
|
||||
completedAt: new Date().toISOString(),
|
||||
completionResult: result,
|
||||
};
|
||||
return this.invoiceRepo.save(invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost for an existing Application entity.
|
||||
* Used by lifecycle service for auto-renew.
|
||||
|
||||
Reference in New Issue
Block a user