feat(02-04): Invoice model, BillingService, InvoiceService, CreditService

- Add InvoiceStatus enum + Invoice + InvoiceLine models to schema
- Add Invoice relation to Subscriber model
- Add invoice/invoiceLine to TENANT_SCOPED_MODELS in prisma-tenant.ts
- Add invoice/invoiceLine tenant-scoped query extensions to withTenantContext()
- Run migration: 20260304152900_add_invoice_model
- Create invoice-service.ts: generateInvoiceNumber, getInvoice, listInvoices,
  updateInvoiceStatus, markOverdueInvoices, voidInvoice (with JE reversal)
- Create credit-service.ts: applyCredit() — DR Subscriber Credits (1150), CR AR (1100)
- Create billing-service.ts: generateInvoiceForSubscriber (idempotent, JE + auto-credit),
  generateMonthlyInvoices (postpaid + prepaid timing with lead days)
This commit is contained in:
kevin-asprec
2026-03-04 23:32:14 +08:00
parent c60c22b080
commit 7cb7a9099c
6 changed files with 1031 additions and 1 deletions

View File

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

View File

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