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