Add invoice payment management.

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