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:
@@ -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;
|
||||||
@@ -81,6 +81,15 @@ enum JournalEntrySource {
|
|||||||
MANUAL
|
MANUAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum InvoiceStatus {
|
||||||
|
DRAFT
|
||||||
|
SENT
|
||||||
|
PARTIAL
|
||||||
|
PAID
|
||||||
|
OVERDUE
|
||||||
|
VOID
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MODELS
|
// MODELS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -236,6 +245,8 @@ model Subscriber {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
invoices Invoice[]
|
||||||
|
|
||||||
/// Account numbers must be unique within a tenant
|
/// Account numbers must be unique within a tenant
|
||||||
@@unique([tenantId, accountNumber])
|
@@unique([tenantId, accountNumber])
|
||||||
/// RLS-ready index — always present on tenant-scoped models
|
/// RLS-ready index — always present on tenant-scoped models
|
||||||
@@ -345,3 +356,68 @@ model JournalEntryLine {
|
|||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
@@index([journalEntryId])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
* Extend this list as new models are added in later phases:
|
* Extend this list as new models are added in later phases:
|
||||||
* e.g., "subscriber", "invoice", "servicePlan", "payment"
|
* 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];
|
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
|
||||||
|
|
||||||
@@ -736,6 +736,118 @@ export function withTenantContext(tenantId: string) {
|
|||||||
return query(args);
|
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