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
236 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|