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