- Add TicketComment model for conversation threads (append-only) - Add paymentInstructions field to TenantSettings - Add ticketComments relation to User model - Create portal-ticket-service with ensurePortalUser shadow User pattern - Implements createPortalTicket, listPortalTickets, getPortalTicket, addTicketComment - Portal ticket creation delegates to existing createTicket with source=SUBSCRIBER Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1093 lines
39 KiB
Plaintext
1093 lines
39 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
|
|
}
|
|
|
|
enum JobOrderStatus {
|
|
PENDING
|
|
IN_PROGRESS
|
|
COMPLETED
|
|
CANCELLED
|
|
}
|
|
|
|
enum CompensationModel {
|
|
PER_JOB
|
|
SALARY
|
|
HYBRID
|
|
}
|
|
|
|
enum ItemTrackingType {
|
|
SERIALIZED
|
|
BATCH
|
|
}
|
|
|
|
enum ItemCondition {
|
|
NEW
|
|
REFURBISHED
|
|
USED
|
|
DAMAGED
|
|
}
|
|
|
|
enum MovementType {
|
|
RECEIVED
|
|
ISSUED
|
|
RETURNED
|
|
DISPOSED
|
|
TRANSFERRED
|
|
}
|
|
|
|
enum LocationType {
|
|
WAREHOUSE
|
|
TECHNICIAN
|
|
SUBSCRIBER
|
|
}
|
|
|
|
enum ExpenseStatus {
|
|
DRAFT
|
|
APPROVED
|
|
POSTED
|
|
VOIDED
|
|
}
|
|
|
|
enum ExpensePaymentMethod {
|
|
CASH
|
|
BANK_TRANSFER
|
|
CHECK
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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)
|
|
/// Freeform payment instructions shown on the portal "pay online" page (e.g., GCash, bank details)
|
|
paymentInstructions String?
|
|
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?
|
|
/// Hashed password for portal login — nullable because existing subscribers may not have portal access
|
|
passwordHash String?
|
|
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[]
|
|
technicianProfiles TechnicianProfile[]
|
|
|
|
/// 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")
|
|
/// Job orders assigned to this technician
|
|
assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo")
|
|
/// Job orders created by this user (staff)
|
|
createdJobOrders JobOrder[] @relation("JobOrderCreatedBy")
|
|
/// Technician profiles for this user (one per tenant, enforced by @@unique([tenantId, userId]))
|
|
technicianProfiles TechnicianProfile[]
|
|
/// Stock movements performed/recorded by this user
|
|
recordedMovements StockMovement[] @relation("MovementPerformedBy")
|
|
/// Expenses created by this user
|
|
createdExpenses Expense[] @relation("ExpenseCreatedBy")
|
|
/// Expenses approved by this user
|
|
approvedExpenses Expense[] @relation("ExpenseApprovedBy")
|
|
/// Ticket comments authored by this user
|
|
ticketComments TicketComment[]
|
|
|
|
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])
|
|
|
|
jobOrders JobOrder[]
|
|
comments TicketComment[]
|
|
}
|
|
|
|
/// A TicketComment is a message in a ticket conversation thread.
|
|
/// Subscribers and staff can both add comments to open tickets.
|
|
/// Comments are append-only — no edits or deletes.
|
|
model TicketComment {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
ticketId String
|
|
ticket Ticket @relation(fields: [ticketId], references: [id])
|
|
/// Who posted this comment — can be subscriber (CLIENT) or staff
|
|
authorId String
|
|
author User @relation(fields: [authorId], references: [id])
|
|
/// For portal comments, link to the subscriber directly
|
|
subscriberId String?
|
|
message String
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([tenantId])
|
|
@@index([ticketId])
|
|
}
|
|
|
|
/// 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 JobOrder is the execution unit created from a support ticket.
|
|
/// One ticket can have many job orders (1:many).
|
|
/// A technician is assigned to each job order and updates its status.
|
|
/// When ALL non-cancelled job orders on a ticket are COMPLETED, the ticket auto-resolves.
|
|
/// When ALL job orders on a ticket are CANCELLED, the ticket reverts to OPEN.
|
|
model JobOrder {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
/// Auto-generated sequential identifier per tenant (e.g., "JO-0001")
|
|
orderNumber String
|
|
/// The ticket this job order is for
|
|
ticketId String
|
|
ticket Ticket @relation(fields: [ticketId], references: [id])
|
|
/// Type of work (e.g., "Installation", "Repair", "Maintenance")
|
|
jobType String
|
|
description String?
|
|
/// The technician assigned to this job order
|
|
assignedToId String
|
|
assignedTo User @relation("JobOrderAssignedTo", fields: [assignedToId], references: [id])
|
|
status JobOrderStatus @default(PENDING)
|
|
scheduledDate DateTime?
|
|
/// Timestamp when the technician started working
|
|
startedAt DateTime?
|
|
/// Timestamp when the job was completed
|
|
completedAt DateTime?
|
|
/// Notes on outcome, required when marking COMPLETED
|
|
outcomeNotes String?
|
|
/// Timestamp when the job was cancelled
|
|
cancelledAt DateTime?
|
|
cancelReason String?
|
|
/// The staff member who created this job order
|
|
createdById String
|
|
createdBy User @relation("JobOrderCreatedBy", fields: [createdById], references: [id])
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// Order numbers must be unique within a tenant
|
|
@@unique([tenantId, orderNumber])
|
|
/// RLS-ready index — always present on tenant-scoped models
|
|
@@index([tenantId])
|
|
@@index([tenantId, ticketId])
|
|
@@index([tenantId, assignedToId])
|
|
@@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])
|
|
}
|
|
|
|
/// A TechnicianProfile holds ISP-specific attributes for a technician user.
|
|
/// Compensation model determines how the technician is paid:
|
|
/// PER_JOB: sum of job type rates for completed jobs
|
|
/// SALARY: fixed monthly salary only
|
|
/// HYBRID: monthly salary + per-job bonuses
|
|
model TechnicianProfile {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
/// The technician user (FK to User) — one profile per user per tenant
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id])
|
|
phone String?
|
|
/// PostgreSQL array of skill tags (e.g., ["fiber", "wireless", "installation"])
|
|
skills String[]
|
|
/// Zone the technician is primarily assigned to (optional)
|
|
zoneId String?
|
|
zone Zone? @relation(fields: [zoneId], references: [id])
|
|
compensationModel CompensationModel @default(PER_JOB)
|
|
/// Fixed monthly salary component (used for SALARY and HYBRID models)
|
|
monthlySalary Decimal? @db.Decimal(10, 2)
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// One profile per user per tenant
|
|
@@unique([tenantId, userId])
|
|
/// RLS-ready index — always present on tenant-scoped models
|
|
@@index([tenantId])
|
|
}
|
|
|
|
/// A JobTypeRate defines the per-job bonus rate for a specific job type at tenant level.
|
|
/// Used to calculate per-job compensation for technicians (PER_JOB and HYBRID models).
|
|
/// Missing rates default to 0 bonus (not an error).
|
|
model JobTypeRate {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
/// The job type identifier (e.g., "Installation", "Repair", "Maintenance")
|
|
jobType String
|
|
/// Per-job bonus rate for this job type
|
|
rate Decimal @db.Decimal(10, 2)
|
|
description String?
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// One rate per job type per tenant
|
|
@@unique([tenantId, jobType])
|
|
/// RLS-ready index — always present on tenant-scoped models
|
|
@@index([tenantId])
|
|
}
|
|
|
|
/// An InventoryItem represents a piece of ISP equipment (router, ONU, cable, etc.).
|
|
/// Serialized items have unique serial numbers and are tracked individually.
|
|
/// Batch items (consumables like cables, connectors) are tracked by type+quantity.
|
|
/// Stock levels are NEVER stored — always derived from movement history.
|
|
model InventoryItem {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
/// Human-readable name (e.g., "Huawei HG8145V5 ONU")
|
|
name String
|
|
/// Category type (e.g., "Router", "ONU", "Cable", "Connector")
|
|
itemType String
|
|
/// Brand/model identifier for serialized items (optional for batch)
|
|
model String?
|
|
/// Unique serial number — required for SERIALIZED, null for BATCH
|
|
serialNumber String?
|
|
trackingType ItemTrackingType
|
|
/// Purchase cost per unit (2 decimal places)
|
|
purchaseCost Decimal? @db.Decimal(10, 2)
|
|
purchaseDate DateTime?
|
|
warrantyExpiry DateTime?
|
|
/// Soft-delete: inactive items are not shown in active lists
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
movements StockMovement[]
|
|
|
|
/// Serial numbers must be unique within a tenant (only enforced for non-null values)
|
|
@@unique([tenantId, serialNumber])
|
|
/// RLS-ready index — always present on tenant-scoped models
|
|
@@index([tenantId])
|
|
@@index([tenantId, itemType])
|
|
@@index([tenantId, trackingType])
|
|
}
|
|
|
|
/// A StockMovement is an immutable record of inventory movement.
|
|
/// Stock levels are derived by aggregating movements — no mutable quantity columns.
|
|
/// RECEIVED movements auto-post journal entries (DR 1200 Equipment Inventory, CR 2010 AP).
|
|
model StockMovement {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
/// The inventory item this movement affects
|
|
inventoryItemId String
|
|
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id])
|
|
movementType MovementType
|
|
/// Quantity moved — always 1 for SERIALIZED items, variable for BATCH
|
|
quantity Int @default(1)
|
|
/// Condition of the item at time of movement
|
|
condition ItemCondition?
|
|
/// Source location (null for RECEIVED — items come from external supplier)
|
|
fromLocationType LocationType?
|
|
fromLocationId String?
|
|
/// Destination location (null for DISPOSED — items leave the system)
|
|
toLocationType LocationType?
|
|
toLocationId String?
|
|
notes String?
|
|
/// Journal entry created for RECEIVED movements (DR 1200, CR 2010)
|
|
journalEntryId String?
|
|
/// The user who recorded this movement
|
|
performedById String
|
|
performedBy User @relation("MovementPerformedBy", fields: [performedById], references: [id])
|
|
/// Immutable — no updatedAt. Movements cannot be modified, only new movements added.
|
|
createdAt DateTime @default(now())
|
|
|
|
/// RLS-ready index — always present on tenant-scoped models
|
|
@@index([tenantId])
|
|
@@index([inventoryItemId])
|
|
@@index([tenantId, movementType])
|
|
}
|
|
|
|
/// A Vendor represents an external supplier or service provider.
|
|
/// Vendors are optional on expenses but useful for tracking who was paid.
|
|
model Vendor {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
name String
|
|
contactPerson String?
|
|
phone String?
|
|
email String?
|
|
address String?
|
|
/// Freetext description of what this vendor provides
|
|
servicesProvided String?
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
expenses Expense[]
|
|
|
|
/// Vendor names must be unique within a tenant
|
|
@@unique([tenantId, name])
|
|
/// RLS-ready index — always present on tenant-scoped models
|
|
@@index([tenantId])
|
|
}
|
|
|
|
/// An ExpenseCategory classifies expenses and links them to COA accounts.
|
|
/// Default categories are pre-seeded at tenant creation (isSystemCategory=true).
|
|
/// Admins can add custom categories; system categories cannot be deleted.
|
|
model ExpenseCategory {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
name String
|
|
description String?
|
|
/// Maps to a COA expense account code (e.g., "5040" for bandwidth)
|
|
accountCode String
|
|
/// True for pre-seeded categories — cannot be deleted by admin
|
|
isSystemCategory Boolean @default(false)
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
expenses Expense[]
|
|
|
|
/// Category names must be unique within a tenant
|
|
@@unique([tenantId, name])
|
|
/// RLS-ready index — always present on tenant-scoped models
|
|
@@index([tenantId])
|
|
}
|
|
|
|
/// An Expense tracks money spent by the ISP business.
|
|
/// Follows an optional approval workflow: DRAFT -> APPROVED -> POSTED (when enabled).
|
|
/// When approval is disabled, expenses go directly from DRAFT to POSTED.
|
|
/// Every POSTED expense creates a balanced journal entry (DR expense account, CR cash/bank).
|
|
model Expense {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
/// Auto-generated sequential identifier per tenant (e.g., "EXP-0001")
|
|
expenseNumber String
|
|
categoryId String
|
|
category ExpenseCategory @relation(fields: [categoryId], references: [id])
|
|
vendorId String?
|
|
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
|
/// Total expense amount
|
|
amount Decimal @db.Decimal(10, 2)
|
|
/// When the expense occurred (economic date)
|
|
expenseDate DateTime
|
|
description String
|
|
paymentMethod ExpensePaymentMethod
|
|
status ExpenseStatus @default(DRAFT)
|
|
/// File path for receipt image/PDF
|
|
attachmentPath String?
|
|
/// Journal entry created when expense is posted (DR expense account, CR cash/bank)
|
|
journalEntryId String?
|
|
/// The user who recorded this expense
|
|
createdById String
|
|
createdBy User @relation("ExpenseCreatedBy", fields: [createdById], references: [id])
|
|
/// The user who approved this expense (if approval workflow enabled)
|
|
approvedById String?
|
|
approvedBy User? @relation("ExpenseApprovedBy", fields: [approvedById], references: [id])
|
|
approvedAt DateTime?
|
|
postedAt DateTime?
|
|
voidedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// Expense numbers must be unique within a tenant
|
|
@@unique([tenantId, expenseNumber])
|
|
/// RLS-ready index — always present on tenant-scoped models
|
|
@@index([tenantId])
|
|
@@index([tenantId, categoryId])
|
|
@@index([tenantId, vendorId])
|
|
@@index([tenantId, status])
|
|
}
|