// ============================================================================= // 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]) }