Files
NetForge/prisma/schema.prisma
kevin-asprec 6b91e67bdc feat(02-05): Payment model with FIFO allocation and void
- Add PaymentMethod (CASH, BANK_TRANSFER) and PaymentStatus (COMPLETED, VOIDED) enums
- Add Payment model with idempotency key, journal entry link, void fields
- Add PaymentAllocation model for FIFO invoice allocation tracking
- Add Payment/PaymentAllocation relations to Subscriber, Invoice, User
- Update TENANT_SCOPED_MODELS with "payment" and "paymentAllocation"
- Add payment/paymentAllocation query extensions in withTenantContext()
- Implement recordPayment() with FIFO allocation, overpayment credit balance
- Implement voidPayment() with reversing journal entries
- Implement getSubscriberPaymentHistory() with pagination
- Run migration: 20260304154606_add_payment_model
2026-03-04 23:47:58 +08:00

502 lines
17 KiB
Plaintext

// =============================================================================
// NetForge Prisma Schema
// =============================================================================
//
// MULTI-TENANCY & RLS CONVENTION:
// All tenant-scoped models MUST include a `tenantId` field.
// This field is the foundation for Row-Level Security (RLS) policies.
// When adding new models (Subscriber, Invoice, Plan, Payment, etc.),
// always include: tenantId String + @@index([tenantId])
//
// Super-admin models that span tenants (e.g., audit logs, platform config)
// are the only exception to this rule.
// =============================================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =============================================================================
// ENUMS
// =============================================================================
enum TenantStatus {
ACTIVE
PENDING_SUSPENSION
SUSPENDED
}
enum Role {
ADMIN
OFFICE_STAFF
COLLECTOR
TECHNICIAN
CLIENT
}
enum AccountType {
ASSET
LIABILITY
EQUITY
REVENUE
EXPENSE
}
enum NormalBalance {
DEBIT
CREDIT
}
enum PeriodStatus {
OPEN
CLOSED
}
enum SubscriberStatus {
ACTIVE
SUSPENDED
CANCELLED
}
enum BillingType {
PREPAID
POSTPAID
}
enum JournalEntryStatus {
DRAFT
PENDING_APPROVAL
APPROVED
POSTED
REVERSED
}
enum JournalEntrySource {
SYSTEM
MANUAL
}
enum InvoiceStatus {
DRAFT
SENT
PARTIAL
PAID
OVERDUE
VOID
}
enum PaymentMethod {
CASH
BANK_TRANSFER
}
enum PaymentStatus {
COMPLETED
VOIDED
}
// =============================================================================
// MODELS
// =============================================================================
/// A Tenant represents a single ISP business using the NetForge platform.
/// All tenant-scoped data is isolated by tenantId (RLS-ready).
model Tenant {
id String @id @default(uuid())
name String
/// URL-friendly identifier, auto-generated from name (e.g., "my-isp" from "My ISP")
slug String @unique
ownerEmail String
/// Physical address of the ISP business (optional)
businessAddress String?
/// Primary contact phone number (optional)
contactPhone String?
status TenantStatus @default(ACTIVE)
/// Timestamp when suspension was triggered (starts grace period clock)
suspendedAt DateTime?
/// When actual service interruption occurs (suspendedAt + 7 days grace period)
gracePeriodEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
}
/// An Account represents a node in the Chart of Accounts tree.
/// Account balances are NEVER stored — they are always derived from journal entry sums.
/// System accounts (isSystemAccount=true) are auto-created from ISP_CHART_OF_ACCOUNTS
/// and cannot be deleted by tenant admins.
model Account {
id String @id @default(uuid())
tenantId String
/// Account code (e.g., "1010" for Cash on Hand). Unique per tenant.
code String
name String
accountType AccountType
normalBalance NormalBalance
/// Optional parent account id for hierarchical COA (e.g., 1010 parent = 1000)
parentId String?
parent Account? @relation("AccountSubAccounts", fields: [parentId], references: [id])
children Account[] @relation("AccountSubAccounts")
/// COA accounts seeded at tenant creation cannot be deleted by admins
isSystemAccount Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
journalEntryLines JournalEntryLine[]
/// Account codes must be unique within a tenant
@@unique([tenantId, code])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
}
/// An AccountingPeriod represents one calendar month for a tenant.
/// Once CLOSED, no journal entries may be posted to that period.
/// Periods are created on-demand (first time an entry needs one).
model AccountingPeriod {
id String @id @default(uuid())
tenantId String
year Int
month Int
status PeriodStatus @default(OPEN)
/// Timestamp when the period was closed (null if OPEN)
closedAt DateTime?
/// The admin user who closed this period
closedById String?
closedBy User? @relation(fields: [closedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// One period per month per tenant
@@unique([tenantId, year, month])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
}
/// TenantSettings holds per-tenant configuration for billing and auto-suspension.
/// One record per tenant, created on first access or at tenant provisioning.
model TenantSettings {
id String @id @default(uuid())
tenantId String @unique
/// Days overdue before automatic subscriber suspension (default 30)
autoSuspendDays Int @default(30)
/// Days before billing date to generate prepaid invoices (default 7)
prepaidLeadDays Int @default(7)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
}
/// A ServicePlan defines the internet service offering (speed, price, billing type).
/// Plans are soft-deleted (isActive=false) to preserve subscriber history.
model ServicePlan {
id String @id @default(uuid())
tenantId String
name String
/// Human-readable speed description (e.g., "50 Mbps", "100/20 Mbps")
speed String
/// Monthly charge for this plan (2 decimal places)
monthlyPrice Decimal @db.Decimal(10, 2)
billingType BillingType
description String?
/// Soft-delete: inactive plans cannot be assigned to new subscribers
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscribers Subscriber[]
/// Plan names must be unique within a tenant
@@unique([tenantId, name])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
}
/// A Subscriber is a customer of the ISP — the core billing entity.
/// All invoices, payments, and collections target subscribers.
model Subscriber {
id String @id @default(uuid())
tenantId String
/// Auto-generated sequential identifier per tenant (e.g., "SUB-0001")
accountNumber String
firstName String
lastName String
email String?
phone String?
address String
/// Zone for collector routing (Phase 3)
zone String?
servicePlanId String
servicePlan ServicePlan @relation(fields: [servicePlanId], references: [id])
status SubscriberStatus @default(ACTIVE)
/// Day of month for invoice generation — derived from signup date, capped at 28
billingDay Int
activatedAt DateTime @default(now())
suspendedAt DateTime?
cancelledAt DateTime?
/// Per-subscriber auto-suspend override (null = use TenantSettings.autoSuspendDays)
autoSuspendDays Int?
notes String?
/// Overpayment credit balance — always updated atomically with journal entries.
/// This is NOT a stored ledger balance; it tracks credits for the FIFO allocation
/// system (02-05) and is always updated within the same transaction as the journal entry.
creditBalance Decimal @default(0) @db.Decimal(10, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
invoices Invoice[]
payments Payment[]
/// Account numbers must be unique within a tenant
@@unique([tenantId, accountNumber])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, status])
@@index([tenantId, servicePlanId])
}
/// A User belongs to a Tenant (or is a super-admin with no tenant).
/// Email uniqueness is enforced per-tenant, not globally.
/// Super-admins have isSuperAdmin=true and tenantId=null.
model User {
id String @id @default(uuid())
email String
passwordHash String
firstName String
lastName String
/// Nullable for super-admins who are not scoped to a specific tenant
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
/// Multi-role support — a user can hold more than one role within a tenant
roles Role[]
isActive Boolean @default(true)
isSuperAdmin Boolean @default(false)
/// Accounting periods this user has closed (as administrator)
closedPeriods AccountingPeriod[]
/// Journal entries this user created (maker)
createdJournalEntries JournalEntry[] @relation("JournalEntryCreatedBy")
/// Journal entries this user approved (checker)
approvedJournalEntries JournalEntry[] @relation("JournalEntryApprovedBy")
/// Payments this user recorded
recordedPayments Payment[] @relation("PaymentRecordedBy")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Email must be unique within a tenant (super-admins have tenantId=null)
@@unique([email, tenantId])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
}
/// A JournalEntry records a double-entry accounting transaction.
/// All financial events in the system flow through this model.
/// Entries are IMMUTABLE — no updates or deletes. Corrections use reversing entries.
model JournalEntry {
id String @id @default(uuid())
tenantId String
/// Auto-generated sequential identifier per tenant per year (e.g., "JE-2026-0001")
entryNumber String
/// The accounting date (when the economic event occurred, not necessarily createdAt)
date DateTime
description String
source JournalEntrySource
status JournalEntryStatus @default(DRAFT)
/// Optional reference to source record (e.g., "Invoice", "Payment")
referenceType String?
/// ID of the source record (e.g., the invoice UUID)
referenceId String?
/// If this entry reverses another entry, this points to the original
reversesEntryId String? @unique
reversesEntry JournalEntry? @relation("JournalEntryReversal", fields: [reversesEntryId], references: [id])
/// If this entry has been reversed, this points to the reversing entry (back-reference)
reversedByEntry JournalEntry? @relation("JournalEntryReversal")
/// The user who created the entry (maker)
createdById String
createdBy User @relation("JournalEntryCreatedBy", fields: [createdById], references: [id])
/// The user who approved the entry (checker — for MANUAL entries)
approvedById String?
approvedBy User? @relation("JournalEntryApprovedBy", fields: [approvedById], references: [id])
approvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lines JournalEntryLine[]
/// Entry numbers must be unique within a tenant
@@unique([tenantId, entryNumber])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, date])
@@index([referenceType, referenceId])
}
/// A single line (debit or credit) within a JournalEntry.
/// Every entry must have at least 2 lines, and sum(debit) = sum(credit).
model JournalEntryLine {
id String @id @default(uuid())
tenantId String
journalEntryId String
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id])
accountId String
account Account @relation(fields: [accountId], references: [id])
/// Debit amount for this line (0 if this is a credit line)
debit Decimal @default(0) @db.Decimal(15, 2)
/// Credit amount for this line (0 if this is a debit line)
credit Decimal @default(0) @db.Decimal(15, 2)
/// Optional line-level memo
description String?
createdAt DateTime @default(now())
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@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[]
paymentAllocations PaymentAllocation[]
/// 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 Payment records a cash or bank transfer received from a subscriber.
/// Payments are allocated FIFO to oldest unpaid invoices.
/// Every payment creates a balanced journal entry (DR Cash/Bank, CR AR).
/// Voids use reversing entries — records are never deleted.
/// idempotencyKey prevents double-recording; @@unique([tenantId, idempotencyKey]).
model Payment {
id String @id @default(uuid())
tenantId String
subscriberId String
subscriber Subscriber @relation(fields: [subscriberId], references: [id])
/// Total amount received
amount Decimal @db.Decimal(10, 2)
paymentMethod PaymentMethod
/// Optional external reference (e.g., bank reference number, receipt number)
referenceNumber String?
/// When the payment was received (economic date, not necessarily createdAt)
paymentDate DateTime
notes String?
status PaymentStatus @default(COMPLETED)
/// Client-supplied key to prevent double-recording on retries
idempotencyKey String
/// Journal entry created when payment was recorded (DR Cash/Bank, CR AR)
journalEntryId String?
/// Timestamp when this payment was voided
voidedAt DateTime?
/// User who voided this payment
voidedById String?
/// Reversing journal entry created when payment was voided
voidJournalEntryId String?
/// User who recorded this payment
recordedById String
recordedBy User @relation("PaymentRecordedBy", fields: [recordedById], references: [id])
allocations PaymentAllocation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Idempotency: one payment per key per tenant
@@unique([tenantId, idempotencyKey])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, subscriberId])
@@index([tenantId, paymentDate])
}
/// A PaymentAllocation links a Payment to an Invoice for the allocated amount.
/// Supports partial allocations and FIFO ordering.
model PaymentAllocation {
id String @id @default(uuid())
tenantId String
paymentId String
payment Payment @relation(fields: [paymentId], references: [id])
invoiceId String
invoice Invoice @relation(fields: [invoiceId], references: [id])
/// Amount of the payment allocated to this invoice
amount Decimal @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@index([paymentId])
@@index([invoiceId])
@@index([tenantId])
}
/// 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])
}