Initial commit: multi-company financial management system

Root company app (src/, client/) — GL foundation, AR, and AP:
- Segmented Chart of Accounts (Sage 300-style), double-entry PostingService with
  three-layer balance enforcement (app validation, CHECK constraints, deferred
  DB trigger), gap-free document numbering, RBAC with branch restrictions.
- Accounts Receivable: Customers, Invoices, Credit Notes, Receipts with
  allocation, all posting through PostingService.
- Accounts Payable: Suppliers, Supplier Invoices, Debit Notes, Payments with
  allocation, mirroring AR's posting pattern.
- Tax Codes shared between AR and AP.
- Company-app login is a token handoff from the Account Manager (RS256,
  single-use, company-scoped) rather than a local password.
- Frontend reskinned to the approved mockup: navy sidebar, dashboard, tree-view
  Chart of Accounts, Customer/Supplier 360 pages.

Account Manager (manager/, manager/client/) — central login gateway:
- Password + TOTP 2FA + Google/Microsoft OAuth, company picker, and the RS256
  handoff token issuance that the company app verifies.

Covered by unit + integration tests against a real Postgres database (GL
balance invariants, AR/AP posting correctness and allocation limits, the full
handoff exchange including replay/cross-company/expiry/forgery rejection).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tq2DbEvzHzs13jDFhLaX44
This commit is contained in:
Joshua Oosthuizen
2026-09-03 13:27:19 +02:00
co-authored by Claude Sonnet 5
commit 4acbc03b35
279 changed files with 38537 additions and 0 deletions
@@ -0,0 +1,53 @@
-- Layer 2 + Layer 3 of the double-entry enforcement design (see the implementation plan,
-- section "Double-Entry Enforcement"). Layer 1 is PostingService.postEntry() in
-- src/modules/gl/posting.service.ts, which is the primary guard. These are the DB-level
-- backstops that make an unbalanced journal impossible to persist even via a bypass of
-- that service (raw SQL, a future bug, a manual DB edit).
--
-- Not expressible in Prisma's schema.prisma DSL, so this is applied as hand-written SQL
-- inside an otherwise-empty Prisma migration. See SETUP.md for how to wire this in.
-- Layer 2: row-level guards on JournalEntryLine.
ALTER TABLE "JournalEntryLine"
ADD CONSTRAINT chk_line_nonnegative
CHECK (debit >= 0 AND credit >= 0);
ALTER TABLE "JournalEntryLine"
ADD CONSTRAINT chk_line_debit_xor_credit
CHECK ((debit > 0 AND credit = 0) OR (credit > 0 AND debit = 0));
-- Layer 3: deferred trigger that re-sums debits/credits per journal_entry_id at commit
-- and rejects the transaction if they don't match. This is the real hard guarantee.
CREATE OR REPLACE FUNCTION trg_check_journal_entry_balance() RETURNS trigger AS $$
DECLARE
affected_entry_id INTEGER;
total_debit NUMERIC(18,2);
total_credit NUMERIC(18,2);
BEGIN
affected_entry_id := COALESCE(NEW."journalEntryId", OLD."journalEntryId");
SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0)
INTO total_debit, total_credit
FROM "JournalEntryLine"
WHERE "journalEntryId" = affected_entry_id;
IF total_debit <> total_credit THEN
RAISE EXCEPTION 'JournalEntry % does not balance: debits % != credits %',
affected_entry_id, total_debit, total_credit;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER journal_entry_line_balance_check
AFTER INSERT OR UPDATE OR DELETE ON "JournalEntryLine"
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION trg_check_journal_entry_balance();
-- Extra guard: at most one AccountSegmentDefinition may be flagged as the branch segment.
-- Prisma's schema DSL can't express a partial unique index, so it's added here.
CREATE UNIQUE INDEX one_branch_segment_only
ON "AccountSegmentDefinition" (("isBranchSegment"))
WHERE "isBranchSegment" = true;
@@ -0,0 +1,353 @@
-- CreateEnum
CREATE TYPE "FiscalYearStatus" AS ENUM ('OPEN', 'LOCKED', 'CLOSED');
-- CreateEnum
CREATE TYPE "PeriodStatus" AS ENUM ('OPEN', 'CLOSED');
-- CreateEnum
CREATE TYPE "SegmentFormat" AS ENUM ('NUMERIC', 'ALPHA', 'ALPHANUMERIC');
-- CreateEnum
CREATE TYPE "GLAccountType" AS ENUM ('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE');
-- CreateEnum
CREATE TYPE "DebitCredit" AS ENUM ('DEBIT', 'CREDIT');
-- CreateEnum
CREATE TYPE "ControlAccountType" AS ENUM ('AR', 'AP', 'STOCK', 'TAX', 'BANK', 'RETAINED_EARNINGS');
-- CreateEnum
CREATE TYPE "JournalType" AS ENUM ('GENERAL', 'SALES', 'PURCHASES', 'CASH_RECEIPT', 'CASH_PAYMENT', 'STOCK', 'BANK', 'CUSTOM');
-- CreateEnum
CREATE TYPE "SourceModule" AS ENUM ('GL', 'AR', 'AP', 'STOCK', 'SYSTEM');
-- CreateEnum
CREATE TYPE "JournalEntryStatus" AS ENUM ('DRAFT', 'POSTED', 'REVERSED');
-- CreateTable
CREATE TABLE "CompanySettings" (
"id" INTEGER NOT NULL DEFAULT 1,
"companyName" TEXT NOT NULL,
"baseCurrencyCode" TEXT NOT NULL DEFAULT 'ZAR',
"fiscalYearStartMonth" INTEGER NOT NULL DEFAULT 1,
"currentFiscalYear" INTEGER NOT NULL,
"accountCodeSeparator" TEXT NOT NULL DEFAULT '-',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CompanySettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FiscalYear" (
"id" SERIAL NOT NULL,
"year" INTEGER NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"status" "FiscalYearStatus" NOT NULL DEFAULT 'OPEN',
CONSTRAINT "FiscalYear_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FiscalPeriod" (
"id" SERIAL NOT NULL,
"fiscalYearId" INTEGER NOT NULL,
"periodNumber" INTEGER NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"status" "PeriodStatus" NOT NULL DEFAULT 'OPEN',
CONSTRAINT "FiscalPeriod_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountSegmentDefinition" (
"id" SERIAL NOT NULL,
"segmentKey" TEXT NOT NULL,
"label" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"length" INTEGER NOT NULL,
"format" "SegmentFormat" NOT NULL DEFAULT 'NUMERIC',
"isBranchSegment" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "AccountSegmentDefinition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountSegmentValue" (
"id" SERIAL NOT NULL,
"segmentDefId" INTEGER NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "AccountSegmentValue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BranchProfile" (
"id" SERIAL NOT NULL,
"segmentValueId" INTEGER NOT NULL,
"address" TEXT,
"contactName" TEXT,
"isConsolidatable" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "BranchProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GLAccount" (
"id" SERIAL NOT NULL,
"accountCode" TEXT NOT NULL,
"accountName" TEXT NOT NULL,
"accountType" "GLAccountType" NOT NULL,
"normalBalance" "DebitCredit" NOT NULL,
"isControlAccount" BOOLEAN NOT NULL DEFAULT false,
"controlType" "ControlAccountType",
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isPostable" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GLAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GLAccountSegmentValue" (
"id" SERIAL NOT NULL,
"glAccountId" INTEGER NOT NULL,
"segmentDefId" INTEGER NOT NULL,
"segmentValueId" INTEGER NOT NULL,
CONSTRAINT "GLAccountSegmentValue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Journal" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"journalType" "JournalType" NOT NULL,
"defaultBranchSegmentValueId" INTEGER,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Journal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JournalEntry" (
"id" SERIAL NOT NULL,
"entryNumber" TEXT NOT NULL,
"journalId" INTEGER NOT NULL,
"fiscalPeriodId" INTEGER NOT NULL,
"entryDate" TIMESTAMP(3) NOT NULL,
"description" TEXT NOT NULL,
"sourceModule" "SourceModule" NOT NULL,
"sourceType" TEXT,
"sourceId" INTEGER,
"status" "JournalEntryStatus" NOT NULL DEFAULT 'POSTED',
"reversalOfId" INTEGER,
"totalDebits" DECIMAL(18,2) NOT NULL,
"totalCredits" DECIMAL(18,2) NOT NULL,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "JournalEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JournalEntryLine" (
"id" SERIAL NOT NULL,
"journalEntryId" INTEGER NOT NULL,
"lineNumber" INTEGER NOT NULL,
"glAccountId" INTEGER NOT NULL,
"debit" DECIMAL(18,2) NOT NULL DEFAULT 0,
"credit" DECIMAL(18,2) NOT NULL DEFAULT 0,
"description" TEXT,
"branchSegmentValueId" INTEGER,
"arInvoiceId" INTEGER,
"apInvoiceId" INTEGER,
"stockMovementId" INTEGER,
"currencyCode" TEXT NOT NULL DEFAULT 'BASE',
"exchangeRateToBase" DECIMAL(18,6) NOT NULL DEFAULT 1,
CONSTRAINT "JournalEntryLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Sequence" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"lastValue" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Sequence_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"roleId" INTEGER NOT NULL,
"permissionId" INTEGER NOT NULL,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId","permissionId")
);
-- CreateTable
CREATE TABLE "UserRole" (
"userId" INTEGER NOT NULL,
"roleId" INTEGER NOT NULL,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("userId","roleId")
);
-- CreateTable
CREATE TABLE "UserBranchRestriction" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
CONSTRAINT "UserBranchRestriction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FiscalYear_year_key" ON "FiscalYear"("year");
-- CreateIndex
CREATE UNIQUE INDEX "FiscalPeriod_fiscalYearId_periodNumber_key" ON "FiscalPeriod"("fiscalYearId", "periodNumber");
-- CreateIndex
CREATE UNIQUE INDEX "AccountSegmentDefinition_segmentKey_key" ON "AccountSegmentDefinition"("segmentKey");
-- CreateIndex
CREATE INDEX "AccountSegmentDefinition_order_idx" ON "AccountSegmentDefinition"("order");
-- CreateIndex
CREATE UNIQUE INDEX "AccountSegmentValue_segmentDefId_code_key" ON "AccountSegmentValue"("segmentDefId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "BranchProfile_segmentValueId_key" ON "BranchProfile"("segmentValueId");
-- CreateIndex
CREATE UNIQUE INDEX "GLAccount_accountCode_key" ON "GLAccount"("accountCode");
-- CreateIndex
CREATE INDEX "GLAccount_accountType_idx" ON "GLAccount"("accountType");
-- CreateIndex
CREATE UNIQUE INDEX "GLAccountSegmentValue_glAccountId_segmentDefId_key" ON "GLAccountSegmentValue"("glAccountId", "segmentDefId");
-- CreateIndex
CREATE UNIQUE INDEX "Journal_code_key" ON "Journal"("code");
-- CreateIndex
CREATE UNIQUE INDEX "JournalEntry_entryNumber_key" ON "JournalEntry"("entryNumber");
-- CreateIndex
CREATE UNIQUE INDEX "JournalEntry_reversalOfId_key" ON "JournalEntry"("reversalOfId");
-- CreateIndex
CREATE INDEX "JournalEntry_fiscalPeriodId_idx" ON "JournalEntry"("fiscalPeriodId");
-- CreateIndex
CREATE INDEX "JournalEntry_sourceModule_sourceType_sourceId_idx" ON "JournalEntry"("sourceModule", "sourceType", "sourceId");
-- CreateIndex
CREATE INDEX "JournalEntryLine_journalEntryId_idx" ON "JournalEntryLine"("journalEntryId");
-- CreateIndex
CREATE INDEX "JournalEntryLine_glAccountId_idx" ON "JournalEntryLine"("glAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Sequence_key_key" ON "Sequence"("key");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_key_key" ON "Permission"("key");
-- CreateIndex
CREATE UNIQUE INDEX "UserBranchRestriction_userId_branchSegmentValueId_key" ON "UserBranchRestriction"("userId", "branchSegmentValueId");
-- AddForeignKey
ALTER TABLE "FiscalPeriod" ADD CONSTRAINT "FiscalPeriod_fiscalYearId_fkey" FOREIGN KEY ("fiscalYearId") REFERENCES "FiscalYear"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountSegmentValue" ADD CONSTRAINT "AccountSegmentValue_segmentDefId_fkey" FOREIGN KEY ("segmentDefId") REFERENCES "AccountSegmentDefinition"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BranchProfile" ADD CONSTRAINT "BranchProfile_segmentValueId_fkey" FOREIGN KEY ("segmentValueId") REFERENCES "AccountSegmentValue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GLAccountSegmentValue" ADD CONSTRAINT "GLAccountSegmentValue_glAccountId_fkey" FOREIGN KEY ("glAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GLAccountSegmentValue" ADD CONSTRAINT "GLAccountSegmentValue_segmentDefId_fkey" FOREIGN KEY ("segmentDefId") REFERENCES "AccountSegmentDefinition"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GLAccountSegmentValue" ADD CONSTRAINT "GLAccountSegmentValue_segmentValueId_fkey" FOREIGN KEY ("segmentValueId") REFERENCES "AccountSegmentValue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntry" ADD CONSTRAINT "JournalEntry_journalId_fkey" FOREIGN KEY ("journalId") REFERENCES "Journal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntry" ADD CONSTRAINT "JournalEntry_fiscalPeriodId_fkey" FOREIGN KEY ("fiscalPeriodId") REFERENCES "FiscalPeriod"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntry" ADD CONSTRAINT "JournalEntry_reversalOfId_fkey" FOREIGN KEY ("reversalOfId") REFERENCES "JournalEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntry" ADD CONSTRAINT "JournalEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntryLine" ADD CONSTRAINT "JournalEntryLine_journalEntryId_fkey" FOREIGN KEY ("journalEntryId") REFERENCES "JournalEntry"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalEntryLine" ADD CONSTRAINT "JournalEntryLine_glAccountId_fkey" FOREIGN KEY ("glAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserBranchRestriction" ADD CONSTRAINT "UserBranchRestriction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,54 @@
-- Layer 2 + Layer 3 of the double-entry enforcement design (see the implementation plan,
-- section "Double-Entry Enforcement"). Layer 1 is PostingService.postEntry() in
-- src/modules/gl/posting.service.ts, which is the primary guard. These are the DB-level
-- backstops that make an unbalanced journal impossible to persist even via a bypass of
-- that service (raw SQL, a future bug, a manual DB edit).
--
-- Not expressible in Prisma's schema.prisma DSL, so this is applied as hand-written SQL
-- inside an otherwise-empty Prisma migration. Source copy kept at
-- prisma/manual-sql/add_journal_balance_trigger.sql for reference.
-- Layer 2: row-level guards on JournalEntryLine.
ALTER TABLE "JournalEntryLine"
ADD CONSTRAINT chk_line_nonnegative
CHECK (debit >= 0 AND credit >= 0);
ALTER TABLE "JournalEntryLine"
ADD CONSTRAINT chk_line_debit_xor_credit
CHECK ((debit > 0 AND credit = 0) OR (credit > 0 AND debit = 0));
-- Layer 3: deferred trigger that re-sums debits/credits per journal_entry_id at commit
-- and rejects the transaction if they don't match. This is the real hard guarantee.
CREATE OR REPLACE FUNCTION trg_check_journal_entry_balance() RETURNS trigger AS $$
DECLARE
affected_entry_id INTEGER;
total_debit NUMERIC(18,2);
total_credit NUMERIC(18,2);
BEGIN
affected_entry_id := COALESCE(NEW."journalEntryId", OLD."journalEntryId");
SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0)
INTO total_debit, total_credit
FROM "JournalEntryLine"
WHERE "journalEntryId" = affected_entry_id;
IF total_debit <> total_credit THEN
RAISE EXCEPTION 'JournalEntry % does not balance: debits % != credits %',
affected_entry_id, total_debit, total_credit;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER journal_entry_line_balance_check
AFTER INSERT OR UPDATE OR DELETE ON "JournalEntryLine"
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION trg_check_journal_entry_balance();
-- Extra guard: at most one AccountSegmentDefinition may be flagged as the branch segment.
-- Prisma's schema DSL can't express a partial unique index, so it's added here.
CREATE UNIQUE INDEX one_branch_segment_only
ON "AccountSegmentDefinition" (("isBranchSegment"))
WHERE "isBranchSegment" = true;
@@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE "CompanySettings" ADD COLUMN "autoProvisionOnHandoff" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "passwordHash" DROP NOT NULL;
-- CreateTable
CREATE TABLE "HandoffTokenUse" (
"id" SERIAL NOT NULL,
"jti" TEXT NOT NULL,
"usedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HandoffTokenUse_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HandoffTokenUse_jti_key" ON "HandoffTokenUse"("jti");
@@ -0,0 +1,271 @@
-- CreateEnum
CREATE TYPE "ARDocumentStatus" AS ENUM ('DRAFT', 'POSTED', 'VOID');
-- AlterEnum
-- Splits the generic TAX control type into TAX_OUTPUT/TAX_INPUT (AR needs Output Tax
-- Payable, AP will need Input Tax Receivable — a single "TAX" value was ambiguous for
-- control-account lookup). Existing rows are remapped by their seeded account code
-- (2100 = Output Tax Payable, 2200 = Input Tax Receivable) rather than dropped blind.
BEGIN;
CREATE TYPE "ControlAccountType_new" AS ENUM ('AR', 'AP', 'STOCK', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS');
ALTER TABLE "GLAccount" ALTER COLUMN "controlType" TYPE "ControlAccountType_new" USING (
CASE
WHEN "controlType"::text = 'TAX' AND "accountCode" LIKE '2100-%' THEN 'TAX_OUTPUT'
WHEN "controlType"::text = 'TAX' AND "accountCode" LIKE '2200-%' THEN 'TAX_INPUT'
ELSE "controlType"::text
END::"ControlAccountType_new"
);
ALTER TYPE "ControlAccountType" RENAME TO "ControlAccountType_old";
ALTER TYPE "ControlAccountType_new" RENAME TO "ControlAccountType";
DROP TYPE "ControlAccountType_old";
COMMIT;
-- CreateTable
CREATE TABLE "TaxCode" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ratePercent" DECIMAL(6,3) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "TaxCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Customer" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"address" TEXT,
"taxNumber" TEXT,
"branchSegmentValueId" INTEGER NOT NULL,
"creditLimit" DECIMAL(18,2) NOT NULL DEFAULT 0,
"paymentTermsDays" INTEGER NOT NULL DEFAULT 30,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ARInvoice" (
"id" SERIAL NOT NULL,
"invoiceNumber" TEXT NOT NULL,
"customerId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"invoiceDate" TIMESTAMP(3) NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"reference" TEXT,
"status" "ARDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"subtotal" DECIMAL(18,2) NOT NULL,
"taxTotal" DECIMAL(18,2) NOT NULL,
"total" DECIMAL(18,2) NOT NULL,
"balance" DECIMAL(18,2) NOT NULL,
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ARInvoice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ARInvoiceLine" (
"id" SERIAL NOT NULL,
"invoiceId" INTEGER NOT NULL,
"lineNumber" INTEGER NOT NULL,
"description" TEXT NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL DEFAULT 1,
"unitPrice" DECIMAL(18,4) NOT NULL,
"revenueGLAccountId" INTEGER NOT NULL,
"taxCodeId" INTEGER,
"lineSubtotal" DECIMAL(18,2) NOT NULL,
"lineTax" DECIMAL(18,2) NOT NULL,
"lineTotal" DECIMAL(18,2) NOT NULL,
CONSTRAINT "ARInvoiceLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ARCreditNote" (
"id" SERIAL NOT NULL,
"creditNoteNumber" TEXT NOT NULL,
"customerId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"creditNoteDate" TIMESTAMP(3) NOT NULL,
"reference" TEXT,
"reason" TEXT,
"status" "ARDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"subtotal" DECIMAL(18,2) NOT NULL,
"taxTotal" DECIMAL(18,2) NOT NULL,
"total" DECIMAL(18,2) NOT NULL,
"balance" DECIMAL(18,2) NOT NULL,
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ARCreditNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ARCreditNoteLine" (
"id" SERIAL NOT NULL,
"creditNoteId" INTEGER NOT NULL,
"lineNumber" INTEGER NOT NULL,
"description" TEXT NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL DEFAULT 1,
"unitPrice" DECIMAL(18,4) NOT NULL,
"revenueGLAccountId" INTEGER NOT NULL,
"taxCodeId" INTEGER,
"lineSubtotal" DECIMAL(18,2) NOT NULL,
"lineTax" DECIMAL(18,2) NOT NULL,
"lineTotal" DECIMAL(18,2) NOT NULL,
CONSTRAINT "ARCreditNoteLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ARReceipt" (
"id" SERIAL NOT NULL,
"receiptNumber" TEXT NOT NULL,
"customerId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"receiptDate" TIMESTAMP(3) NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"unallocatedAmount" DECIMAL(18,2) NOT NULL,
"bankGLAccountId" INTEGER NOT NULL,
"reference" TEXT,
"status" "ARDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ARReceipt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ARAllocation" (
"id" SERIAL NOT NULL,
"invoiceId" INTEGER NOT NULL,
"receiptId" INTEGER,
"creditNoteId" INTEGER,
"amount" DECIMAL(18,2) NOT NULL,
"allocatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ARAllocation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TaxCode_code_key" ON "TaxCode"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Customer_code_key" ON "Customer"("code");
-- CreateIndex
CREATE INDEX "Customer_branchSegmentValueId_idx" ON "Customer"("branchSegmentValueId");
-- CreateIndex
CREATE UNIQUE INDEX "ARInvoice_invoiceNumber_key" ON "ARInvoice"("invoiceNumber");
-- CreateIndex
CREATE INDEX "ARInvoice_customerId_idx" ON "ARInvoice"("customerId");
-- CreateIndex
CREATE INDEX "ARInvoice_branchSegmentValueId_idx" ON "ARInvoice"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "ARInvoice_status_idx" ON "ARInvoice"("status");
-- CreateIndex
CREATE INDEX "ARInvoiceLine_invoiceId_idx" ON "ARInvoiceLine"("invoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "ARCreditNote_creditNoteNumber_key" ON "ARCreditNote"("creditNoteNumber");
-- CreateIndex
CREATE INDEX "ARCreditNote_customerId_idx" ON "ARCreditNote"("customerId");
-- CreateIndex
CREATE INDEX "ARCreditNote_branchSegmentValueId_idx" ON "ARCreditNote"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "ARCreditNote_status_idx" ON "ARCreditNote"("status");
-- CreateIndex
CREATE INDEX "ARCreditNoteLine_creditNoteId_idx" ON "ARCreditNoteLine"("creditNoteId");
-- CreateIndex
CREATE UNIQUE INDEX "ARReceipt_receiptNumber_key" ON "ARReceipt"("receiptNumber");
-- CreateIndex
CREATE INDEX "ARReceipt_customerId_idx" ON "ARReceipt"("customerId");
-- CreateIndex
CREATE INDEX "ARReceipt_branchSegmentValueId_idx" ON "ARReceipt"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "ARReceipt_status_idx" ON "ARReceipt"("status");
-- CreateIndex
CREATE INDEX "ARAllocation_invoiceId_idx" ON "ARAllocation"("invoiceId");
-- CreateIndex
CREATE INDEX "ARAllocation_receiptId_idx" ON "ARAllocation"("receiptId");
-- CreateIndex
CREATE INDEX "ARAllocation_creditNoteId_idx" ON "ARAllocation"("creditNoteId");
-- AddForeignKey
ALTER TABLE "JournalEntryLine" ADD CONSTRAINT "JournalEntryLine_arInvoiceId_fkey" FOREIGN KEY ("arInvoiceId") REFERENCES "ARInvoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_branchSegmentValueId_fkey" FOREIGN KEY ("branchSegmentValueId") REFERENCES "AccountSegmentValue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARInvoice" ADD CONSTRAINT "ARInvoice_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARInvoice" ADD CONSTRAINT "ARInvoice_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARInvoiceLine" ADD CONSTRAINT "ARInvoiceLine_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "ARInvoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARInvoiceLine" ADD CONSTRAINT "ARInvoiceLine_revenueGLAccountId_fkey" FOREIGN KEY ("revenueGLAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARInvoiceLine" ADD CONSTRAINT "ARInvoiceLine_taxCodeId_fkey" FOREIGN KEY ("taxCodeId") REFERENCES "TaxCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARCreditNote" ADD CONSTRAINT "ARCreditNote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARCreditNote" ADD CONSTRAINT "ARCreditNote_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARCreditNoteLine" ADD CONSTRAINT "ARCreditNoteLine_creditNoteId_fkey" FOREIGN KEY ("creditNoteId") REFERENCES "ARCreditNote"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARCreditNoteLine" ADD CONSTRAINT "ARCreditNoteLine_revenueGLAccountId_fkey" FOREIGN KEY ("revenueGLAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARCreditNoteLine" ADD CONSTRAINT "ARCreditNoteLine_taxCodeId_fkey" FOREIGN KEY ("taxCodeId") REFERENCES "TaxCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARReceipt" ADD CONSTRAINT "ARReceipt_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARReceipt" ADD CONSTRAINT "ARReceipt_bankGLAccountId_fkey" FOREIGN KEY ("bankGLAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARReceipt" ADD CONSTRAINT "ARReceipt_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARAllocation" ADD CONSTRAINT "ARAllocation_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "ARInvoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARAllocation" ADD CONSTRAINT "ARAllocation_receiptId_fkey" FOREIGN KEY ("receiptId") REFERENCES "ARReceipt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ARAllocation" ADD CONSTRAINT "ARAllocation_creditNoteId_fkey" FOREIGN KEY ("creditNoteId") REFERENCES "ARCreditNote"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,237 @@
-- CreateEnum
CREATE TYPE "APDocumentStatus" AS ENUM ('DRAFT', 'POSTED', 'VOID');
-- CreateTable
CREATE TABLE "Supplier" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"address" TEXT,
"taxNumber" TEXT,
"branchSegmentValueId" INTEGER NOT NULL,
"paymentTermsDays" INTEGER NOT NULL DEFAULT 30,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Supplier_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "APInvoice" (
"id" SERIAL NOT NULL,
"invoiceNumber" TEXT NOT NULL,
"supplierReference" TEXT,
"supplierId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"invoiceDate" TIMESTAMP(3) NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"status" "APDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"subtotal" DECIMAL(18,2) NOT NULL,
"taxTotal" DECIMAL(18,2) NOT NULL,
"total" DECIMAL(18,2) NOT NULL,
"balance" DECIMAL(18,2) NOT NULL,
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "APInvoice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "APInvoiceLine" (
"id" SERIAL NOT NULL,
"invoiceId" INTEGER NOT NULL,
"lineNumber" INTEGER NOT NULL,
"description" TEXT NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL DEFAULT 1,
"unitPrice" DECIMAL(18,4) NOT NULL,
"expenseGLAccountId" INTEGER NOT NULL,
"taxCodeId" INTEGER,
"lineSubtotal" DECIMAL(18,2) NOT NULL,
"lineTax" DECIMAL(18,2) NOT NULL,
"lineTotal" DECIMAL(18,2) NOT NULL,
CONSTRAINT "APInvoiceLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "APDebitNote" (
"id" SERIAL NOT NULL,
"debitNoteNumber" TEXT NOT NULL,
"supplierId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"debitNoteDate" TIMESTAMP(3) NOT NULL,
"reference" TEXT,
"reason" TEXT,
"status" "APDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"subtotal" DECIMAL(18,2) NOT NULL,
"taxTotal" DECIMAL(18,2) NOT NULL,
"total" DECIMAL(18,2) NOT NULL,
"balance" DECIMAL(18,2) NOT NULL,
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "APDebitNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "APDebitNoteLine" (
"id" SERIAL NOT NULL,
"debitNoteId" INTEGER NOT NULL,
"lineNumber" INTEGER NOT NULL,
"description" TEXT NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL DEFAULT 1,
"unitPrice" DECIMAL(18,4) NOT NULL,
"expenseGLAccountId" INTEGER NOT NULL,
"taxCodeId" INTEGER,
"lineSubtotal" DECIMAL(18,2) NOT NULL,
"lineTax" DECIMAL(18,2) NOT NULL,
"lineTotal" DECIMAL(18,2) NOT NULL,
CONSTRAINT "APDebitNoteLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "APPayment" (
"id" SERIAL NOT NULL,
"paymentNumber" TEXT NOT NULL,
"supplierId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"paymentDate" TIMESTAMP(3) NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"unallocatedAmount" DECIMAL(18,2) NOT NULL,
"bankGLAccountId" INTEGER NOT NULL,
"reference" TEXT,
"status" "APDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "APPayment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "APAllocation" (
"id" SERIAL NOT NULL,
"invoiceId" INTEGER NOT NULL,
"paymentId" INTEGER,
"debitNoteId" INTEGER,
"amount" DECIMAL(18,2) NOT NULL,
"allocatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "APAllocation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Supplier_code_key" ON "Supplier"("code");
-- CreateIndex
CREATE INDEX "Supplier_branchSegmentValueId_idx" ON "Supplier"("branchSegmentValueId");
-- CreateIndex
CREATE UNIQUE INDEX "APInvoice_invoiceNumber_key" ON "APInvoice"("invoiceNumber");
-- CreateIndex
CREATE INDEX "APInvoice_supplierId_idx" ON "APInvoice"("supplierId");
-- CreateIndex
CREATE INDEX "APInvoice_branchSegmentValueId_idx" ON "APInvoice"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "APInvoice_status_idx" ON "APInvoice"("status");
-- CreateIndex
CREATE INDEX "APInvoiceLine_invoiceId_idx" ON "APInvoiceLine"("invoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "APDebitNote_debitNoteNumber_key" ON "APDebitNote"("debitNoteNumber");
-- CreateIndex
CREATE INDEX "APDebitNote_supplierId_idx" ON "APDebitNote"("supplierId");
-- CreateIndex
CREATE INDEX "APDebitNote_branchSegmentValueId_idx" ON "APDebitNote"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "APDebitNote_status_idx" ON "APDebitNote"("status");
-- CreateIndex
CREATE INDEX "APDebitNoteLine_debitNoteId_idx" ON "APDebitNoteLine"("debitNoteId");
-- CreateIndex
CREATE UNIQUE INDEX "APPayment_paymentNumber_key" ON "APPayment"("paymentNumber");
-- CreateIndex
CREATE INDEX "APPayment_supplierId_idx" ON "APPayment"("supplierId");
-- CreateIndex
CREATE INDEX "APPayment_branchSegmentValueId_idx" ON "APPayment"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "APPayment_status_idx" ON "APPayment"("status");
-- CreateIndex
CREATE INDEX "APAllocation_invoiceId_idx" ON "APAllocation"("invoiceId");
-- CreateIndex
CREATE INDEX "APAllocation_paymentId_idx" ON "APAllocation"("paymentId");
-- CreateIndex
CREATE INDEX "APAllocation_debitNoteId_idx" ON "APAllocation"("debitNoteId");
-- AddForeignKey
ALTER TABLE "JournalEntryLine" ADD CONSTRAINT "JournalEntryLine_apInvoiceId_fkey" FOREIGN KEY ("apInvoiceId") REFERENCES "APInvoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Supplier" ADD CONSTRAINT "Supplier_branchSegmentValueId_fkey" FOREIGN KEY ("branchSegmentValueId") REFERENCES "AccountSegmentValue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APInvoice" ADD CONSTRAINT "APInvoice_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Supplier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APInvoice" ADD CONSTRAINT "APInvoice_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "APInvoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey" FOREIGN KEY ("expenseGLAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_taxCodeId_fkey" FOREIGN KEY ("taxCodeId") REFERENCES "TaxCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APDebitNote" ADD CONSTRAINT "APDebitNote_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Supplier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APDebitNote" ADD CONSTRAINT "APDebitNote_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APDebitNoteLine" ADD CONSTRAINT "APDebitNoteLine_debitNoteId_fkey" FOREIGN KEY ("debitNoteId") REFERENCES "APDebitNote"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APDebitNoteLine" ADD CONSTRAINT "APDebitNoteLine_expenseGLAccountId_fkey" FOREIGN KEY ("expenseGLAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APDebitNoteLine" ADD CONSTRAINT "APDebitNoteLine_taxCodeId_fkey" FOREIGN KEY ("taxCodeId") REFERENCES "TaxCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APPayment" ADD CONSTRAINT "APPayment_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Supplier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APPayment" ADD CONSTRAINT "APPayment_bankGLAccountId_fkey" FOREIGN KEY ("bankGLAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APPayment" ADD CONSTRAINT "APPayment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APAllocation" ADD CONSTRAINT "APAllocation_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "APInvoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APAllocation" ADD CONSTRAINT "APAllocation_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "APPayment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APAllocation" ADD CONSTRAINT "APAllocation_debitNoteId_fkey" FOREIGN KEY ("debitNoteId") REFERENCES "APDebitNote"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+681
View File
@@ -0,0 +1,681 @@
// 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[]
@@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
STOCK
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/apInvoiceId are real FKs now that
// AR/AP exist; stockMovementId stays a plain id until Stock lands in its own phase.
arInvoiceId Int?
arInvoice ARInvoice? @relation(fields: [arInvoiceId], references: [id])
apInvoiceId Int?
apInvoice APInvoice? @relation(fields: [apInvoiceId], references: [id])
stockMovementId Int?
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])
}
// ---------------------------------------------------------------------------
// 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])
}
+315
View File
@@ -0,0 +1,315 @@
import { PrismaClient, GLAccountType, DebitCredit, ControlAccountType, JournalType } from '@prisma/client';
const prisma = new PrismaClient();
const PERMISSIONS = [
'admin.segments.manage',
'admin.fiscalPeriods.manage',
'admin.users.manage',
'admin.tax.manage',
'gl.account.manage',
'gl.journal.manage',
'gl.journal.post',
'ar.customer.manage',
'ar.invoice.manage',
'ar.creditnote.manage',
'ar.receipt.manage',
'ap.supplier.manage',
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
] as const;
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
ADMIN: PERMISSIONS,
ACCOUNTANT: [
'gl.account.manage',
'gl.journal.manage',
'gl.journal.post',
'admin.tax.manage',
'ar.customer.manage',
'ar.invoice.manage',
'ar.creditnote.manage',
'ar.receipt.manage',
'ap.supplier.manage',
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
],
AR_AP_CLERK: [
'ar.customer.manage',
'ar.invoice.manage',
'ar.creditnote.manage',
'ar.receipt.manage',
'ap.supplier.manage',
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
],
BRANCH_USER: [],
READ_ONLY: [],
};
async function main() {
console.log('Seeding company settings...');
await prisma.companySettings.upsert({
where: { id: 1 },
create: { id: 1, companyName: 'Demo Company', baseCurrencyCode: 'ZAR', fiscalYearStartMonth: 1, currentFiscalYear: 2026 },
update: {},
});
console.log('Seeding permissions and roles...');
for (const key of PERMISSIONS) {
await prisma.permission.upsert({ where: { key }, create: { key }, update: {} });
}
for (const roleName of Object.keys(ROLE_PERMISSIONS)) {
await prisma.role.upsert({ where: { name: roleName }, create: { name: roleName }, update: {} });
}
for (const [roleName, permissionKeys] of Object.entries(ROLE_PERMISSIONS)) {
const role = await prisma.role.findUniqueOrThrow({ where: { name: roleName } });
for (const key of permissionKeys) {
const permission = await prisma.permission.findUniqueOrThrow({ where: { key } });
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: role.id, permissionId: permission.id } },
create: { roleId: role.id, permissionId: permission.id },
update: {},
});
}
}
console.log('Seeding default account segment structure...');
const segmentDefs = [
{ segmentKey: 'MASTER', label: 'Master Account', order: 0, length: 4, format: 'NUMERIC' as const, isBranchSegment: false },
{ segmentKey: 'SUB', label: 'Sub Account', order: 1, length: 3, format: 'NUMERIC' as const, isBranchSegment: false },
{ segmentKey: 'SUBSUB', label: 'Sub-Sub Account', order: 2, length: 3, format: 'NUMERIC' as const, isBranchSegment: false },
{ segmentKey: 'BRANCH', label: 'Branch', order: 3, length: 3, format: 'ALPHA' as const, isBranchSegment: true },
];
const defByKey: Record<string, { id: number }> = {};
for (const def of segmentDefs) {
defByKey[def.segmentKey] = await prisma.accountSegmentDefinition.upsert({
where: { segmentKey: def.segmentKey },
create: def,
update: def,
});
}
const masterValues = [
{ code: '1000', description: 'Bank / Cash' },
{ code: '1100', description: 'Accounts Receivable Control' },
{ code: '1200', description: 'Inventory / Stock Control' },
{ code: '2000', description: 'Accounts Payable Control' },
{ code: '2100', description: 'Output Tax Payable' },
{ code: '2200', description: 'Input Tax Receivable' },
{ code: '3000', description: 'Retained Earnings' },
{ code: '3100', description: 'Share Capital' },
{ code: '4000', description: 'Sales Income' },
{ code: '5000', description: 'Cost of Goods Sold' },
{ code: '5100', description: 'Operating Expenses' },
{ code: '5200', description: 'Stock Variance' },
];
const masterByCode: Record<string, { id: number }> = {};
for (const value of masterValues) {
masterByCode[value.code] = await prisma.accountSegmentValue.upsert({
where: { segmentDefId_code: { segmentDefId: defByKey.MASTER.id, code: value.code } },
create: { segmentDefId: defByKey.MASTER.id, ...value },
update: value,
});
}
const subDefault = await prisma.accountSegmentValue.upsert({
where: { segmentDefId_code: { segmentDefId: defByKey.SUB.id, code: '000' } },
create: { segmentDefId: defByKey.SUB.id, code: '000', description: 'Default' },
update: {},
});
const subsubDefault = await prisma.accountSegmentValue.upsert({
where: { segmentDefId_code: { segmentDefId: defByKey.SUBSUB.id, code: '000' } },
create: { segmentDefId: defByKey.SUBSUB.id, code: '000', description: 'Default' },
update: {},
});
const branchDefs = [
{ code: 'HQ', description: 'Head Office' },
{ code: 'AMA', description: 'Branch AMA (example branch)' },
];
const branchByCode: Record<string, { id: number }> = {};
for (const branch of branchDefs) {
const value = await prisma.accountSegmentValue.upsert({
where: { segmentDefId_code: { segmentDefId: defByKey.BRANCH.id, code: branch.code } },
create: { segmentDefId: defByKey.BRANCH.id, code: branch.code, description: branch.description },
update: {},
});
branchByCode[branch.code] = value;
await prisma.branchProfile.upsert({
where: { segmentValueId: value.id },
create: { segmentValueId: value.id },
update: {},
});
}
console.log('Seeding default Chart of Accounts...');
const operationalAccounts: Array<{
masterCode: string;
accountName: string;
accountType: GLAccountType;
normalBalance: DebitCredit;
isControlAccount?: boolean;
controlType?: ControlAccountType;
}> = [
{ masterCode: '1000', accountName: 'Bank / Cash', accountType: 'ASSET', normalBalance: 'DEBIT', controlType: 'BANK' },
{ masterCode: '1100', accountName: 'Accounts Receivable Control', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'AR' },
{ masterCode: '1200', accountName: 'Inventory / Stock Control', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'STOCK' },
{ masterCode: '2000', accountName: 'Accounts Payable Control', accountType: 'LIABILITY', normalBalance: 'CREDIT', isControlAccount: true, controlType: 'AP' },
{ masterCode: '2100', accountName: 'Output Tax Payable', accountType: 'LIABILITY', normalBalance: 'CREDIT', isControlAccount: true, controlType: 'TAX_OUTPUT' },
{ masterCode: '2200', accountName: 'Input Tax Receivable', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'TAX_INPUT' },
{ masterCode: '4000', accountName: 'Sales Income', accountType: 'INCOME', normalBalance: 'CREDIT' },
{ masterCode: '5000', accountName: 'Cost of Goods Sold', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
{ masterCode: '5100', accountName: 'Operating Expenses', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
{ masterCode: '5200', accountName: 'Stock Variance', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
];
for (const branchCode of Object.keys(branchByCode)) {
for (const account of operationalAccounts) {
const accountCode = [masterByCode[account.masterCode].id, subDefault.id, subsubDefault.id, branchByCode[branchCode].id];
const fullCode = `${account.masterCode}-000-000-${branchCode}`;
await prisma.gLAccount.upsert({
where: { accountCode: fullCode },
create: {
accountCode: fullCode,
accountName: `${account.accountName} — ${branchCode}`,
accountType: account.accountType,
normalBalance: account.normalBalance,
isControlAccount: account.isControlAccount ?? false,
controlType: account.controlType,
segmentValues: {
create: [
{ segmentDefId: defByKey.MASTER.id, segmentValueId: accountCode[0] },
{ segmentDefId: defByKey.SUB.id, segmentValueId: accountCode[1] },
{ segmentDefId: defByKey.SUBSUB.id, segmentValueId: accountCode[2] },
{ segmentDefId: defByKey.BRANCH.id, segmentValueId: accountCode[3] },
],
},
},
update: {},
});
}
}
// Equity accounts are booked at Head Office only.
const equityAccounts: Array<{ masterCode: string; accountName: string; controlType?: ControlAccountType }> = [
{ masterCode: '3000', accountName: 'Retained Earnings', controlType: 'RETAINED_EARNINGS' },
{ masterCode: '3100', accountName: 'Share Capital' },
];
for (const account of equityAccounts) {
const fullCode = `${account.masterCode}-000-000-HQ`;
await prisma.gLAccount.upsert({
where: { accountCode: fullCode },
create: {
accountCode: fullCode,
accountName: account.accountName,
accountType: 'EQUITY',
normalBalance: 'CREDIT',
controlType: account.controlType,
segmentValues: {
create: [
{ segmentDefId: defByKey.MASTER.id, segmentValueId: masterByCode[account.masterCode].id },
{ segmentDefId: defByKey.SUB.id, segmentValueId: subDefault.id },
{ segmentDefId: defByKey.SUBSUB.id, segmentValueId: subsubDefault.id },
{ segmentDefId: defByKey.BRANCH.id, segmentValueId: branchByCode.HQ.id },
],
},
},
update: {},
});
}
console.log('Seeding journals...');
await prisma.journal.upsert({
where: { code: 'GJ' },
create: { code: 'GJ', name: 'General Journal', journalType: JournalType.GENERAL },
update: {},
});
await prisma.journal.upsert({
where: { code: 'PC-HQ' },
create: {
code: 'PC-HQ',
name: 'Petty Cash — Head Office',
journalType: JournalType.CUSTOM,
defaultBranchSegmentValueId: branchByCode.HQ.id,
},
update: {},
});
await prisma.journal.upsert({
where: { code: 'SALES' },
create: { code: 'SALES', name: 'Sales Journal', journalType: JournalType.SALES },
update: {},
});
await prisma.journal.upsert({
where: { code: 'CASH_RECEIPT' },
create: { code: 'CASH_RECEIPT', name: 'Cash Receipts Journal', journalType: JournalType.CASH_RECEIPT },
update: {},
});
await prisma.journal.upsert({
where: { code: 'PURCHASES' },
create: { code: 'PURCHASES', name: 'Purchases Journal', journalType: JournalType.PURCHASES },
update: {},
});
await prisma.journal.upsert({
where: { code: 'CASH_PAYMENT' },
create: { code: 'CASH_PAYMENT', name: 'Cash Payments Journal', journalType: JournalType.CASH_PAYMENT },
update: {},
});
console.log('Seeding tax codes...');
const taxCodes = [
{ code: 'STD', name: 'Standard Rate (15%)', ratePercent: 15 },
{ code: 'ZERO', name: 'Zero-Rated (0%)', ratePercent: 0 },
];
for (const taxCode of taxCodes) {
await prisma.taxCode.upsert({ where: { code: taxCode.code }, create: taxCode, update: taxCode });
}
console.log('Seeding current fiscal year...');
const fiscalYear = await prisma.fiscalYear.upsert({
where: { year: 2026 },
create: { year: 2026, startDate: new Date(Date.UTC(2026, 0, 1)), endDate: new Date(Date.UTC(2026, 11, 31)) },
update: {},
});
for (let month = 0; month < 12; month += 1) {
const startDate = new Date(Date.UTC(2026, month, 1));
const endDate = new Date(Date.UTC(2026, month + 1, 0));
await prisma.fiscalPeriod.upsert({
where: { fiscalYearId_periodNumber: { fiscalYearId: fiscalYear.id, periodNumber: month + 1 } },
create: { fiscalYearId: fiscalYear.id, periodNumber: month + 1, startDate, endDate },
update: {},
});
}
console.log('Seeding admin user...');
// No password here — identity is established by the Account Manager's handoff token and
// matched to this User by email (see src/modules/auth/auth.service.ts verifyHandoff()).
// This email must match the Person who logs into the Manager for the mapping to resolve.
const adminEmail = 'joosth2002@icloud.com';
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: 'ADMIN' } });
const existingAdmin = await prisma.user.findUnique({ where: { email: adminEmail } });
if (!existingAdmin) {
await prisma.user.create({
data: {
email: adminEmail,
fullName: 'System Administrator',
roles: { create: { roleId: adminRole.id } },
},
});
console.log(`Created admin user ${adminEmail} — signs in via the Account Manager, no local password.`);
}
console.log('Seed complete.');
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});