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

@@ -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])
}