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

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