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

236 lines
10 KiB
TypeScript

/**
* 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);
});
});