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)
This commit is contained in:
159
src/lib/services/credit-service.ts
Normal file
159
src/lib/services/credit-service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user