- 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)
160 lines
5.3 KiB
TypeScript
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,
|
|
};
|
|
}
|