diff --git a/prisma/migrations/20260304152900_add_invoice_model/migration.sql b/prisma/migrations/20260304152900_add_invoice_model/migration.sql new file mode 100644 index 0000000..5d28fe0 --- /dev/null +++ b/prisma/migrations/20260304152900_add_invoice_model/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60aca89..02d369f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts index 2534695..1251097 100644 --- a/src/lib/prisma-tenant.ts +++ b/src/lib/prisma-tenant.ts @@ -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); + }, + }, }, }); } diff --git a/src/lib/services/billing-service.ts b/src/lib/services/billing-service.ts new file mode 100644 index 0000000..fe62ebc --- /dev/null +++ b/src/lib/services/billing-service.ts @@ -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; + +// --------------------------------------------------------------------------- +// 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 { + // 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 { + // 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 }; +} diff --git a/src/lib/services/credit-service.ts b/src/lib/services/credit-service.ts new file mode 100644 index 0000000..80d2d57 --- /dev/null +++ b/src/lib/services/credit-service.ts @@ -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 { + // 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, + }; +} diff --git a/src/lib/services/invoice-service.ts b/src/lib/services/invoice-service.ts new file mode 100644 index 0000000..1b6a96e --- /dev/null +++ b/src/lib/services/invoice-service.ts @@ -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; + +// --------------------------------------------------------------------------- +// 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 { + 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>; + total: number; + page: number; + pageSize: number; +} + +/** + * List invoices with filtering and pagination. + */ +export async function listInvoices( + tenantPrisma: TenantPrismaClient, + options: ListInvoicesOptions = {} +): Promise { + const { status, subscriberId, dueDateFrom, dueDateTo, page = 1, pageSize = 20 } = options; + + const where: Record = {}; + + if (status) where.status = status; + if (subscriberId) where.subscriberId = subscriberId; + + if (dueDateFrom || dueDateTo) { + const dueDateFilter: Record = {}; + 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 +) { + 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 { + 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 }; +}