Files
cross-financial-management/prisma/schema.prisma
T
Joshua OosthuizenandClaude Sonnet 5 b137176c40 Add Stock catalog (items/warehouses) and fix session persistence
Stock is scoped to a catalog + cost centers only, per direction that
physical GRV/stock-take/recipe transactions belong in an external
system (Matrix), not this accounting app. Stock Control/GRN/Variance/
PPV GL accounts are now plain postable accounts rather than locked
control accounts.

Also fixes session loss on page refresh: auth stores now persist to
localStorage, and ProtectedRoute waits for rehydration before deciding
to redirect to login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWqiUSojMLUoN7mrJqdEbN
2026-09-04 10:35:04 +02:00

726 lines
26 KiB
Plaintext

// Phase 1: Foundation — auth, company/fiscal setup, segmented Chart of Accounts, GL posting engine, journals.
// AR / AP / Tax / Stock models are added in later phases per the implementation plan.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ---------------------------------------------------------------------------
// Company / fiscal setup
// ---------------------------------------------------------------------------
model CompanySettings {
id Int @id @default(1)
companyName String
baseCurrencyCode String @default("ZAR")
fiscalYearStartMonth Int @default(1)
currentFiscalYear Int
accountCodeSeparator String @default("-")
// Login now happens at the central Account Manager, which hands this deployment a signed
// token instead of a password (see src/lib/handoffVerify.ts). If false (the default), a
// handoff for an email with no matching User here is rejected rather than auto-creating one.
autoProvisionOnHandoff Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Records each handoff token's jti once used, so a captured token can't be replayed.
model HandoffTokenUse {
id Int @id @default(autoincrement())
jti String @unique
usedAt DateTime @default(now())
}
enum FiscalYearStatus {
OPEN
LOCKED
CLOSED
}
enum PeriodStatus {
OPEN
CLOSED
}
model FiscalYear {
id Int @id @default(autoincrement())
year Int @unique
startDate DateTime
endDate DateTime
status FiscalYearStatus @default(OPEN)
periods FiscalPeriod[]
}
model FiscalPeriod {
id Int @id @default(autoincrement())
fiscalYearId Int
fiscalYear FiscalYear @relation(fields: [fiscalYearId], references: [id])
periodNumber Int
startDate DateTime
endDate DateTime
status PeriodStatus @default(OPEN)
journalEntries JournalEntry[]
@@unique([fiscalYearId, periodNumber])
}
// ---------------------------------------------------------------------------
// Segmented Chart of Accounts (Sage 300-style account segments)
// ---------------------------------------------------------------------------
enum SegmentFormat {
NUMERIC
ALPHA
ALPHANUMERIC
}
model AccountSegmentDefinition {
id Int @id @default(autoincrement())
segmentKey String @unique
label String
order Int
length Int
format SegmentFormat @default(NUMERIC)
isBranchSegment Boolean @default(false)
isActive Boolean @default(true)
values AccountSegmentValue[]
glAccountLinks GLAccountSegmentValue[]
@@index([order])
}
model AccountSegmentValue {
id Int @id @default(autoincrement())
segmentDefId Int
segmentDef AccountSegmentDefinition @relation(fields: [segmentDefId], references: [id])
code String
description String
isActive Boolean @default(true)
branchProfile BranchProfile?
glAccountLinks GLAccountSegmentValue[]
customers Customer[]
suppliers Supplier[]
warehouses Warehouse[]
@@unique([segmentDefId, code])
}
model BranchProfile {
id Int @id @default(autoincrement())
segmentValueId Int @unique
segmentValue AccountSegmentValue @relation(fields: [segmentValueId], references: [id])
address String?
contactName String?
isConsolidatable Boolean @default(true)
}
enum GLAccountType {
ASSET
LIABILITY
EQUITY
INCOME
EXPENSE
}
enum DebitCredit {
DEBIT
CREDIT
}
enum ControlAccountType {
AR
AP
TAX_OUTPUT
TAX_INPUT
BANK
RETAINED_EARNINGS
}
model GLAccount {
id Int @id @default(autoincrement())
accountCode String @unique
accountName String
accountType GLAccountType
normalBalance DebitCredit
isControlAccount Boolean @default(false)
controlType ControlAccountType?
isActive Boolean @default(true)
isPostable Boolean @default(true)
segmentValues GLAccountSegmentValue[]
journalLines JournalEntryLine[]
arInvoiceLines ARInvoiceLine[] @relation("ARInvoiceLineRevenue")
arCreditNoteLines ARCreditNoteLine[] @relation("ARCreditNoteLineRevenue")
arReceipts ARReceipt[] @relation("ARReceiptBank")
apInvoiceLines APInvoiceLine[] @relation("APInvoiceLineExpense")
apDebitNoteLines APDebitNoteLine[] @relation("APDebitNoteLineExpense")
apPayments APPayment[] @relation("APPaymentBank")
createdAt DateTime @default(now())
@@index([accountType])
}
model GLAccountSegmentValue {
id Int @id @default(autoincrement())
glAccountId Int
glAccount GLAccount @relation(fields: [glAccountId], references: [id])
segmentDefId Int
segmentDef AccountSegmentDefinition @relation(fields: [segmentDefId], references: [id])
segmentValueId Int
segmentValue AccountSegmentValue @relation(fields: [segmentValueId], references: [id])
@@unique([glAccountId, segmentDefId])
}
// ---------------------------------------------------------------------------
// Journals / General Ledger posting core
// ---------------------------------------------------------------------------
enum JournalType {
GENERAL
SALES
PURCHASES
CASH_RECEIPT
CASH_PAYMENT
STOCK
BANK
CUSTOM
}
model Journal {
id Int @id @default(autoincrement())
code String @unique
name String
journalType JournalType
defaultBranchSegmentValueId Int?
isActive Boolean @default(true)
entries JournalEntry[]
}
enum SourceModule {
GL
AR
AP
STOCK
SYSTEM
}
enum JournalEntryStatus {
DRAFT
POSTED
REVERSED
}
model JournalEntry {
id Int @id @default(autoincrement())
entryNumber String @unique
journalId Int
journal Journal @relation(fields: [journalId], references: [id])
fiscalPeriodId Int
fiscalPeriod FiscalPeriod @relation(fields: [fiscalPeriodId], references: [id])
entryDate DateTime
description String
sourceModule SourceModule
sourceType String?
sourceId Int?
status JournalEntryStatus @default(POSTED)
reversalOfId Int? @unique
reversalOf JournalEntry? @relation("Reversal", fields: [reversalOfId], references: [id])
reversedBy JournalEntry? @relation("Reversal")
totalDebits Decimal @db.Decimal(18, 2)
totalCredits Decimal @db.Decimal(18, 2)
createdById Int
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
lines JournalEntryLine[]
@@index([fiscalPeriodId])
@@index([sourceModule, sourceType, sourceId])
}
model JournalEntryLine {
id Int @id @default(autoincrement())
journalEntryId Int
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id])
lineNumber Int
glAccountId Int
glAccount GLAccount @relation(fields: [glAccountId], references: [id])
debit Decimal @default(0) @db.Decimal(18, 2)
credit Decimal @default(0) @db.Decimal(18, 2)
description String?
branchSegmentValueId Int?
// Sub-ledger cross-references for drill-down.
arInvoiceId Int?
arInvoice ARInvoice? @relation(fields: [arInvoiceId], references: [id])
apInvoiceId Int?
apInvoice APInvoice? @relation(fields: [apInvoiceId], references: [id])
currencyCode String @default("BASE")
exchangeRateToBase Decimal @default(1) @db.Decimal(18, 6)
@@index([journalEntryId])
@@index([glAccountId])
}
// ---------------------------------------------------------------------------
// Document numbering
// ---------------------------------------------------------------------------
// One row per document-number series (e.g. "JE-2026", "ARINV-2026"). Numbers are
// issued via an atomic increment inside the same transaction as the document create,
// so they stay gap-free/race-free under concurrent posting.
model Sequence {
id Int @id @default(autoincrement())
key String @unique
lastValue Int @default(0)
}
// ---------------------------------------------------------------------------
// Accounts Receivable
// ---------------------------------------------------------------------------
model TaxCode {
id Int @id @default(autoincrement())
code String @unique
name String
ratePercent Decimal @db.Decimal(6, 3)
isActive Boolean @default(true)
arInvoiceLines ARInvoiceLine[]
arCreditNoteLines ARCreditNoteLine[]
apInvoiceLines APInvoiceLine[]
apDebitNoteLines APDebitNoteLine[]
}
model Customer {
id Int @id @default(autoincrement())
code String @unique
name String
email String?
phone String?
address String?
taxNumber String?
branchSegmentValueId Int
branchSegmentValue AccountSegmentValue @relation(fields: [branchSegmentValueId], references: [id])
creditLimit Decimal @default(0) @db.Decimal(18, 2)
paymentTermsDays Int @default(30)
isActive Boolean @default(true)
invoices ARInvoice[]
creditNotes ARCreditNote[]
receipts ARReceipt[]
createdAt DateTime @default(now())
@@index([branchSegmentValueId])
}
enum ARDocumentStatus {
DRAFT
POSTED
VOID
}
model ARInvoice {
id Int @id @default(autoincrement())
invoiceNumber String @unique
customerId Int
customer Customer @relation(fields: [customerId], references: [id])
branchSegmentValueId Int
invoiceDate DateTime
dueDate DateTime
reference String?
status ARDocumentStatus @default(DRAFT)
subtotal Decimal @db.Decimal(18, 2)
taxTotal Decimal @db.Decimal(18, 2)
total Decimal @db.Decimal(18, 2)
balance Decimal @db.Decimal(18, 2)
journalEntryId Int?
lines ARInvoiceLine[]
allocations ARAllocation[]
journalLines JournalEntryLine[]
createdById Int
createdBy User @relation("ARInvoiceCreatedBy", fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([customerId])
@@index([branchSegmentValueId])
@@index([status])
}
model ARInvoiceLine {
id Int @id @default(autoincrement())
invoiceId Int
invoice ARInvoice @relation(fields: [invoiceId], references: [id])
lineNumber Int
description String
quantity Decimal @default(1) @db.Decimal(18, 4)
unitPrice Decimal @db.Decimal(18, 4)
revenueGLAccountId Int
revenueGLAccount GLAccount @relation("ARInvoiceLineRevenue", fields: [revenueGLAccountId], references: [id])
taxCodeId Int?
taxCode TaxCode? @relation(fields: [taxCodeId], references: [id])
lineSubtotal Decimal @db.Decimal(18, 2)
lineTax Decimal @db.Decimal(18, 2)
lineTotal Decimal @db.Decimal(18, 2)
@@index([invoiceId])
}
model ARCreditNote {
id Int @id @default(autoincrement())
creditNoteNumber String @unique
customerId Int
customer Customer @relation(fields: [customerId], references: [id])
branchSegmentValueId Int
creditNoteDate DateTime
reference String?
reason String?
status ARDocumentStatus @default(DRAFT)
subtotal Decimal @db.Decimal(18, 2)
taxTotal Decimal @db.Decimal(18, 2)
total Decimal @db.Decimal(18, 2)
balance Decimal @db.Decimal(18, 2)
journalEntryId Int?
lines ARCreditNoteLine[]
allocations ARAllocation[]
createdById Int
createdBy User @relation("ARCreditNoteCreatedBy", fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([customerId])
@@index([branchSegmentValueId])
@@index([status])
}
model ARCreditNoteLine {
id Int @id @default(autoincrement())
creditNoteId Int
creditNote ARCreditNote @relation(fields: [creditNoteId], references: [id])
lineNumber Int
description String
quantity Decimal @default(1) @db.Decimal(18, 4)
unitPrice Decimal @db.Decimal(18, 4)
revenueGLAccountId Int
revenueGLAccount GLAccount @relation("ARCreditNoteLineRevenue", fields: [revenueGLAccountId], references: [id])
taxCodeId Int?
taxCode TaxCode? @relation(fields: [taxCodeId], references: [id])
lineSubtotal Decimal @db.Decimal(18, 2)
lineTax Decimal @db.Decimal(18, 2)
lineTotal Decimal @db.Decimal(18, 2)
@@index([creditNoteId])
}
model ARReceipt {
id Int @id @default(autoincrement())
receiptNumber String @unique
customerId Int
customer Customer @relation(fields: [customerId], references: [id])
branchSegmentValueId Int
receiptDate DateTime
amount Decimal @db.Decimal(18, 2)
unallocatedAmount Decimal @db.Decimal(18, 2)
bankGLAccountId Int
bankGLAccount GLAccount @relation("ARReceiptBank", fields: [bankGLAccountId], references: [id])
reference String?
status ARDocumentStatus @default(DRAFT)
journalEntryId Int?
allocations ARAllocation[]
createdById Int
createdBy User @relation("ARReceiptCreatedBy", fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([customerId])
@@index([branchSegmentValueId])
@@index([status])
}
model ARAllocation {
id Int @id @default(autoincrement())
invoiceId Int
invoice ARInvoice @relation(fields: [invoiceId], references: [id])
receiptId Int?
receipt ARReceipt? @relation(fields: [receiptId], references: [id])
creditNoteId Int?
creditNote ARCreditNote? @relation(fields: [creditNoteId], references: [id])
amount Decimal @db.Decimal(18, 2)
allocatedAt DateTime @default(now())
@@index([invoiceId])
@@index([receiptId])
@@index([creditNoteId])
}
// ---------------------------------------------------------------------------
// Accounts Payable
// ---------------------------------------------------------------------------
model Supplier {
id Int @id @default(autoincrement())
code String @unique
name String
email String?
phone String?
address String?
taxNumber String?
branchSegmentValueId Int
branchSegmentValue AccountSegmentValue @relation(fields: [branchSegmentValueId], references: [id])
paymentTermsDays Int @default(30)
isActive Boolean @default(true)
invoices APInvoice[]
debitNotes APDebitNote[]
payments APPayment[]
createdAt DateTime @default(now())
@@index([branchSegmentValueId])
}
enum APDocumentStatus {
DRAFT
POSTED
VOID
}
model APInvoice {
id Int @id @default(autoincrement())
invoiceNumber String @unique
supplierReference String?
supplierId Int
supplier Supplier @relation(fields: [supplierId], references: [id])
branchSegmentValueId Int
invoiceDate DateTime
dueDate DateTime
status APDocumentStatus @default(DRAFT)
subtotal Decimal @db.Decimal(18, 2)
taxTotal Decimal @db.Decimal(18, 2)
total Decimal @db.Decimal(18, 2)
balance Decimal @db.Decimal(18, 2)
journalEntryId Int?
lines APInvoiceLine[]
allocations APAllocation[]
journalLines JournalEntryLine[]
createdById Int
createdBy User @relation("APInvoiceCreatedBy", fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([supplierId])
@@index([branchSegmentValueId])
@@index([status])
}
model APInvoiceLine {
id Int @id @default(autoincrement())
invoiceId Int
invoice APInvoice @relation(fields: [invoiceId], references: [id])
lineNumber Int
description String
quantity Decimal @default(1) @db.Decimal(18, 4)
unitPrice Decimal @db.Decimal(18, 4)
expenseGLAccountId Int
expenseGLAccount GLAccount @relation("APInvoiceLineExpense", fields: [expenseGLAccountId], references: [id])
taxCodeId Int?
taxCode TaxCode? @relation(fields: [taxCodeId], references: [id])
lineSubtotal Decimal @db.Decimal(18, 2)
lineTax Decimal @db.Decimal(18, 2)
lineTotal Decimal @db.Decimal(18, 2)
@@index([invoiceId])
}
model APDebitNote {
id Int @id @default(autoincrement())
debitNoteNumber String @unique
supplierId Int
supplier Supplier @relation(fields: [supplierId], references: [id])
branchSegmentValueId Int
debitNoteDate DateTime
reference String?
reason String?
status APDocumentStatus @default(DRAFT)
subtotal Decimal @db.Decimal(18, 2)
taxTotal Decimal @db.Decimal(18, 2)
total Decimal @db.Decimal(18, 2)
balance Decimal @db.Decimal(18, 2)
journalEntryId Int?
lines APDebitNoteLine[]
allocations APAllocation[]
createdById Int
createdBy User @relation("APDebitNoteCreatedBy", fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([supplierId])
@@index([branchSegmentValueId])
@@index([status])
}
model APDebitNoteLine {
id Int @id @default(autoincrement())
debitNoteId Int
debitNote APDebitNote @relation(fields: [debitNoteId], references: [id])
lineNumber Int
description String
quantity Decimal @default(1) @db.Decimal(18, 4)
unitPrice Decimal @db.Decimal(18, 4)
expenseGLAccountId Int
expenseGLAccount GLAccount @relation("APDebitNoteLineExpense", fields: [expenseGLAccountId], references: [id])
taxCodeId Int?
taxCode TaxCode? @relation(fields: [taxCodeId], references: [id])
lineSubtotal Decimal @db.Decimal(18, 2)
lineTax Decimal @db.Decimal(18, 2)
lineTotal Decimal @db.Decimal(18, 2)
@@index([debitNoteId])
}
model APPayment {
id Int @id @default(autoincrement())
paymentNumber String @unique
supplierId Int
supplier Supplier @relation(fields: [supplierId], references: [id])
branchSegmentValueId Int
paymentDate DateTime
amount Decimal @db.Decimal(18, 2)
unallocatedAmount Decimal @db.Decimal(18, 2)
bankGLAccountId Int
bankGLAccount GLAccount @relation("APPaymentBank", fields: [bankGLAccountId], references: [id])
reference String?
status APDocumentStatus @default(DRAFT)
journalEntryId Int?
allocations APAllocation[]
createdById Int
createdBy User @relation("APPaymentCreatedBy", fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([supplierId])
@@index([branchSegmentValueId])
@@index([status])
}
model APAllocation {
id Int @id @default(autoincrement())
invoiceId Int
invoice APInvoice @relation(fields: [invoiceId], references: [id])
paymentId Int?
payment APPayment? @relation(fields: [paymentId], references: [id])
debitNoteId Int?
debitNote APDebitNote? @relation(fields: [debitNoteId], references: [id])
amount Decimal @db.Decimal(18, 2)
allocatedAt DateTime @default(now())
@@index([invoiceId])
@@index([paymentId])
@@index([debitNoteId])
}
// ---------------------------------------------------------------------------
// Stock (catalog + cost centers only — see the comment on Warehouse below)
// ---------------------------------------------------------------------------
// Deliberately minimal: this is accounting software, not a stock/warehouse system. Physical
// stock transactions (goods received, stock takes, recipe production) belong in Matrix (the
// external hospitality/retail stock system this app is designed to eventually integrate with)
// — this app only needs a catalog of items, a set of cost centers to view SOH by, and a place
// for whatever quantities Matrix eventually reports. No costing engine, no movement ledger, no
// document workflow live here.
model Warehouse {
id Int @id @default(autoincrement())
code String @unique
name String
branchSegmentValueId Int
branchSegmentValue AccountSegmentValue @relation(fields: [branchSegmentValueId], references: [id])
isActive Boolean @default(true)
stockLevels StockLevel[]
@@index([branchSegmentValueId])
}
model StockItem {
id Int @id @default(autoincrement())
code String @unique
name String
unitOfMeasure String
isActive Boolean @default(true)
stockLevels StockLevel[]
createdAt DateTime @default(now())
}
// Quantity on hand per item/warehouse — a read target for a future Matrix feed, not written to
// from any UI in this app today.
model StockLevel {
id Int @id @default(autoincrement())
itemId Int
item StockItem @relation(fields: [itemId], references: [id])
warehouseId Int
warehouse Warehouse @relation(fields: [warehouseId], references: [id])
quantityOnHand Decimal @default(0) @db.Decimal(18, 4)
updatedAt DateTime @updatedAt
@@unique([itemId, warehouseId])
}
// ---------------------------------------------------------------------------
// Auth / RBAC
// ---------------------------------------------------------------------------
model User {
id Int @id @default(autoincrement())
email String @unique
// Nullable: login happens at the central Account Manager now, not here (see
// src/modules/auth/auth.service.ts verifyHandoff()). Kept, not dropped, in case a fallback
// local-login path is ever needed again.
passwordHash String?
fullName String
isActive Boolean @default(true)
roles UserRole[]
branchRestrictions UserBranchRestriction[]
journalEntries JournalEntry[]
arInvoices ARInvoice[] @relation("ARInvoiceCreatedBy")
arCreditNotes ARCreditNote[] @relation("ARCreditNoteCreatedBy")
arReceipts ARReceipt[] @relation("ARReceiptCreatedBy")
apInvoices APInvoice[] @relation("APInvoiceCreatedBy")
apDebitNotes APDebitNote[] @relation("APDebitNoteCreatedBy")
apPayments APPayment[] @relation("APPaymentCreatedBy")
createdAt DateTime @default(now())
}
model Role {
id Int @id @default(autoincrement())
name String @unique
permissions RolePermission[]
users UserRole[]
}
model Permission {
id Int @id @default(autoincrement())
key String @unique
roles RolePermission[]
}
model RolePermission {
roleId Int
role Role @relation(fields: [roleId], references: [id])
permissionId Int
permission Permission @relation(fields: [permissionId], references: [id])
@@id([roleId, permissionId])
}
model UserRole {
userId Int
user User @relation(fields: [userId], references: [id])
roleId Int
role Role @relation(fields: [roleId], references: [id])
@@id([userId, roleId])
}
model UserBranchRestriction {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id])
branchSegmentValueId Int
@@unique([userId, branchSegmentValueId])
}