Files
NetForge/prisma/schema.prisma
kevin-asprec 0967fc23fd feat(03-02): Collection/Remittance schema, 1030 COA account, migration, tenant scoping
- Added 1030 Cash in Transit to ISP_CHART_OF_ACCOUNTS (between 1020 and 1100)
- Added CollectionStatus (COMPLETED/VOIDED) and RemittanceStatus (PENDING/VERIFIED) enums
- Added Collection model with FIFO allocations, zone-scoped collector, JE link, void fields
- Added CollectionAllocation model linking collections to invoices
- Added Remittance model with two-party verification, variance field, JE link
- Added reverse relations on User (collections, remittances) and Subscriber (collections)
- Applied migration: add-collections-remittances
- Extended TENANT_SCOPED_MODELS with collection, collectionAllocation, remittance
- Added full 12-operation extension blocks for all three new models
2026-03-05 07:46:21 +08:00

740 lines
26 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
}
enum TicketStatus {
OPEN
ASSIGNED
RESOLVED
CLOSED
}
enum TicketPriority {
LOW
MEDIUM
HIGH
URGENT
}
enum TicketSource {
STAFF
SUBSCRIBER
}
enum CollectionStatus {
COMPLETED
VOIDED
}
enum RemittanceStatus {
PENDING
VERIFIED
}
// =============================================================================
// 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 FK for collector routing (Phase 3) — replaces old String? zone field
zoneId String?
zone Zone? @relation(fields: [zoneId], references: [id])
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[]
tickets Ticket[]
collections Collection[]
/// 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])
@@index([tenantId, zoneId])
}
/// A Zone groups subscribers geographically for collector routing.
/// Collectors are assigned to zones and can only collect from subscribers in those zones.
/// This is a security boundary enforced at the data layer.
model Zone {
id String @id @default(uuid())
tenantId String
name String
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscribers Subscriber[]
assignments ZoneAssignment[]
/// Zone names must be unique within a tenant
@@unique([tenantId, name])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
}
/// A ZoneAssignment links a collector user to a zone.
/// Collectors can be assigned to multiple zones; zones can have multiple collectors.
model ZoneAssignment {
id String @id @default(uuid())
tenantId String
/// The collector user assigned to this zone
userId String
user User @relation(fields: [userId], references: [id])
zoneId String
zone Zone @relation(fields: [zoneId], references: [id])
createdAt DateTime @default(now())
/// One assignment per collector per zone per tenant
@@unique([tenantId, userId, zoneId])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([userId])
@@index([zoneId])
}
/// 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")
/// Zone assignments for collector role (which zones this user can collect from)
zoneAssignments ZoneAssignment[]
/// Tickets this user created (staff or subscriber portal)
createdTickets Ticket[] @relation("TicketCreatedBy")
/// Collections recorded by this collector
collections Collection[] @relation("CollectionByCollector")
/// Collections voided by this user
voidedCollections Collection[] @relation("CollectionVoidedBy")
/// Remittances this collector submitted
remittances Remittance[] @relation("RemittanceByCollector")
/// Remittances verified by this user (office staff)
verifiedRemittances Remittance[] @relation("RemittanceVerifiedBy")
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[]
collectionAllocations CollectionAllocation[]
/// 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 TicketCategory is an admin-configurable label for classifying support tickets.
/// Default ISP categories are seeded at tenant creation.
/// Deactivated categories (isActive=false) cannot be used for new tickets.
model TicketCategory {
id String @id @default(uuid())
tenantId String
name String
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tickets Ticket[]
/// Category names must be unique within a tenant
@@unique([tenantId, name])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
}
/// A Ticket records a support request from a subscriber or staff member.
/// Tickets follow the lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED.
/// Status transitions are validated by the ticket service guard map.
model Ticket {
id String @id @default(uuid())
tenantId String
/// Auto-generated sequential identifier per tenant (e.g., "TKT-0001")
ticketNumber String
subject String
description String
categoryId String
category TicketCategory @relation(fields: [categoryId], references: [id])
priority TicketPriority @default(MEDIUM)
status TicketStatus @default(OPEN)
source TicketSource @default(STAFF)
/// The subscriber this ticket is about (optional — not all tickets are subscriber-specific)
subscriberId String?
subscriber Subscriber? @relation(fields: [subscriberId], references: [id])
/// The staff member or subscriber who created this ticket
createdById String
createdBy User @relation("TicketCreatedBy", fields: [createdById], references: [id])
resolvedAt DateTime?
closedAt DateTime?
/// Internal staff notes
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Ticket numbers must be unique within a tenant
@@unique([tenantId, ticketNumber])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, status])
@@index([tenantId, categoryId])
@@index([subscriberId])
}
/// A Collection records cash received from a subscriber by a field collector.
/// The collector logs the lump sum; FIFO allocation maps it to outstanding invoices.
/// Every collection creates a JE: DR 1030 Cash in Transit, CR 1100 Accounts Receivable.
/// Voids use reversing entries — records are never deleted.
model Collection {
id String @id @default(uuid())
tenantId String
/// The collector who received the cash
collectorId String
collector User @relation("CollectionByCollector", fields: [collectorId], references: [id])
/// The subscriber who paid
subscriberId String
subscriber Subscriber @relation(fields: [subscriberId], references: [id])
/// Total cash received from subscriber
amount Decimal @db.Decimal(10, 2)
/// When the cash was collected (economic date)
collectionDate DateTime
status CollectionStatus @default(COMPLETED)
notes String?
/// Journal entry created when collection was recorded (DR 1030, CR 1100)
journalEntryId String?
/// Timestamp when this collection was voided
voidedAt DateTime?
/// User who voided this collection
voidedById String?
voidedBy User? @relation("CollectionVoidedBy", fields: [voidedById], references: [id])
/// Reversing journal entry created when collection was voided
voidJournalEntryId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
allocations CollectionAllocation[]
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, collectorId])
@@index([tenantId, collectionDate])
@@index([tenantId, subscriberId])
}
/// A CollectionAllocation links a Collection to an Invoice for the allocated amount.
/// Supports partial allocations and FIFO ordering (same pattern as PaymentAllocation).
model CollectionAllocation {
id String @id @default(uuid())
tenantId String
collectionId String
collection Collection @relation(fields: [collectionId], references: [id])
invoiceId String
invoice Invoice @relation(fields: [invoiceId], references: [id])
/// Amount of the collection allocated to this invoice
amount Decimal @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@index([collectionId])
@@index([invoiceId])
@@index([tenantId])
}
/// A Remittance records a collector turning in collected cash to the office.
/// Two-party verification: collector declares total, office staff counts and verifies.
/// Variance between collector total and staff count is recorded but non-blocking.
/// Verification creates JE: DR 1010 Cash on Hand, CR 1030 Cash in Transit.
model Remittance {
id String @id @default(uuid())
tenantId String
/// The collector remitting cash
collectorId String
collector User @relation("RemittanceByCollector", fields: [collectorId], references: [id])
/// When the remittance occurred
remittanceDate DateTime
/// Total collected by the collector (self-reported)
collectedTotal Decimal @db.Decimal(10, 2)
/// Total counted by office staff (set during verification)
verifiedTotal Decimal? @db.Decimal(10, 2)
/// Difference: verifiedTotal - collectedTotal (positive = overage, negative = shortage)
variance Decimal? @db.Decimal(10, 2)
status RemittanceStatus @default(PENDING)
/// Office staff who verified the remittance
verifiedById String?
verifiedBy User? @relation("RemittanceVerifiedBy", fields: [verifiedById], references: [id])
verifiedAt DateTime?
/// Journal entry created when remittance was verified (DR 1010, CR 1030)
journalEntryId String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, collectorId])
@@index([tenantId, remittanceDate])
@@index([tenantId, status])
}
/// 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])
}