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:
@@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma";
|
||||
* Extend this list as new models are added in later phases:
|
||||
* e.g., "subscriber", "invoice", "servicePlan", "payment"
|
||||
*/
|
||||
export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine"] as const;
|
||||
export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine"] as const;
|
||||
|
||||
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
|
||||
|
||||
@@ -736,6 +736,118 @@ export function withTenantContext(tenantId: string) {
|
||||
return query(args);
|
||||
},
|
||||
},
|
||||
|
||||
invoice: {
|
||||
async findMany({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async findFirst({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async findFirstOrThrow({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async findUnique({ args, query }) {
|
||||
if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) {
|
||||
return prisma.invoice.findFirst({
|
||||
...args,
|
||||
where: { ...args.where, tenantId },
|
||||
});
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async findUniqueOrThrow({ args, query }) {
|
||||
if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) {
|
||||
const result = await prisma.invoice.findFirst({
|
||||
...args,
|
||||
where: { ...args.where, tenantId },
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("Record not found");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async create({ args, query }) {
|
||||
args.data = { ...args.data, tenantId } as typeof args.data;
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async createMany({ args, query }) {
|
||||
if (Array.isArray(args.data)) {
|
||||
args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data;
|
||||
} else {
|
||||
args.data = { ...args.data, tenantId } as typeof args.data;
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async update({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async updateMany({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async delete({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async deleteMany({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async count({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async aggregate({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async groupBy({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
},
|
||||
|
||||
invoiceLine: {
|
||||
async findMany({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async findFirst({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async create({ args, query }) {
|
||||
args.data = { ...args.data, tenantId } as typeof args.data;
|
||||
return query(args);
|
||||
},
|
||||
|
||||
async count({ args, query }) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
return query(args);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
356
src/lib/services/billing-service.ts
Normal file
356
src/lib/services/billing-service.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* BillingService — Billing cycle engine for invoice generation.
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* This service is the revenue cycle — it turns service plans into invoices.
|
||||
* It is the only place where invoice records and their associated journal entries
|
||||
* are created from billing cycles.
|
||||
*
|
||||
* BILLING TYPES:
|
||||
* - POSTPAID: Invoice generated ON the billing day (subscriber already used the service)
|
||||
* - PREPAID: Invoice generated X days BEFORE billing day (prepaidLeadDays from TenantSettings)
|
||||
*
|
||||
* JOURNAL ENTRY:
|
||||
* Each invoice creates a balanced journal entry via JournalEntryService:
|
||||
* DR Accounts Receivable (1100) — subscriber owes us money
|
||||
* CR Subscription Revenue (4010) — we earned revenue
|
||||
*
|
||||
* CREDIT AUTO-APPLICATION:
|
||||
* After creating an invoice, if the subscriber has a creditBalance > 0,
|
||||
* applyCredit() is called from credit-service.ts to auto-apply it.
|
||||
*
|
||||
* IDEMPOTENCY:
|
||||
* generateInvoiceForSubscriber checks for duplicate invoice (same tenantId + subscriberId + periodStart)
|
||||
* before creating. Duplicate invoices are skipped, not errored.
|
||||
*/
|
||||
|
||||
import { Prisma, JournalEntrySource, SubscriberStatus, BillingType } from "@prisma/client";
|
||||
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
|
||||
import { generateInvoiceNumber } from "@/lib/services/invoice-service";
|
||||
import { applyCredit } from "@/lib/services/credit-service";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TenantPrismaClient = any;
|
||||
type TenantPrisma = ReturnType<typeof withTenantContext>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BillingPeriod {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
dueDate: Date;
|
||||
}
|
||||
|
||||
export interface GenerateInvoiceResult {
|
||||
invoice: unknown;
|
||||
creditApplied: boolean;
|
||||
}
|
||||
|
||||
export interface GenerateMonthlyResult {
|
||||
generated: GenerateInvoiceResult[];
|
||||
skipped: string[]; // subscriber IDs that had duplicate invoices
|
||||
errors: Array<{ subscriberId: string; error: string }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute billing period for a given billing day and target date.
|
||||
*
|
||||
* For POSTPAID: period covers the previous month on billing day.
|
||||
* For PREPAID: period covers the current month (invoice sent in advance).
|
||||
*
|
||||
* Period = 1 calendar month.
|
||||
* periodStart = billing day of current month
|
||||
* periodEnd = day before billing day of next month (or last day of month)
|
||||
* dueDate = periodStart + 7 days (grace period for payment)
|
||||
*/
|
||||
export function computeBillingPeriod(targetDate: Date, billingDay: number): BillingPeriod {
|
||||
// Period starts on billing day of the target month
|
||||
const year = targetDate.getFullYear();
|
||||
const month = targetDate.getMonth();
|
||||
|
||||
const periodStart = new Date(Date.UTC(year, month, billingDay));
|
||||
|
||||
// Period ends the day before the billing day next month
|
||||
const nextMonth = new Date(Date.UTC(year, month + 1, billingDay));
|
||||
const periodEnd = new Date(nextMonth.getTime() - 86400000); // minus 1 day in ms
|
||||
|
||||
// Due date: 30 days after period start (standard billing terms)
|
||||
const dueDate = new Date(Date.UTC(year, month, billingDay + 30));
|
||||
|
||||
return { periodStart, periodEnd, dueDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a subscriber should be billed on targetDate.
|
||||
*
|
||||
* POSTPAID: billingDay === targetDate's day-of-month
|
||||
* PREPAID: (billingDay - prepaidLeadDays) matches targetDate's day-of-month
|
||||
* with month wrapping (e.g., billingDay=5, leadDays=7 -> bill on day 28/29 of previous month)
|
||||
*/
|
||||
export function shouldBillToday(
|
||||
billingType: BillingType,
|
||||
billingDay: number,
|
||||
targetDate: Date,
|
||||
prepaidLeadDays: number
|
||||
): boolean {
|
||||
const targetDay = targetDate.getUTCDate();
|
||||
const targetMonth = targetDate.getUTCMonth();
|
||||
const targetYear = targetDate.getUTCFullYear();
|
||||
|
||||
if (billingType === BillingType.POSTPAID) {
|
||||
return billingDay === targetDay;
|
||||
}
|
||||
|
||||
// PREPAID: determine the lead-up day
|
||||
// If billingDay - leadDays <= 0, we spill into the previous month
|
||||
const leadDay = billingDay - prepaidLeadDays;
|
||||
|
||||
if (leadDay > 0) {
|
||||
return leadDay === targetDay;
|
||||
} else {
|
||||
// Spills into previous month — compute last N days of previous month
|
||||
const prevMonthLastDay = new Date(Date.UTC(targetYear, targetMonth, 0)).getUTCDate();
|
||||
const actualLeadDay = prevMonthLastDay + leadDay; // leadDay is negative here
|
||||
return actualLeadDay === targetDay;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core invoice generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a single invoice for a subscriber for a given billing period.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Check for duplicate (idempotent) — skip if already exists
|
||||
* 2. Create Invoice + InvoiceLine
|
||||
* 3. Create journal entry: DR AR (1100), CR Revenue (4010)
|
||||
* 4. Auto-apply credit if subscriber has balance
|
||||
*
|
||||
* @returns null if a duplicate invoice already exists (idempotent skip)
|
||||
*/
|
||||
export async function generateInvoiceForSubscriber(
|
||||
tenantPrisma: TenantPrisma | TenantPrismaClient,
|
||||
tenantId: string,
|
||||
subscriber: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
creditBalance: Prisma.Decimal | number | string;
|
||||
servicePlan: {
|
||||
name: string;
|
||||
monthlyPrice: Prisma.Decimal | number | string;
|
||||
billingType: BillingType;
|
||||
};
|
||||
},
|
||||
period: BillingPeriod,
|
||||
createdById: string
|
||||
): Promise<GenerateInvoiceResult | null> {
|
||||
// 1. Idempotency check — prevent duplicate invoices for same subscriber + period
|
||||
const existing = await tenantPrisma.invoice.findFirst({
|
||||
where: {
|
||||
subscriberId: subscriber.id,
|
||||
periodStart: period.periodStart,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return null; // Duplicate — skip
|
||||
}
|
||||
|
||||
// Find AR and Revenue accounts
|
||||
const [arAccount, revenueAccount] = await Promise.all([
|
||||
tenantPrisma.account.findFirst({ where: { code: "1100" }, select: { id: true } }),
|
||||
tenantPrisma.account.findFirst({ where: { code: "4010" }, select: { id: true } }),
|
||||
]);
|
||||
|
||||
if (!arAccount || !revenueAccount) {
|
||||
throw new Error(
|
||||
"Required accounts (1100 AR, 4010 Revenue) not found for this tenant"
|
||||
);
|
||||
}
|
||||
|
||||
const monthlyPrice = new Prisma.Decimal(subscriber.servicePlan.monthlyPrice);
|
||||
const invoiceNumber = await generateInvoiceNumber(tenantPrisma, period.periodStart.getFullYear());
|
||||
|
||||
// 2. Create invoice + line in a transaction
|
||||
const invoice = await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => {
|
||||
const created = await tx.invoice.create({
|
||||
data: {
|
||||
tenantId,
|
||||
invoiceNumber,
|
||||
subscriberId: subscriber.id,
|
||||
periodStart: period.periodStart,
|
||||
periodEnd: period.periodEnd,
|
||||
dueDate: period.dueDate,
|
||||
subtotal: monthlyPrice,
|
||||
totalAmount: monthlyPrice,
|
||||
amountPaid: new Prisma.Decimal(0),
|
||||
lines: {
|
||||
create: [
|
||||
{
|
||||
tenantId,
|
||||
description: `${subscriber.servicePlan.name} — Monthly Service`,
|
||||
quantity: 1,
|
||||
unitPrice: monthlyPrice,
|
||||
lineTotal: monthlyPrice,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { lines: true },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
// 3. Create journal entry: DR AR (1100), CR Revenue (4010)
|
||||
const subscriberName = `${subscriber.firstName} ${subscriber.lastName || ""}`.trim();
|
||||
const journalEntry = await JournalEntryService.createEntry({
|
||||
tenantPrisma,
|
||||
tenantId,
|
||||
date: period.periodStart,
|
||||
description: `Invoice ${invoiceNumber} — ${subscriberName}`,
|
||||
source: JournalEntrySource.SYSTEM,
|
||||
referenceType: "Invoice",
|
||||
referenceId: invoice.id,
|
||||
createdById,
|
||||
lines: [
|
||||
{
|
||||
accountId: arAccount.id,
|
||||
debit: monthlyPrice.toNumber(),
|
||||
credit: 0,
|
||||
description: `AR: Invoice ${invoiceNumber}`,
|
||||
},
|
||||
{
|
||||
accountId: revenueAccount.id,
|
||||
debit: 0,
|
||||
credit: monthlyPrice.toNumber(),
|
||||
description: `Revenue: Invoice ${invoiceNumber}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Link journal entry to invoice
|
||||
await tenantPrisma.invoice.update({
|
||||
where: { id: invoice.id, tenantId },
|
||||
data: { journalEntryId: journalEntry.id },
|
||||
});
|
||||
|
||||
// 4. Auto-apply credit if subscriber has any
|
||||
const creditBalance = new Prisma.Decimal(subscriber.creditBalance);
|
||||
let creditApplied = false;
|
||||
|
||||
if (creditBalance.greaterThan(0)) {
|
||||
const result = await applyCredit(
|
||||
tenantPrisma,
|
||||
tenantId,
|
||||
subscriber.id,
|
||||
invoice.id,
|
||||
createdById
|
||||
);
|
||||
creditApplied = result !== null && result.appliedAmount.greaterThan(0);
|
||||
}
|
||||
|
||||
// Return the invoice with journalEntryId set
|
||||
const finalInvoice = await tenantPrisma.invoice.findFirst({
|
||||
where: { id: invoice.id },
|
||||
include: { lines: true },
|
||||
});
|
||||
|
||||
return { invoice: finalInvoice, creditApplied };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monthly billing cycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run the monthly billing cycle for all eligible subscribers.
|
||||
*
|
||||
* Eligibility:
|
||||
* - POSTPAID: subscriber.billingDay === targetDate.getUTCDate() AND status = ACTIVE
|
||||
* - PREPAID: (billingDay - prepaidLeadDays) matches targetDate.getUTCDate() AND status = ACTIVE
|
||||
* - SUSPENDED and CANCELLED subscribers are excluded
|
||||
*
|
||||
* @param targetDate - The date to run billing for (default: today)
|
||||
* @returns Summary of generated, skipped, and errored invoices
|
||||
*/
|
||||
export async function generateMonthlyInvoices(
|
||||
tenantPrisma: TenantPrisma | TenantPrismaClient,
|
||||
tenantId: string,
|
||||
targetDate: Date,
|
||||
createdById: string
|
||||
): Promise<GenerateMonthlyResult> {
|
||||
// Get tenant settings for prepaid lead days
|
||||
const settings = await tenantPrisma.tenantSettings.findFirst({
|
||||
select: { prepaidLeadDays: true },
|
||||
});
|
||||
const prepaidLeadDays = settings?.prepaidLeadDays ?? 7;
|
||||
|
||||
// Fetch all active subscribers with their service plans
|
||||
const subscribers = await tenantPrisma.subscriber.findMany({
|
||||
where: {
|
||||
status: SubscriberStatus.ACTIVE,
|
||||
},
|
||||
include: {
|
||||
servicePlan: {
|
||||
select: {
|
||||
name: true,
|
||||
monthlyPrice: true,
|
||||
billingType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const generated: GenerateInvoiceResult[] = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: Array<{ subscriberId: string; error: string }> = [];
|
||||
|
||||
for (const subscriber of subscribers) {
|
||||
try {
|
||||
const shouldBill = shouldBillToday(
|
||||
subscriber.servicePlan.billingType,
|
||||
subscriber.billingDay,
|
||||
targetDate,
|
||||
prepaidLeadDays
|
||||
);
|
||||
|
||||
if (!shouldBill) {
|
||||
continue; // Not their billing day
|
||||
}
|
||||
|
||||
const period = computeBillingPeriod(targetDate, subscriber.billingDay);
|
||||
|
||||
const result = await generateInvoiceForSubscriber(
|
||||
tenantPrisma,
|
||||
tenantId,
|
||||
subscriber,
|
||||
period,
|
||||
createdById
|
||||
);
|
||||
|
||||
if (result === null) {
|
||||
skipped.push(subscriber.id); // Duplicate
|
||||
} else {
|
||||
generated.push(result);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
subscriberId: subscriber.id,
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { generated, skipped, errors };
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
258
src/lib/services/invoice-service.ts
Normal file
258
src/lib/services/invoice-service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* InvoiceService — Invoice CRUD, status management, and overdue detection.
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* This service handles Invoice read operations and status lifecycle transitions.
|
||||
* Invoice creation (generation) is handled by BillingService.
|
||||
* Payment recording updates amountPaid and status — that is done by PaymentService (02-05).
|
||||
*
|
||||
* STATUS LIFECYCLE:
|
||||
* DRAFT -> SENT -> PARTIAL -> PAID -> OVERDUE -> VOID
|
||||
* Void creates a reversing journal entry for the original invoice JE.
|
||||
*/
|
||||
|
||||
import { InvoiceStatus, Prisma } from "@prisma/client";
|
||||
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TenantPrismaClient = any;
|
||||
type TenantPrisma = ReturnType<typeof withTenantContext>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invoice number generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate the next invoice number for a tenant in a given year.
|
||||
* Format: "INV-{YYYY}-{NNNN}"
|
||||
*
|
||||
* Uses the tenant-scoped client (which auto-filters by tenant).
|
||||
*/
|
||||
export async function generateInvoiceNumber(
|
||||
tenantPrisma: TenantPrisma,
|
||||
year: number
|
||||
): Promise<string> {
|
||||
const yearPrefix = `INV-${year}-`;
|
||||
|
||||
const existing = await tenantPrisma.invoice.findMany({
|
||||
where: {
|
||||
invoiceNumber: {
|
||||
startsWith: yearPrefix,
|
||||
},
|
||||
},
|
||||
select: { invoiceNumber: true },
|
||||
orderBy: { invoiceNumber: "desc" },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
let nextNumber = 1;
|
||||
if (existing.length > 0) {
|
||||
const lastNumber = existing[0].invoiceNumber as string;
|
||||
const seq = lastNumber.slice(yearPrefix.length);
|
||||
const lastSeq = parseInt(seq, 10);
|
||||
nextNumber = lastSeq + 1;
|
||||
}
|
||||
|
||||
return `INV-${year}-${String(nextNumber).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a single invoice by ID, including its line items.
|
||||
* Returns null if not found within tenant scope.
|
||||
*/
|
||||
export async function getInvoice(tenantPrisma: TenantPrismaClient, invoiceId: string) {
|
||||
return tenantPrisma.invoice.findFirst({
|
||||
where: { id: invoiceId },
|
||||
include: {
|
||||
lines: true,
|
||||
subscriber: {
|
||||
select: {
|
||||
id: true,
|
||||
accountNumber: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface ListInvoicesOptions {
|
||||
status?: InvoiceStatus;
|
||||
subscriberId?: string;
|
||||
/** Only invoices with dueDate on or after this date */
|
||||
dueDateFrom?: Date;
|
||||
/** Only invoices with dueDate on or before this date */
|
||||
dueDateTo?: Date;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ListInvoicesResult {
|
||||
invoices: Awaited<ReturnType<TenantPrisma["invoice"]["findMany"]>>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* List invoices with filtering and pagination.
|
||||
*/
|
||||
export async function listInvoices(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
options: ListInvoicesOptions = {}
|
||||
): Promise<ListInvoicesResult> {
|
||||
const { status, subscriberId, dueDateFrom, dueDateTo, page = 1, pageSize = 20 } = options;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (status) where.status = status;
|
||||
if (subscriberId) where.subscriberId = subscriberId;
|
||||
|
||||
if (dueDateFrom || dueDateTo) {
|
||||
const dueDateFilter: Record<string, Date> = {};
|
||||
if (dueDateFrom) dueDateFilter.gte = dueDateFrom;
|
||||
if (dueDateTo) dueDateFilter.lte = dueDateTo;
|
||||
where.dueDate = dueDateFilter;
|
||||
}
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const [invoices, total] = await Promise.all([
|
||||
tenantPrisma.invoice.findMany({
|
||||
where,
|
||||
include: {
|
||||
lines: true,
|
||||
subscriber: {
|
||||
select: {
|
||||
id: true,
|
||||
accountNumber: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
tenantPrisma.invoice.count({ where }),
|
||||
]);
|
||||
|
||||
return { invoices, total, page, pageSize };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update invoice status directly.
|
||||
* Used internally by billing engine; external callers use specific methods.
|
||||
*/
|
||||
export async function updateInvoiceStatus(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
invoiceId: string,
|
||||
status: InvoiceStatus,
|
||||
extraData?: Record<string, unknown>
|
||||
) {
|
||||
return tenantPrisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
status,
|
||||
...extraData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark invoices as OVERDUE where:
|
||||
* - status is DRAFT, SENT, or PARTIAL
|
||||
* - dueDate is before targetDate (default: now)
|
||||
*
|
||||
* Returns the count of invoices updated.
|
||||
*/
|
||||
export async function markOverdueInvoices(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
targetDate: Date = new Date()
|
||||
): Promise<number> {
|
||||
const result = await tenantPrisma.invoice.updateMany({
|
||||
where: {
|
||||
status: {
|
||||
in: [InvoiceStatus.DRAFT, InvoiceStatus.SENT, InvoiceStatus.PARTIAL],
|
||||
},
|
||||
dueDate: {
|
||||
lt: targetDate,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: InvoiceStatus.OVERDUE,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Void an invoice.
|
||||
*
|
||||
* Transitions invoice to VOID status and creates a reversing journal entry
|
||||
* for the original invoice journal entry (if one exists).
|
||||
*
|
||||
* @throws Error if invoice not found, already VOID, or already PAID.
|
||||
*/
|
||||
export async function voidInvoice(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
tenantId: string,
|
||||
invoiceId: string,
|
||||
voidedById: string
|
||||
): Promise<{ invoice: unknown; reversingEntry: unknown | null }> {
|
||||
const invoice = await tenantPrisma.invoice.findFirst({
|
||||
where: { id: invoiceId },
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error(`Invoice not found: ${invoiceId}`);
|
||||
}
|
||||
|
||||
if (invoice.status === InvoiceStatus.VOID) {
|
||||
throw new Error(`Invoice ${invoice.invoiceNumber} is already voided.`);
|
||||
}
|
||||
|
||||
if (invoice.status === InvoiceStatus.PAID) {
|
||||
throw new Error(
|
||||
`Cannot void invoice ${invoice.invoiceNumber}: it has been fully paid. ` +
|
||||
`Issue a credit memo or refund instead.`
|
||||
);
|
||||
}
|
||||
|
||||
// Reverse the original journal entry if one exists
|
||||
let reversingEntry: unknown = null;
|
||||
if (invoice.journalEntryId) {
|
||||
reversingEntry = await JournalEntryService.reverseEntry({
|
||||
tenantPrisma,
|
||||
tenantId,
|
||||
entryId: invoice.journalEntryId,
|
||||
reversedById: voidedById,
|
||||
description: `Void of invoice ${invoice.invoiceNumber}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Update invoice to VOID
|
||||
const updatedInvoice = await tenantPrisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
status: InvoiceStatus.VOID,
|
||||
voidedAt: new Date(),
|
||||
},
|
||||
include: { lines: true },
|
||||
});
|
||||
|
||||
return { invoice: updatedInvoice, reversingEntry };
|
||||
}
|
||||
Reference in New Issue
Block a user