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:
kevin-asprec
2026-03-04 23:32:14 +08:00
parent c60c22b080
commit 7cb7a9099c
6 changed files with 1031 additions and 1 deletions

View File

@@ -0,0 +1,69 @@
-- CreateEnum
CREATE TYPE "InvoiceStatus" AS ENUM ('DRAFT', 'SENT', 'PARTIAL', 'PAID', 'OVERDUE', 'VOID');
-- CreateTable
CREATE TABLE "Invoice" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"invoiceNumber" TEXT NOT NULL,
"subscriberId" TEXT NOT NULL,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"subtotal" DECIMAL(10,2) NOT NULL,
"totalAmount" DECIMAL(10,2) NOT NULL,
"amountPaid" DECIMAL(10,2) NOT NULL DEFAULT 0,
"status" "InvoiceStatus" NOT NULL DEFAULT 'DRAFT',
"journalEntryId" TEXT,
"issuedAt" TIMESTAMP(3),
"paidAt" TIMESTAMP(3),
"voidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InvoiceLine" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1,
"unitPrice" DECIMAL(10,2) NOT NULL,
"lineTotal" DECIMAL(10,2) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InvoiceLine_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Invoice_tenantId_idx" ON "Invoice"("tenantId");
-- CreateIndex
CREATE INDEX "Invoice_tenantId_status_idx" ON "Invoice"("tenantId", "status");
-- CreateIndex
CREATE INDEX "Invoice_tenantId_subscriberId_idx" ON "Invoice"("tenantId", "subscriberId");
-- CreateIndex
CREATE INDEX "Invoice_tenantId_dueDate_idx" ON "Invoice"("tenantId", "dueDate");
-- CreateIndex
CREATE UNIQUE INDEX "Invoice_tenantId_invoiceNumber_key" ON "Invoice"("tenantId", "invoiceNumber");
-- CreateIndex
CREATE UNIQUE INDEX "Invoice_tenantId_subscriberId_periodStart_key" ON "Invoice"("tenantId", "subscriberId", "periodStart");
-- CreateIndex
CREATE INDEX "InvoiceLine_invoiceId_idx" ON "InvoiceLine"("invoiceId");
-- CreateIndex
CREATE INDEX "InvoiceLine_tenantId_idx" ON "InvoiceLine"("tenantId");
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "Subscriber"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InvoiceLine" ADD CONSTRAINT "InvoiceLine_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -81,6 +81,15 @@ enum JournalEntrySource {
MANUAL
}
enum InvoiceStatus {
DRAFT
SENT
PARTIAL
PAID
OVERDUE
VOID
}
// =============================================================================
// MODELS
// =============================================================================
@@ -236,6 +245,8 @@ model Subscriber {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
invoices Invoice[]
/// Account numbers must be unique within a tenant
@@unique([tenantId, accountNumber])
/// RLS-ready index — always present on tenant-scoped models
@@ -345,3 +356,68 @@ model JournalEntryLine {
@@index([accountId])
@@index([journalEntryId])
}
/// An Invoice is a billing document issued to a subscriber for a billing period.
/// Invoices are generated by the BillingService (auto) or manually.
/// Each invoice generation creates a balanced journal entry (DR AR, CR Revenue).
/// amountPaid is a transactional convenience field — always updated atomically with JEs.
model Invoice {
id String @id @default(uuid())
tenantId String
/// Auto-generated sequential identifier per tenant (e.g., "INV-2026-0001")
invoiceNumber String
subscriberId String
subscriber Subscriber @relation(fields: [subscriberId], references: [id])
/// Billing period start date (inclusive)
periodStart DateTime
/// Billing period end date (inclusive)
periodEnd DateTime
/// Payment due date
dueDate DateTime
/// Subtotal before any adjustments (sum of line totals)
subtotal Decimal @db.Decimal(10, 2)
/// Total amount due (equals subtotal for now; extensible for taxes/discounts)
totalAmount Decimal @db.Decimal(10, 2)
/// Amount paid so far — transactional convenience field, NOT a standalone stored balance.
/// Always updated atomically with journal entries.
amountPaid Decimal @default(0) @db.Decimal(10, 2)
status InvoiceStatus @default(DRAFT)
/// Journal entry created when this invoice was generated (DR AR, CR Revenue)
journalEntryId String?
/// Timestamp when invoice was sent to subscriber
issuedAt DateTime?
/// Timestamp when invoice was fully paid
paidAt DateTime?
/// Timestamp when invoice was voided
voidedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lines InvoiceLine[]
/// Invoice numbers must be unique within a tenant
@@unique([tenantId, invoiceNumber])
/// Prevent duplicate invoices for same subscriber + period
@@unique([tenantId, subscriberId, periodStart])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, status])
@@index([tenantId, subscriberId])
@@index([tenantId, dueDate])
}
/// A single line item on an Invoice (e.g., "50 Mbps Monthly Service — $49.99").
model InvoiceLine {
id String @id @default(uuid())
tenantId String
invoiceId String
invoice Invoice @relation(fields: [invoiceId], references: [id])
description String
quantity Int @default(1)
unitPrice Decimal @db.Decimal(10, 2)
lineTotal Decimal @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@index([invoiceId])
@@index([tenantId])
}

View File

@@ -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);
},
},
},
});
}

View 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 };
}

View 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,
};
}

View 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 };
}