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
+235
View File
@@ -0,0 +1,235 @@
/**
* Requires a real, migrated, seeded Postgres database (see SETUP.md) reachable via DATABASE_URL.
* Run with: npm run test:integration
*/
import { PrismaClient } from '@prisma/client';
import * as apInvoicesService from '../../src/modules/ap/apInvoices.service';
import * as apDebitNotesService from '../../src/modules/ap/apDebitNotes.service';
import * as apPaymentsService from '../../src/modules/ap/apPayments.service';
import { APAllocationError, APDocumentStateError } from '../../src/modules/ap/ap.errors';
const prisma = new PrismaClient();
async function getAccount(accountCode: string) {
return prisma.gLAccount.findUniqueOrThrow({ where: { accountCode } });
}
describe('Accounts Payable — posting and allocation (real DB)', () => {
let adminId: number;
let branchHQId: number;
let apAccount: { id: number };
let expenseAccount: { id: number };
let inputTaxAccount: { id: number };
let bankAccount: { id: number };
let supplierId: number;
let stdTaxCodeId: number;
beforeAll(async () => {
const admin = await prisma.user.findUniqueOrThrow({ where: { email: 'joosth2002@icloud.com' } });
adminId = admin.id;
apAccount = await getAccount('2000-000-000-HQ');
expenseAccount = await getAccount('5100-000-000-HQ');
inputTaxAccount = await getAccount('2200-000-000-HQ');
bankAccount = await getAccount('1000-000-000-HQ');
const hqSegmentValue = await prisma.accountSegmentValue.findFirstOrThrow({
where: { code: 'HQ', segmentDef: { isBranchSegment: true } },
});
branchHQId = hqSegmentValue.id;
const stdTaxCode = await prisma.taxCode.findUniqueOrThrow({ where: { code: 'STD' } });
stdTaxCodeId = stdTaxCode.id;
const supplier = await prisma.supplier.create({
data: {
code: `AP-TEST-${Date.now()}`,
name: 'Integration Test Supplier',
branchSegmentValueId: branchHQId,
},
});
supplierId = supplier.id;
});
afterAll(async () => {
await prisma.$disconnect();
});
it('creates a draft invoice with computed tax and does not touch the GL', async () => {
const beforeJournalCount = await prisma.journalEntry.count();
const invoice = await apInvoicesService.createInvoiceDraft(
{
supplierId,
invoiceDate: '2026-03-01',
dueDate: '2026-03-31',
lines: [
{ description: 'Office supplies', quantity: 10, unitPrice: 100, expenseGLAccountId: expenseAccount.id, taxCodeId: stdTaxCodeId },
{ description: 'Zero-rated service', quantity: 1, unitPrice: 50, expenseGLAccountId: expenseAccount.id },
],
},
adminId,
);
expect(invoice.status).toBe('DRAFT');
expect(invoice.subtotal.toString()).toBe('1050');
expect(invoice.taxTotal.toString()).toBe('150');
expect(invoice.total.toString()).toBe('1200');
expect(invoice.balance.toString()).toBe('1200');
const afterJournalCount = await prisma.journalEntry.count();
expect(afterJournalCount).toBe(beforeJournalCount);
});
it('posts a supplier invoice to a balanced GL entry: Dr Expense + Dr Input Tax / Cr AP Control', async () => {
const draft = await apInvoicesService.createInvoiceDraft(
{
supplierId,
invoiceDate: '2026-03-05',
dueDate: '2026-04-04',
lines: [{ description: 'Consulting', quantity: 2, unitPrice: 500, expenseGLAccountId: expenseAccount.id, taxCodeId: stdTaxCodeId }],
},
adminId,
);
const posted = await apInvoicesService.postInvoice(draft.id, adminId);
expect(posted.status).toBe('POSTED');
expect(posted.journalEntryId).not.toBeNull();
const entry = await prisma.journalEntry.findUniqueOrThrow({
where: { id: posted.journalEntryId! },
include: { lines: true },
});
expect(entry.totalDebits.toString()).toBe(entry.totalCredits.toString());
expect(entry.sourceModule).toBe('AP');
expect(entry.sourceType).toBe('APInvoice');
expect(entry.sourceId).toBe(posted.id);
const apLine = entry.lines.find((line) => line.glAccountId === apAccount.id);
const expenseLine = entry.lines.find((line) => line.glAccountId === expenseAccount.id);
const taxLine = entry.lines.find((line) => line.glAccountId === inputTaxAccount.id);
expect(apLine?.credit.toString()).toBe('1150');
expect(expenseLine?.debit.toString()).toBe('1000');
expect(taxLine?.debit.toString()).toBe('150');
expect(apLine?.apInvoiceId).toBe(posted.id);
});
it('rejects posting the same invoice twice', async () => {
const draft = await apInvoicesService.createInvoiceDraft(
{ supplierId, invoiceDate: '2026-03-06', dueDate: '2026-04-05', lines: [{ description: 'X', quantity: 1, unitPrice: 10, expenseGLAccountId: expenseAccount.id }] },
adminId,
);
await apInvoicesService.postInvoice(draft.id, adminId);
await expect(apInvoicesService.postInvoice(draft.id, adminId)).rejects.toBeInstanceOf(APDocumentStateError);
});
it('posts a payment (Dr AP Control / Cr Bank) and allocates it against an open invoice', async () => {
const invoiceDraft = await apInvoicesService.createInvoiceDraft(
{ supplierId, invoiceDate: '2026-03-10', dueDate: '2026-04-09', lines: [{ description: 'Goods', quantity: 1, unitPrice: 300, expenseGLAccountId: expenseAccount.id }] },
adminId,
);
const invoice = await apInvoicesService.postInvoice(invoiceDraft.id, adminId);
expect(invoice.balance.toString()).toBe('300');
const paymentDraft = await apPaymentsService.createPaymentDraft(
{ supplierId, paymentDate: '2026-03-15', amount: 300, bankGLAccountId: bankAccount.id },
adminId,
);
const payment = await apPaymentsService.postPayment(paymentDraft.id, adminId);
expect(payment.status).toBe('POSTED');
const entry = await prisma.journalEntry.findUniqueOrThrow({ where: { id: payment.journalEntryId! }, include: { lines: true } });
const apLine = entry.lines.find((line) => line.glAccountId === apAccount.id);
const bankLine = entry.lines.find((line) => line.glAccountId === bankAccount.id);
expect(apLine?.debit.toString()).toBe('300');
expect(bankLine?.credit.toString()).toBe('300');
await apPaymentsService.allocatePayment(payment.id, [{ invoiceId: invoice.id, amount: 300 }]);
const invoiceAfter = await prisma.aPInvoice.findUniqueOrThrow({ where: { id: invoice.id } });
const paymentAfter = await prisma.aPPayment.findUniqueOrThrow({ where: { id: payment.id } });
expect(invoiceAfter.balance.toString()).toBe('0');
expect(paymentAfter.unallocatedAmount.toString()).toBe('0');
});
it('rejects an allocation that exceeds the invoice balance', async () => {
const invoiceDraft = await apInvoicesService.createInvoiceDraft(
{ supplierId, invoiceDate: '2026-03-11', dueDate: '2026-04-10', lines: [{ description: 'Goods', quantity: 1, unitPrice: 50, expenseGLAccountId: expenseAccount.id }] },
adminId,
);
const invoice = await apInvoicesService.postInvoice(invoiceDraft.id, adminId);
const paymentDraft = await apPaymentsService.createPaymentDraft(
{ supplierId, paymentDate: '2026-03-16', amount: 200, bankGLAccountId: bankAccount.id },
adminId,
);
const payment = await apPaymentsService.postPayment(paymentDraft.id, adminId);
await expect(apPaymentsService.allocatePayment(payment.id, [{ invoiceId: invoice.id, amount: 200 }])).rejects.toBeInstanceOf(
APAllocationError,
);
const invoiceAfter = await prisma.aPInvoice.findUniqueOrThrow({ where: { id: invoice.id } });
expect(invoiceAfter.balance.toString()).toBe('50');
});
it('posts a debit note as the reverse of an invoice and allocates it against the invoice', async () => {
const invoiceDraft = await apInvoicesService.createInvoiceDraft(
{
supplierId,
invoiceDate: '2026-03-12',
dueDate: '2026-04-11',
lines: [{ description: 'Faulty goods', quantity: 1, unitPrice: 100, expenseGLAccountId: expenseAccount.id, taxCodeId: stdTaxCodeId }],
},
adminId,
);
const invoice = await apInvoicesService.postInvoice(invoiceDraft.id, adminId);
expect(invoice.total.toString()).toBe('115');
const debitNoteDraft = await apDebitNotesService.createDebitNoteDraft(
{
supplierId,
debitNoteDate: '2026-03-13',
reason: 'Return',
lines: [{ description: 'Faulty goods', quantity: 1, unitPrice: 100, expenseGLAccountId: expenseAccount.id, taxCodeId: stdTaxCodeId }],
},
adminId,
);
const debitNote = await apDebitNotesService.postDebitNote(debitNoteDraft.id, adminId);
expect(debitNote.total.toString()).toBe('115');
const entry = await prisma.journalEntry.findUniqueOrThrow({ where: { id: debitNote.journalEntryId! }, include: { lines: true } });
expect(entry.totalDebits.toString()).toBe(entry.totalCredits.toString());
const apLine = entry.lines.find((line) => line.glAccountId === apAccount.id);
const expenseLine = entry.lines.find((line) => line.glAccountId === expenseAccount.id);
expect(apLine?.debit.toString()).toBe('115');
expect(expenseLine?.credit.toString()).toBe('100');
await apDebitNotesService.allocateDebitNote(debitNote.id, [{ invoiceId: invoice.id, amount: 115 }]);
const invoiceAfter = await prisma.aPInvoice.findUniqueOrThrow({ where: { id: invoice.id } });
const debitNoteAfter = await prisma.aPDebitNote.findUniqueOrThrow({ where: { id: debitNote.id } });
expect(invoiceAfter.balance.toString()).toBe('0');
expect(debitNoteAfter.balance.toString()).toBe('0');
});
it('voids a draft invoice without touching the GL, and refuses to void a posted one', async () => {
const draft = await apInvoicesService.createInvoiceDraft(
{ supplierId, invoiceDate: '2026-03-14', dueDate: '2026-04-13', lines: [{ description: 'X', quantity: 1, unitPrice: 10, expenseGLAccountId: expenseAccount.id }] },
adminId,
);
const voided = await apInvoicesService.voidInvoiceDraft(draft.id);
expect(voided.status).toBe('VOID');
const posted = await apInvoicesService.postInvoice(
(
await apInvoicesService.createInvoiceDraft(
{ supplierId, invoiceDate: '2026-03-14', dueDate: '2026-04-13', lines: [{ description: 'X', quantity: 1, unitPrice: 10, expenseGLAccountId: expenseAccount.id }] },
adminId,
)
).id,
adminId,
);
await expect(apInvoicesService.voidInvoiceDraft(posted.id)).rejects.toBeInstanceOf(APDocumentStateError);
});
});
+235
View File
@@ -0,0 +1,235 @@
/**
* Requires a real, migrated, seeded Postgres database (see SETUP.md) reachable via DATABASE_URL.
* Run with: npm run test:integration
*/
import { PrismaClient } from '@prisma/client';
import * as arInvoicesService from '../../src/modules/ar/arInvoices.service';
import * as arCreditNotesService from '../../src/modules/ar/arCreditNotes.service';
import * as arReceiptsService from '../../src/modules/ar/arReceipts.service';
import { ARAllocationError, ARDocumentStateError } from '../../src/modules/ar/ar.errors';
const prisma = new PrismaClient();
async function getAccount(accountCode: string) {
return prisma.gLAccount.findUniqueOrThrow({ where: { accountCode } });
}
describe('Accounts Receivable — posting and allocation (real DB)', () => {
let adminId: number;
let branchHQId: number;
let arAccount: { id: number };
let salesAccount: { id: number };
let taxAccount: { id: number };
let bankAccount: { id: number };
let customerId: number;
let stdTaxCodeId: number;
beforeAll(async () => {
const admin = await prisma.user.findUniqueOrThrow({ where: { email: 'joosth2002@icloud.com' } });
adminId = admin.id;
arAccount = await getAccount('1100-000-000-HQ');
salesAccount = await getAccount('4000-000-000-HQ');
taxAccount = await getAccount('2100-000-000-HQ');
bankAccount = await getAccount('1000-000-000-HQ');
const hqSegmentValue = await prisma.accountSegmentValue.findFirstOrThrow({
where: { code: 'HQ', segmentDef: { isBranchSegment: true } },
});
branchHQId = hqSegmentValue.id;
const stdTaxCode = await prisma.taxCode.findUniqueOrThrow({ where: { code: 'STD' } });
stdTaxCodeId = stdTaxCode.id;
const customer = await prisma.customer.create({
data: {
code: `AR-TEST-${Date.now()}`,
name: 'Integration Test Customer',
branchSegmentValueId: branchHQId,
},
});
customerId = customer.id;
});
afterAll(async () => {
await prisma.$disconnect();
});
it('creates a draft invoice with computed tax and does not touch the GL', async () => {
const beforeJournalCount = await prisma.journalEntry.count();
const invoice = await arInvoicesService.createInvoiceDraft(
{
customerId,
invoiceDate: '2026-03-01',
dueDate: '2026-03-31',
lines: [
{ description: 'Widgets', quantity: 10, unitPrice: 100, revenueGLAccountId: salesAccount.id, taxCodeId: stdTaxCodeId },
{ description: 'Zero-rated service', quantity: 1, unitPrice: 50, revenueGLAccountId: salesAccount.id },
],
},
adminId,
);
expect(invoice.status).toBe('DRAFT');
expect(invoice.subtotal.toString()).toBe('1050');
expect(invoice.taxTotal.toString()).toBe('150'); // 15% of 1000, zero-rated line contributes 0
expect(invoice.total.toString()).toBe('1200');
expect(invoice.balance.toString()).toBe('1200');
const afterJournalCount = await prisma.journalEntry.count();
expect(afterJournalCount).toBe(beforeJournalCount);
});
it('posts an invoice to a balanced GL entry: Dr AR = total, Cr Sales = subtotal, Cr Output Tax = tax', async () => {
const draft = await arInvoicesService.createInvoiceDraft(
{
customerId,
invoiceDate: '2026-03-05',
dueDate: '2026-04-04',
lines: [{ description: 'Consulting', quantity: 2, unitPrice: 500, revenueGLAccountId: salesAccount.id, taxCodeId: stdTaxCodeId }],
},
adminId,
);
const posted = await arInvoicesService.postInvoice(draft.id, adminId);
expect(posted.status).toBe('POSTED');
expect(posted.journalEntryId).not.toBeNull();
const entry = await prisma.journalEntry.findUniqueOrThrow({
where: { id: posted.journalEntryId! },
include: { lines: true },
});
expect(entry.totalDebits.toString()).toBe(entry.totalCredits.toString());
expect(entry.sourceModule).toBe('AR');
expect(entry.sourceType).toBe('ARInvoice');
expect(entry.sourceId).toBe(posted.id);
const arLine = entry.lines.find((line) => line.glAccountId === arAccount.id);
const salesLine = entry.lines.find((line) => line.glAccountId === salesAccount.id);
const taxLine = entry.lines.find((line) => line.glAccountId === taxAccount.id);
expect(arLine?.debit.toString()).toBe('1150');
expect(salesLine?.credit.toString()).toBe('1000');
expect(taxLine?.credit.toString()).toBe('150');
expect(arLine?.arInvoiceId).toBe(posted.id);
});
it('rejects posting the same invoice twice', async () => {
const draft = await arInvoicesService.createInvoiceDraft(
{ customerId, invoiceDate: '2026-03-06', dueDate: '2026-04-05', lines: [{ description: 'X', quantity: 1, unitPrice: 10, revenueGLAccountId: salesAccount.id }] },
adminId,
);
await arInvoicesService.postInvoice(draft.id, adminId);
await expect(arInvoicesService.postInvoice(draft.id, adminId)).rejects.toBeInstanceOf(ARDocumentStateError);
});
it('posts a receipt (Dr Bank / Cr AR) and allocates it against an open invoice', async () => {
const invoiceDraft = await arInvoicesService.createInvoiceDraft(
{ customerId, invoiceDate: '2026-03-10', dueDate: '2026-04-09', lines: [{ description: 'Goods', quantity: 1, unitPrice: 300, revenueGLAccountId: salesAccount.id }] },
adminId,
);
const invoice = await arInvoicesService.postInvoice(invoiceDraft.id, adminId);
expect(invoice.balance.toString()).toBe('300');
const receiptDraft = await arReceiptsService.createReceiptDraft(
{ customerId, receiptDate: '2026-03-15', amount: 300, bankGLAccountId: bankAccount.id },
adminId,
);
const receipt = await arReceiptsService.postReceipt(receiptDraft.id, adminId);
expect(receipt.status).toBe('POSTED');
const entry = await prisma.journalEntry.findUniqueOrThrow({ where: { id: receipt.journalEntryId! }, include: { lines: true } });
const bankLine = entry.lines.find((line) => line.glAccountId === bankAccount.id);
const arLine = entry.lines.find((line) => line.glAccountId === arAccount.id);
expect(bankLine?.debit.toString()).toBe('300');
expect(arLine?.credit.toString()).toBe('300');
await arReceiptsService.allocateReceipt(receipt.id, [{ invoiceId: invoice.id, amount: 300 }]);
const invoiceAfter = await prisma.aRInvoice.findUniqueOrThrow({ where: { id: invoice.id } });
const receiptAfter = await prisma.aRReceipt.findUniqueOrThrow({ where: { id: receipt.id } });
expect(invoiceAfter.balance.toString()).toBe('0');
expect(receiptAfter.unallocatedAmount.toString()).toBe('0');
});
it('rejects an allocation that exceeds the invoice balance', async () => {
const invoiceDraft = await arInvoicesService.createInvoiceDraft(
{ customerId, invoiceDate: '2026-03-11', dueDate: '2026-04-10', lines: [{ description: 'Goods', quantity: 1, unitPrice: 50, revenueGLAccountId: salesAccount.id }] },
adminId,
);
const invoice = await arInvoicesService.postInvoice(invoiceDraft.id, adminId);
const receiptDraft = await arReceiptsService.createReceiptDraft(
{ customerId, receiptDate: '2026-03-16', amount: 200, bankGLAccountId: bankAccount.id },
adminId,
);
const receipt = await arReceiptsService.postReceipt(receiptDraft.id, adminId);
await expect(arReceiptsService.allocateReceipt(receipt.id, [{ invoiceId: invoice.id, amount: 200 }])).rejects.toBeInstanceOf(
ARAllocationError,
);
const invoiceAfter = await prisma.aRInvoice.findUniqueOrThrow({ where: { id: invoice.id } });
expect(invoiceAfter.balance.toString()).toBe('50');
});
it('posts a credit note as the reverse of an invoice and allocates it against the invoice', async () => {
const invoiceDraft = await arInvoicesService.createInvoiceDraft(
{
customerId,
invoiceDate: '2026-03-12',
dueDate: '2026-04-11',
lines: [{ description: 'Faulty goods', quantity: 1, unitPrice: 100, revenueGLAccountId: salesAccount.id, taxCodeId: stdTaxCodeId }],
},
adminId,
);
const invoice = await arInvoicesService.postInvoice(invoiceDraft.id, adminId);
expect(invoice.total.toString()).toBe('115');
const creditNoteDraft = await arCreditNotesService.createCreditNoteDraft(
{
customerId,
creditNoteDate: '2026-03-13',
reason: 'Return',
lines: [{ description: 'Faulty goods', quantity: 1, unitPrice: 100, revenueGLAccountId: salesAccount.id, taxCodeId: stdTaxCodeId }],
},
adminId,
);
const creditNote = await arCreditNotesService.postCreditNote(creditNoteDraft.id, adminId);
expect(creditNote.total.toString()).toBe('115');
const entry = await prisma.journalEntry.findUniqueOrThrow({ where: { id: creditNote.journalEntryId! }, include: { lines: true } });
expect(entry.totalDebits.toString()).toBe(entry.totalCredits.toString());
const arLine = entry.lines.find((line) => line.glAccountId === arAccount.id);
const salesLine = entry.lines.find((line) => line.glAccountId === salesAccount.id);
expect(arLine?.credit.toString()).toBe('115');
expect(salesLine?.debit.toString()).toBe('100');
await arCreditNotesService.allocateCreditNote(creditNote.id, [{ invoiceId: invoice.id, amount: 115 }]);
const invoiceAfter = await prisma.aRInvoice.findUniqueOrThrow({ where: { id: invoice.id } });
const creditNoteAfter = await prisma.aRCreditNote.findUniqueOrThrow({ where: { id: creditNote.id } });
expect(invoiceAfter.balance.toString()).toBe('0');
expect(creditNoteAfter.balance.toString()).toBe('0');
});
it('voids a draft invoice without touching the GL, and refuses to void a posted one', async () => {
const draft = await arInvoicesService.createInvoiceDraft(
{ customerId, invoiceDate: '2026-03-14', dueDate: '2026-04-13', lines: [{ description: 'X', quantity: 1, unitPrice: 10, revenueGLAccountId: salesAccount.id }] },
adminId,
);
const voided = await arInvoicesService.voidInvoiceDraft(draft.id);
expect(voided.status).toBe('VOID');
const posted = await arInvoicesService.postInvoice(
(
await arInvoicesService.createInvoiceDraft(
{ customerId, invoiceDate: '2026-03-14', dueDate: '2026-04-13', lines: [{ description: 'X', quantity: 1, unitPrice: 10, revenueGLAccountId: salesAccount.id }] },
adminId,
)
).id,
adminId,
);
await expect(arInvoicesService.voidInvoiceDraft(posted.id)).rejects.toBeInstanceOf(ARDocumentStateError);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* Requires the real, migrated, seeded company-app database (see SETUP.md) reachable via
* DATABASE_URL, and the Manager's real handoff keypair at ../manager/keys/ (see
* manager/SETUP.md — npm run generate-handoff-keys). Run with: npm run test:integration
*/
import fs from 'fs';
import path from 'path';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import request from 'supertest';
import { createApp } from '../../src/app';
import { prisma } from '../../src/lib/prisma';
const app = createApp();
const managerPrivateKey = fs.readFileSync(path.resolve(__dirname, '../../manager/keys/handoff-private.pem'), 'utf8');
const ADMIN_EMAIL = 'joosth2002@icloud.com';
function mintHandoffToken(overrides: Partial<{ sub: string; companySlug: string; expiresIn: number }> = {}) {
const claims = { sub: overrides.sub ?? ADMIN_EMAIL, pid: 1, cid: 1, companySlug: overrides.companySlug ?? 'demo' };
return jwt.sign(claims, managerPrivateKey, {
algorithm: 'RS256',
expiresIn: overrides.expiresIn ?? 90,
jwtid: crypto.randomUUID(),
});
}
describe('Company app — handoff token exchange (real DB, real keypair)', () => {
afterAll(async () => {
await prisma.$disconnect();
});
it('exchanges a valid handoff token for a working session', async () => {
const token = mintHandoffToken();
const response = await request(app).post('/api/auth/handoff').send({ token });
expect(response.status).toBe(200);
expect(response.body.accessToken).toBeDefined();
expect(response.body.user.email).toBe(ADMIN_EMAIL);
const me = await request(app).get('/api/auth/me').set('Authorization', `Bearer ${response.body.accessToken}`);
expect(me.status).toBe(200);
expect(me.body.user.email).toBe(ADMIN_EMAIL);
});
it('rejects the same token twice (single-use / replay protection)', async () => {
const token = mintHandoffToken();
const first = await request(app).post('/api/auth/handoff').send({ token });
expect(first.status).toBe(200);
const second = await request(app).post('/api/auth/handoff').send({ token });
expect(second.status).toBe(401);
expect(second.body.error).toMatch(/already been used/i);
});
it('rejects a token minted for a different company slug', async () => {
const token = mintHandoffToken({ companySlug: 'some-other-company' });
const response = await request(app).post('/api/auth/handoff').send({ token });
expect(response.status).toBe(401);
expect(response.body.error).toMatch(/not issued for this company/i);
});
it('rejects an expired token', async () => {
const token = mintHandoffToken({ expiresIn: -10 });
const response = await request(app).post('/api/auth/handoff').send({ token });
expect(response.status).toBe(401);
});
it('rejects a well-formed but unsigned/forged token', async () => {
const forged = jwt.sign({ sub: ADMIN_EMAIL, pid: 1, cid: 1, companySlug: 'demo' }, 'not-the-real-key', {
algorithm: 'HS256',
expiresIn: 90,
jwtid: crypto.randomUUID(),
});
const response = await request(app).post('/api/auth/handoff').send({ token: forged });
expect(response.status).toBe(401);
});
it('the old POST /api/auth/login route no longer exists', async () => {
const response = await request(app).post('/api/auth/login').send({ email: 'x', password: 'y' });
expect(response.status).toBe(404);
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Requires a real, migrated, seeded Postgres database (see SETUP.md) reachable via DATABASE_URL.
* Run with: npm run test:integration
*/
import { PrismaClient } from '@prisma/client';
import { PostingService } from '../../src/modules/gl/posting.service';
import { UnbalancedEntryError } from '../../src/modules/gl/posting.types';
const prisma = new PrismaClient();
async function getAccount(accountCode: string) {
return prisma.gLAccount.findUniqueOrThrow({ where: { accountCode } });
}
describe('PostingService — double-entry balance invariant (real DB)', () => {
let adminId: number;
let bankHQ: { id: number };
let salesHQ: { id: number };
beforeAll(async () => {
const admin = await prisma.user.findUniqueOrThrow({ where: { email: 'joosth2002@icloud.com' } });
adminId = admin.id;
bankHQ = await getAccount('1000-000-000-HQ');
salesHQ = await getAccount('4000-000-000-HQ');
});
afterAll(async () => {
await prisma.$disconnect();
});
it('posts a balanced entry and persists matching totals', async () => {
const entry = await PostingService.postEntry({
journalCode: 'GJ',
entryDate: new Date(),
description: 'Integration test — balanced entry',
sourceModule: 'GL',
sourceType: 'IntegrationTest',
createdById: adminId,
lines: [
{ glAccountId: bankHQ.id, debit: 250, credit: 0 },
{ glAccountId: salesHQ.id, debit: 0, credit: 250 },
],
});
expect(entry.totalDebits.toString()).toBe('250');
expect(entry.totalCredits.toString()).toBe('250');
const lines = await prisma.journalEntryLine.findMany({ where: { journalEntryId: entry.id } });
const totalDebits = lines.reduce((sum, line) => sum + Number(line.debit), 0);
const totalCredits = lines.reduce((sum, line) => sum + Number(line.credit), 0);
expect(totalDebits).toBe(totalCredits);
});
it('rejects an unbalanced entry via the application layer and writes nothing', async () => {
const before = await prisma.journalEntry.count();
await expect(
PostingService.postEntry({
journalCode: 'GJ',
entryDate: new Date(),
description: 'Integration test — unbalanced entry',
sourceModule: 'GL',
sourceType: 'IntegrationTest',
createdById: adminId,
lines: [
{ glAccountId: bankHQ.id, debit: 100, credit: 0 },
{ glAccountId: salesHQ.id, debit: 0, credit: 40 },
],
}),
).rejects.toBeInstanceOf(UnbalancedEntryError);
const after = await prisma.journalEntry.count();
expect(after).toBe(before);
});
it('rejects an unbalanced write at the database layer even bypassing PostingService', async () => {
// Prove Layer 3 (the deferred trigger) is the real backstop: attempt to insert an
// unbalanced JournalEntryLine set directly via raw SQL, bypassing the service entirely.
//
// Note: with Prisma's interactive transactions, a commit failure caused by a DEFERRED
// constraint trigger is not always surfaced as a rejected promise (observed with
// @prisma/client 5.22 — the engine logs "transaction failed to commit" and correctly
// rolls back at the database level, but the JS promise can still resolve). So the
// authoritative assertion here is "nothing persisted", checked directly against the
// database after the attempt — not whether the promise rejected.
const marker = `TRIGGER-TEST-${Date.now()}`;
try {
await prisma.$transaction(async (tx) => {
const entry = await tx.journalEntry.create({
data: {
entryNumber: marker,
journalId: (await tx.journal.findUniqueOrThrow({ where: { code: 'GJ' } })).id,
fiscalPeriodId: (
await tx.fiscalPeriod.findFirstOrThrow({ where: { status: 'OPEN' } })
).id,
entryDate: new Date(),
description: 'Trigger bypass attempt — must be rejected at commit',
sourceModule: 'GL',
status: 'POSTED',
totalDebits: 100,
totalCredits: 100,
createdById: adminId,
},
});
await tx.$executeRawUnsafe(
`INSERT INTO "JournalEntryLine" ("journalEntryId", "lineNumber", "glAccountId", debit, credit, "currencyCode", "exchangeRateToBase")
VALUES ($1, 1, $2, 100, 0, 'BASE', 1)`,
entry.id,
bankHQ.id,
);
await tx.$executeRawUnsafe(
`INSERT INTO "JournalEntryLine" ("journalEntryId", "lineNumber", "glAccountId", debit, credit, "currencyCode", "exchangeRateToBase")
VALUES ($1, 2, $2, 0, 40, 'BASE', 1)`,
entry.id,
salesHQ.id,
);
});
} catch {
// Expected outcome on Prisma versions that do propagate the commit failure — fine either way.
}
const persisted = await prisma.journalEntry.findUnique({ where: { entryNumber: marker } });
expect(persisted).toBeNull();
});
});
+93
View File
@@ -0,0 +1,93 @@
import { InvalidLineError, UnbalancedEntryError } from '../../src/modules/gl/posting.types';
const transactionMock = jest.fn();
jest.mock('../../src/lib/prisma', () => ({
prisma: { $transaction: transactionMock },
}));
// eslint-disable-next-line import/first
import { postEntry } from '../../src/modules/gl/posting.service';
const baseDraft = {
journalCode: 'GJ',
entryDate: new Date('2026-01-15'),
description: 'Test entry',
sourceModule: 'GL' as const,
createdById: 1,
};
describe('PostingService.postEntry — pre-write validation (no DB required)', () => {
beforeEach(() => {
transactionMock.mockReset();
});
it('rejects an unbalanced entry before touching the database', async () => {
await expect(
postEntry({
...baseDraft,
lines: [
{ glAccountId: 1, debit: 100, credit: 0 },
{ glAccountId: 2, debit: 0, credit: 50 },
],
}),
).rejects.toBeInstanceOf(UnbalancedEntryError);
expect(transactionMock).not.toHaveBeenCalled();
});
it('rejects a line with both debit and credit set', async () => {
await expect(
postEntry({
...baseDraft,
lines: [
{ glAccountId: 1, debit: 100, credit: 100 },
{ glAccountId: 2, debit: 0, credit: 0 },
],
}),
).rejects.toBeInstanceOf(InvalidLineError);
expect(transactionMock).not.toHaveBeenCalled();
});
it('rejects a line with neither debit nor credit set', async () => {
await expect(
postEntry({
...baseDraft,
lines: [
{ glAccountId: 1, debit: 0, credit: 0 },
{ glAccountId: 2, debit: 100, credit: 100 },
],
}),
).rejects.toBeInstanceOf(InvalidLineError);
expect(transactionMock).not.toHaveBeenCalled();
});
it('rejects a negative amount', async () => {
await expect(
postEntry({
...baseDraft,
lines: [
{ glAccountId: 1, debit: -50, credit: 0 },
{ glAccountId: 2, debit: 0, credit: -50 },
],
}),
).rejects.toBeInstanceOf(InvalidLineError);
expect(transactionMock).not.toHaveBeenCalled();
});
it('rejects an entry with no lines', async () => {
await expect(postEntry({ ...baseDraft, lines: [] })).rejects.toBeInstanceOf(InvalidLineError);
expect(transactionMock).not.toHaveBeenCalled();
});
it('proceeds to the DB transaction once a balanced, valid draft is given', async () => {
transactionMock.mockResolvedValue({ id: 1 });
await postEntry({
...baseDraft,
lines: [
{ glAccountId: 1, debit: 100, credit: 0 },
{ glAccountId: 2, debit: 0, credit: 100 },
],
});
expect(transactionMock).toHaveBeenCalledTimes(1);
});
});