Files
NetForge/src/lib/services/credit-service.ts
kevin-asprec 7cb7a9099c feat(02-04): Invoice model, BillingService, InvoiceService, CreditService
- Add InvoiceStatus enum + Invoice + InvoiceLine models to schema
- Add Invoice relation to Subscriber model
- Add invoice/invoiceLine to TENANT_SCOPED_MODELS in prisma-tenant.ts
- Add invoice/invoiceLine tenant-scoped query extensions to withTenantContext()
- Run migration: 20260304152900_add_invoice_model
- Create invoice-service.ts: generateInvoiceNumber, getInvoice, listInvoices,
  updateInvoiceStatus, markOverdueInvoices, voidInvoice (with JE reversal)
- Create credit-service.ts: applyCredit() — DR Subscriber Credits (1150), CR AR (1100)
- Create billing-service.ts: generateInvoiceForSubscriber (idempotent, JE + auto-credit),
  generateMonthlyInvoices (postpaid + prepaid timing with lead days)
2026-03-04 23:32:14 +08:00

160 lines
5.3 KiB
TypeScript

/**
* CreditService — Subscriber credit balance application.
*
* ARCHITECTURE:
* Applies subscriber credit balances to invoices.
* Called by BillingService after invoice creation (auto-apply on new invoice)
* and by PaymentService when overpayments create credits.
*
* ACCOUNTING TREATMENT:
* Credit application journal entry:
* DR Accounts Receivable (1100) — reduces the AR balance for this invoice
* CR Subscriber Credits (1150) — reduces the credit contra-asset
*
* Wait — the correct entry for applying credit to invoice is:
* DR Subscriber Credits (1150) — debit contra-asset to reduce it (net effect: reduce credit balance)
* CR Accounts Receivable (1100) — credit AR to reduce what subscriber owes
*
* The subscriber.creditBalance is an operational convenience field for FIFO allocation.
* It is always updated atomically with the journal entry in the same transaction.
*/
import { Prisma, InvoiceStatus, JournalEntrySource } from "@prisma/client";
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TenantPrismaClient = any;
export interface ApplyCreditResult {
appliedAmount: Prisma.Decimal;
remainingCredit: Prisma.Decimal;
newInvoiceStatus: InvoiceStatus;
journalEntryId: string | null;
}
/**
* Apply subscriber credit balance to an invoice.
*
* - Deducts from subscriber.creditBalance
* - Adds to invoice.amountPaid
* - Creates journal entry: DR Subscriber Credits (1150), CR AR (1100)
* - Updates invoice status to PARTIAL or PAID as appropriate
*
* All operations are atomic within a single transaction.
*
* @returns null if the subscriber has no credit balance
*/
export async function applyCredit(
tenantPrisma: TenantPrismaClient,
tenantId: string,
subscriberId: string,
invoiceId: string,
createdById: string
): Promise<ApplyCreditResult | null> {
// Fetch subscriber and invoice together
const [subscriber, invoice] = await Promise.all([
tenantPrisma.subscriber.findFirst({
where: { id: subscriberId },
select: { id: true, creditBalance: true },
}),
tenantPrisma.invoice.findFirst({
where: { id: invoiceId },
select: { id: true, totalAmount: true, amountPaid: true, status: true, invoiceNumber: true },
}),
]);
if (!subscriber) {
throw new Error(`Subscriber not found: ${subscriberId}`);
}
if (!invoice) {
throw new Error(`Invoice not found: ${invoiceId}`);
}
const creditBalance = new Prisma.Decimal(subscriber.creditBalance);
if (creditBalance.lessThanOrEqualTo(0)) {
return null; // No credit to apply
}
if (invoice.status === InvoiceStatus.PAID || invoice.status === InvoiceStatus.VOID) {
return null; // Invoice already settled — skip
}
const totalAmount = new Prisma.Decimal(invoice.totalAmount);
const alreadyPaid = new Prisma.Decimal(invoice.amountPaid);
const remaining = totalAmount.minus(alreadyPaid);
if (remaining.lessThanOrEqualTo(0)) {
return null; // Already fully paid
}
// Apply the lesser of credit balance vs remaining balance
const appliedAmount = creditBalance.lessThan(remaining) ? creditBalance : remaining;
const newAmountPaid = alreadyPaid.plus(appliedAmount);
const newCreditBalance = creditBalance.minus(appliedAmount);
const isFullyPaid = newAmountPaid.greaterThanOrEqualTo(totalAmount);
const newStatus = isFullyPaid ? InvoiceStatus.PAID : InvoiceStatus.PARTIAL;
// Find AR and Subscriber Credits accounts
const [arAccount, creditsAccount] = await Promise.all([
tenantPrisma.account.findFirst({ where: { code: "1100" }, select: { id: true } }),
tenantPrisma.account.findFirst({ where: { code: "1150" }, select: { id: true } }),
]);
if (!arAccount || !creditsAccount) {
throw new Error("Required accounts (1100 AR, 1150 Subscriber Credits) not found for this tenant");
}
// Create the journal entry:
// DR Subscriber Credits (1150) — reduces contra-asset (credit balance goes down)
// CR Accounts Receivable (1100) — reduces what subscriber owes
const journalEntry = await JournalEntryService.createEntry({
tenantPrisma,
tenantId,
date: new Date(),
description: `Credit applied to invoice ${invoice.invoiceNumber}`,
source: JournalEntrySource.SYSTEM,
referenceType: "Invoice",
referenceId: invoiceId,
createdById,
lines: [
{
accountId: creditsAccount.id,
debit: appliedAmount.toNumber(),
credit: 0,
description: `Credit applied: ${appliedAmount.toFixed(2)}`,
},
{
accountId: arAccount.id,
debit: 0,
credit: appliedAmount.toNumber(),
description: `AR reduced by credit: ${appliedAmount.toFixed(2)}`,
},
],
});
// Update subscriber credit balance and invoice in a transaction
await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => {
await tx.subscriber.update({
where: { id: subscriberId, tenantId },
data: { creditBalance: newCreditBalance },
});
await tx.invoice.update({
where: { id: invoiceId, tenantId },
data: {
amountPaid: newAmountPaid,
status: newStatus,
paidAt: isFullyPaid ? new Date() : null,
},
});
});
return {
appliedAmount,
remainingCredit: newCreditBalance,
newInvoiceStatus: newStatus,
journalEntryId: journalEntry.id,
};
}