- Add Zone model (id, tenantId, name, description, isActive) - Add ZoneAssignment model (collector-to-zone join table) - Replace Subscriber.zone String? with Subscriber.zoneId FK to Zone - Add Zone + ZoneAssignment to TENANT_SCOPED_MODELS with full operation blocks - Add "Zone" to AppSubjects in types.ts - Grant OFFICE_STAFF manage Zone, COLLECTOR read Zone in permissions.ts - Migration 20260305000000_add_zones applied to DB
547 lines
19 KiB
Plaintext
547 lines
19 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 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[]
|
|
|
|
/// 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[]
|
|
|
|
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])
|
|
}
|