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
54 lines
2.2 KiB
PL/PgSQL
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;
|