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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user