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:
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user