Files
Joshua OosthuizenandClaude Sonnet 5 4acbc03b35 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
2026-09-03 13:27:19 +02:00

54 lines
2.2 KiB
PL/PgSQL

-- 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;