Adding all functionalities

This commit is contained in:
Muluhabt
2026-08-26 23:57:26 +03:00
parent 70171fa9d8
commit 1587620e14
62 changed files with 14331 additions and 357 deletions

View File

@@ -0,0 +1,416 @@
import { BadRequestException, ConflictException } from "@nestjs/common";
import { AssetsService } from "./assets.service";
import { DepreciationEntry, DepreciationRun, FixedAsset } from "./entities/fixed-asset.entity";
import type { ActorContext } from "../../common/current-actor.util";
const actor: ActorContext = {
employeeId: "emp-1",
userId: "user-1",
organizationId: "org-1",
isSuperAdmin: false,
};
/** A manager whose getRepository() dispatches to per-entity save/create/update spies. */
function makeManager() {
const savedRuns: unknown[] = [];
const savedEntries: unknown[] = [];
const assetUpdates: { id: string; patch: Record<string, unknown> }[] = [];
const runRepo = {
create: (data: Record<string, unknown>) => ({ id: "run-1", ...data }),
save: (data: Record<string, unknown>) => {
savedRuns.push(data);
return Promise.resolve(data);
},
};
const entryRepo = {
create: (data: Record<string, unknown>) => data,
save: (rows: Record<string, unknown>[]) => {
savedEntries.push(...rows);
return Promise.resolve(rows);
},
};
const assetRepo = {
update: (id: string, patch: Record<string, unknown>) => {
assetUpdates.push({ id, patch });
return Promise.resolve(undefined);
},
};
const manager = {
getRepository: (entity: unknown) => {
if (entity === DepreciationRun) return runRepo;
if (entity === DepreciationEntry) return entryRepo;
if (entity === FixedAsset) return assetRepo;
throw new Error("unexpected repository requested");
},
};
return { manager, savedRuns, savedEntries, assetUpdates };
}
describe("AssetsService.runDepreciation", () => {
function build(opts: {
period?: Record<string, unknown>;
existingRun?: Record<string, unknown> | null;
ledgerRows?: Record<string, unknown>[];
depreciable?: Record<string, unknown>[];
}) {
const period = opts.period ?? {
id: "period-1",
status: "OPEN",
endDate: "2026-01-31",
name: { en: "Jan 2026" },
fiscalYearId: "fy-2026",
};
const runs = {
findForPeriod: jest.fn().mockResolvedValue(opts.existingRun ?? null),
};
const managerBundle = makeManager();
const dataSource = {
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(managerBundle.manager)),
};
const journals = {
createPosted: jest
.fn()
.mockResolvedValue({ id: "je-1", entryNumber: "JE-2026-0001" }),
};
const assets = {
ledgerAccumulated: jest.fn().mockResolvedValue(
opts.ledgerRows ?? [
{ accountId: "acc-accum", ledgerAccumulated: 0, registerAccumulated: 0 },
],
),
findDepreciable: jest.fn().mockResolvedValue(
opts.depreciable ?? [
{
id: "asset-1",
assetCode: "AST-001",
acquisitionCost: 1000,
salvageValue: 0,
usefulLifeMonths: 10,
accumulatedDepreciation: 0,
inServiceDate: "2026-01-01",
depreciationMethod: "STRAIGHT_LINE",
status: "ACTIVE",
costCenterId: null,
expenseAccountId: "acc-expense",
accumulatedAccountId: "acc-accum",
periodsCharged: 0,
},
],
),
};
const periods = { findPeriod: jest.fn().mockResolvedValue(period) };
const service = new AssetsService(
{} as never, // categories
assets as never,
runs as never,
{} as never, // disposals
{} as never, // accounts
periods as never,
journals as never,
dataSource as never,
);
return { service, runs, assets, periods, journals, dataSource, ...managerBundle };
}
it("charges depreciation, posts ONE journal entry, and advances the asset's running total", async () => {
const { service, journals, assetUpdates } = build({});
const result = await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
expect(journals.createPosted).toHaveBeenCalledTimes(1);
const [, dto] = journals.createPosted.mock.calls[0];
expect(dto.lines).toHaveLength(2); // one debit (expense), one credit (accumulated)
expect(result.assetCount).toBe(1);
expect(result.total).toBe(100); // 1000 base / 10 months
expect(assetUpdates).toEqual([
{ id: "asset-1", patch: { accumulatedDepreciation: 100 } },
]);
});
it("summarizes multiple assets sharing the same (expense account, cost center) into ONE debit line, not one per asset", async () => {
const { service, journals } = build({
depreciable: [
{
id: "asset-1", assetCode: "AST-001", acquisitionCost: 1000, salvageValue: 0,
usefulLifeMonths: 10, accumulatedDepreciation: 0, inServiceDate: "2026-01-01",
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 0,
},
{
id: "asset-2", assetCode: "AST-002", acquisitionCost: 2000, salvageValue: 0,
usefulLifeMonths: 10, accumulatedDepreciation: 0, inServiceDate: "2026-01-01",
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 0,
},
],
});
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
const [, dto] = journals.createPosted.mock.calls[0];
expect(dto.lines).toHaveLength(2); // still just debit + credit, summed
const debitLine = dto.lines.find((l: { debit?: number }) => l.debit);
const creditLine = dto.lines.find((l: { credit?: number }) => l.credit);
expect(debitLine.debit).toBe(300); // 100 + 200
expect(creditLine.credit).toBe(300);
});
it("flips a fully-depreciated asset's status to FULLY_DEPRECIATED", async () => {
const { service, assetUpdates } = build({
depreciable: [
{
id: "asset-1", assetCode: "AST-001", acquisitionCost: 1000, salvageValue: 0,
usefulLifeMonths: 10, accumulatedDepreciation: 900, inServiceDate: "2026-01-01",
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 9,
},
],
});
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
expect(assetUpdates[0].patch).toMatchObject({
accumulatedDepreciation: 1000,
status: "FULLY_DEPRECIATED",
});
});
it("refuses a second run for the same period (idempotency)", async () => {
const { service, journals } = build({
existingRun: { id: "run-existing" },
});
await expect(
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
).rejects.toBeInstanceOf(ConflictException);
expect(journals.createPosted).not.toHaveBeenCalled();
});
it("refuses to charge a non-OPEN period", async () => {
const { service, journals } = build({
period: { id: "period-1", status: "CLOSED", endDate: "2026-01-31", name: { en: "Jan" }, fiscalYearId: "fy" },
});
await expect(
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
).rejects.toBeInstanceOf(BadRequestException);
expect(journals.createPosted).not.toHaveBeenCalled();
});
it("refuses when the asset register disagrees with the ledger by 0.005 or more", async () => {
const { service, journals } = build({
ledgerRows: [
{ accountId: "acc-accum", ledgerAccumulated: 100, registerAccumulated: 105 },
],
});
await expect(
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
).rejects.toBeInstanceOf(BadRequestException);
expect(journals.createPosted).not.toHaveBeenCalled();
});
it("tolerates sub-cent drift between register and ledger (< 0.005)", async () => {
const { service, journals } = build({
ledgerRows: [
{ accountId: "acc-accum", ledgerAccumulated: 100, registerAccumulated: 100.003 },
],
});
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
expect(journals.createPosted).toHaveBeenCalled();
});
it("refuses when nothing is due this period (every asset skipped)", async () => {
const { service, journals } = build({
depreciable: [
{
id: "asset-1", assetCode: "AST-001", acquisitionCost: 1000, salvageValue: 0,
usefulLifeMonths: 10, accumulatedDepreciation: 1000, inServiceDate: "2026-01-01",
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 10,
},
],
});
await expect(
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
).rejects.toBeInstanceOf(BadRequestException);
expect(journals.createPosted).not.toHaveBeenCalled();
});
it("passes the transaction manager through to journals.createPosted, so the entry, run, entries and asset updates all commit together", async () => {
const { service, journals, manager } = build({});
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
const [, , passedManager] = journals.createPosted.mock.calls[0];
expect(passedManager).toBe(manager);
});
});
describe("AssetsService.dispose", () => {
function fixedAsset(overrides: Record<string, unknown> = {}) {
return {
id: "asset-1",
organizationId: "org-1",
assetCode: "AST-001",
name: "Forklift",
assetCategoryId: "cat-1",
acquisitionCost: 1000,
salvageValue: 0,
accumulatedDepreciation: 700,
inServiceDate: "2026-01-01",
depreciationMethod: "STRAIGHT_LINE",
status: "ACTIVE",
...overrides,
};
}
function build(opts: {
asset?: Record<string, unknown>;
category?: Record<string, unknown> | null;
}) {
const assets = {
findById: jest.fn().mockResolvedValue(opts.asset ?? fixedAsset()),
update: jest.fn().mockResolvedValue(undefined),
};
const categories = {
findById: jest.fn().mockResolvedValue(
opts.category ?? {
id: "cat-1",
assetAccountId: "acc-asset",
accumulatedAccountId: "acc-accum",
expenseAccountId: "acc-expense",
},
),
};
const accounts = {
assertPostable: jest.fn().mockResolvedValue({ id: "acc-cash", code: "1110" }),
findByCode: jest.fn().mockImplementation((_actor, code: string) =>
Promise.resolve({ id: `acc-${code}`, code }),
),
};
const journals = {
createPosted: jest
.fn()
.mockResolvedValue({ id: "je-1", entryNumber: "JE-2026-0002" }),
};
const disposals = {
create: jest.fn().mockImplementation((data) => Promise.resolve({ id: "disposal-1", ...data })),
};
const service = new AssetsService(
categories as never,
assets as never,
{} as never, // runs
disposals as never,
accounts as never,
{} as never, // periods
journals as never,
{} as never, // dataSource
);
return { service, assets, categories, accounts, journals, disposals };
}
it("computes gain/loss as a plug and posts the disposal (gain case: proceeds > NBV)", async () => {
const { service, journals, assets } = build({});
// cost 1000, accumulated 700 -> NBV 300; proceeds 450 -> gain 150
const result = await service.dispose(actor, "asset-1", {
disposalDate: "2026-06-01",
disposalType: "SALE",
proceeds: 450,
proceedsAccountId: "acc-cash",
});
expect(result.netBookValue).toBe(300);
expect(result.gainLoss).toBe(150);
const [, dto] = journals.createPosted.mock.calls[0];
const creditGain = dto.lines.find((l: { accountId: string }) => l.accountId === "acc-4910");
expect(creditGain).toBeDefined();
expect(creditGain.credit).toBe(150);
expect(assets.update).toHaveBeenCalledWith("asset-1", { status: "DISPOSED" });
});
it("computes a loss and books it to the loss account (proceeds < NBV)", async () => {
const { service, journals } = build({});
// NBV 300; proceeds 100 -> loss 200
const result = await service.dispose(actor, "asset-1", {
disposalDate: "2026-06-01",
disposalType: "SALE",
proceeds: 100,
proceedsAccountId: "acc-cash",
});
expect(result.gainLoss).toBe(-200);
const [, dto] = journals.createPosted.mock.calls[0];
const debitLoss = dto.lines.find((l: { accountId: string }) => l.accountId === "acc-5900");
expect(debitLoss).toBeDefined();
expect(debitLoss.debit).toBe(200);
});
it("sets status to WRITTEN_OFF (not DISPOSED) for a write-off", async () => {
const { service, assets } = build({});
await service.dispose(actor, "asset-1", {
disposalDate: "2026-06-01",
disposalType: "WRITE_OFF",
proceeds: 0,
});
expect(assets.update).toHaveBeenCalledWith("asset-1", { status: "WRITTEN_OFF" });
});
it("refuses to dispose an asset that is already DISPOSED", async () => {
const { service } = build({ asset: fixedAsset({ status: "DISPOSED" }) });
await expect(
service.dispose(actor, "asset-1", { disposalDate: "2026-06-01", disposalType: "SALE", proceeds: 0 }),
).rejects.toBeInstanceOf(ConflictException);
});
it("refuses to dispose an asset that is already WRITTEN_OFF", async () => {
const { service } = build({ asset: fixedAsset({ status: "WRITTEN_OFF" }) });
await expect(
service.dispose(actor, "asset-1", { disposalDate: "2026-06-01", disposalType: "SALE", proceeds: 0 }),
).rejects.toBeInstanceOf(ConflictException);
});
it("refuses a disposal date before the asset entered service", async () => {
const { service } = build({ asset: fixedAsset({ inServiceDate: "2026-06-01" }) });
await expect(
service.dispose(actor, "asset-1", { disposalDate: "2026-01-01", disposalType: "SALE", proceeds: 0 }),
).rejects.toBeInstanceOf(BadRequestException);
});
it("requires a proceedsAccountId whenever proceeds > 0", async () => {
const { service } = build({});
await expect(
service.dispose(actor, "asset-1", { disposalDate: "2026-06-01", disposalType: "SALE", proceeds: 450 }),
).rejects.toBeInstanceOf(BadRequestException);
});
it("does not require a proceedsAccountId when proceeds is zero", async () => {
const { service, journals } = build({});
await service.dispose(actor, "asset-1", {
disposalDate: "2026-06-01",
disposalType: "WRITE_OFF",
proceeds: 0,
});
expect(journals.createPosted).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,170 @@
import {
depreciableBase,
depreciationFor,
depreciationSchedule,
disposalResult,
netBookValue,
type DepreciableAsset,
} from "./depreciation.calculator";
function asset(overrides: Partial<DepreciableAsset> = {}): DepreciableAsset {
return {
acquisitionCost: 1000,
salvageValue: 0,
usefulLifeMonths: 3,
accumulatedDepreciation: 0,
inServiceDate: "2026-01-01",
depreciationMethod: "STRAIGHT_LINE",
status: "ACTIVE",
periodsCharged: 0,
...overrides,
};
}
describe("depreciationFor — the cumulative-target method", () => {
it("guards against the naive fixed-monthly bug: 1000 over 3 months gives 333.33/333.34/333.33, not 333.33 x3 (which would lose a cent)", () => {
let a = asset();
let periodEnd = "2026-01-31";
const c1 = depreciationFor(a, periodEnd);
expect(c1.amount).toBe(333.33);
a = { ...a, accumulatedDepreciation: c1.accumulatedAfter, periodsCharged: 1 };
periodEnd = "2026-02-28";
const c2 = depreciationFor(a, periodEnd);
expect(c2.amount).toBe(333.34); // the rounding remainder lands here, not lost
a = { ...a, accumulatedDepreciation: c2.accumulatedAfter, periodsCharged: 2 };
periodEnd = "2026-03-31";
const c3 = depreciationFor(a, periodEnd);
expect(c3.amount).toBe(333.33);
a = { ...a, accumulatedDepreciation: c3.accumulatedAfter, periodsCharged: 3 };
expect(roundSum([c1.amount, c2.amount, c3.amount])).toBe(1000);
expect(a.accumulatedDepreciation).toBe(1000);
expect(c3.fullyDepreciated).toBe(true);
});
it("a full life's schedule sums EXACTLY to the depreciable base, for an odd (non-dividing) life/cost combination", () => {
const a = asset({ acquisitionCost: 10000, salvageValue: 500, usefulLifeMonths: 7 });
const base = depreciableBase(a);
expect(base).toBe(9500);
const schedule = depreciationSchedule(a);
expect(schedule).toHaveLength(7);
const total = roundSum(schedule.map((s) => s.amount));
expect(total).toBe(base);
// Monotonically increasing accumulated total, ending exactly at the base.
expect(schedule[schedule.length - 1].accumulated).toBe(base);
expect(schedule[schedule.length - 1].netBookValue).toBe(
Math.round((a.acquisitionCost - base) * 100) / 100,
);
});
it("charges nothing and refuses (skipReason) for a non-STRAIGHT_LINE method", () => {
const a = asset({ depreciationMethod: "DECLINING_BALANCE" });
const charge = depreciationFor(a, "2026-01-31");
expect(charge.amount).toBe(0);
expect(charge.skipReason).toMatch(/not implemented/);
});
it("charges nothing for a DISPOSED asset", () => {
const a = asset({ status: "DISPOSED" });
const charge = depreciationFor(a, "2026-01-31");
expect(charge.amount).toBe(0);
expect(charge.skipReason).toBe("asset is DISPOSED");
});
it("charges nothing for a WRITTEN_OFF asset", () => {
const a = asset({ status: "WRITTEN_OFF" });
const charge = depreciationFor(a, "2026-01-31");
expect(charge.amount).toBe(0);
expect(charge.skipReason).toBe("asset is WRITTEN_OFF");
});
it("charges nothing for an asset not yet in service by the period end", () => {
const a = asset({ inServiceDate: "2026-05-01" });
const charge = depreciationFor(a, "2026-01-31");
expect(charge.amount).toBe(0);
expect(charge.skipReason).toMatch(/not in service until/);
});
it("charges nothing once the asset is already fully depreciated", () => {
const a = asset({ accumulatedDepreciation: 1000, periodsCharged: 3 });
const charge = depreciationFor(a, "2026-04-30");
expect(charge.amount).toBe(0);
expect(charge.fullyDepreciated).toBe(true);
expect(charge.skipReason).toBe("fully depreciated");
});
it("caps periodsAfter at usefulLifeMonths even if periodsCharged somehow exceeds it, so it never charges past the base", () => {
const a = asset({
accumulatedDepreciation: 999.99,
periodsCharged: 3, // already at life
});
const charge = depreciationFor(a, "2026-04-30");
// remaining = 1000 - 999.99 = 0.01, target capped at base (1000)
expect(charge.amount).toBe(0.01);
expect(charge.accumulatedAfter).toBe(1000);
expect(charge.fullyDepreciated).toBe(true);
});
it("first-month charge for an asset entering service exactly on the period end (full-month convention)", () => {
const a = asset({ inServiceDate: "2026-01-31" });
const charge = depreciationFor(a, "2026-01-31");
expect(charge.amount).toBe(333.33);
});
});
describe("depreciationSchedule", () => {
it("stops emitting rows once nothing further is due (no trailing zero-amount rows)", () => {
const a = asset({ usefulLifeMonths: 3 });
const schedule = depreciationSchedule(a);
expect(schedule.every((s) => s.amount > 0)).toBe(true);
expect(schedule).toHaveLength(3);
});
});
describe("netBookValue / depreciableBase", () => {
it("netBookValue is cost minus accumulated depreciation", () => {
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 400 });
expect(netBookValue(a)).toBe(600);
});
it("depreciableBase is cost minus salvage value, independent of what has accumulated", () => {
const a = asset({ acquisitionCost: 1000, salvageValue: 200, accumulatedDepreciation: 400 });
expect(depreciableBase(a)).toBe(800);
});
});
describe("disposalResult — gain/loss is a computed plug", () => {
it("is a gain when proceeds exceed net book value", () => {
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
const result = disposalResult(a, 450);
expect(result.netBookValue).toBe(300);
expect(result.gainLoss).toBe(150);
});
it("is a loss when proceeds are below net book value", () => {
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
const result = disposalResult(a, 100);
expect(result.gainLoss).toBe(-200);
});
it("a scrap with zero proceeds is a loss equal to the remaining net book value", () => {
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
const result = disposalResult(a, 0);
expect(result.gainLoss).toBe(-300);
});
it("proceeds exactly equal to net book value produce neither gain nor loss", () => {
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
const result = disposalResult(a, 300);
expect(result.gainLoss).toBe(0);
});
});
/** Sums an array of already-rounded money amounts and rounds once, at the end. */
function roundSum(amounts: number[]): number {
return Math.round(amounts.reduce((s, a) => s + a, 0) * 100) / 100;
}

View File

@@ -0,0 +1,140 @@
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from "@nestjs/common";
import { BudgetingService } from "./budgeting.service";
import type { ActorContext } from "../../common/current-actor.util";
/**
* `BudgetingService.approveBudget` — only DRAFT→APPROVED, refuses an empty
* budget, and enforces at most one APPROVED budget per fiscal year (also
* backed by a DB unique index, but the service-level guard is what's under
* test here).
*/
describe("BudgetingService.approveBudget", () => {
const actor: ActorContext = {
employeeId: "emp-1",
userId: "user-1",
organizationId: "org-1",
isSuperAdmin: false,
};
function draftBudget(overrides: Record<string, unknown> = {}) {
return {
id: "budget-1",
organizationId: "org-1",
fiscalYearId: "fy-2026",
name: "FY2026 Budget",
status: "DRAFT",
...overrides,
};
}
function build(opts: {
budget?: Record<string, unknown> | null;
lines?: unknown[];
approvedForYear?: Record<string, unknown> | null;
}) {
// `"budget" in opts` distinguishes "not provided" (use the default draft)
// from an explicit `null` (simulate a missing/foreign-org row) — `??`
// would treat both the same and mask the not-found case.
let current: Record<string, unknown> | null =
"budget" in opts ? (opts.budget as Record<string, unknown> | null) : draftBudget();
const budgets = {
findById: jest.fn().mockImplementation(() => Promise.resolve(current)),
findApprovedForYear: jest
.fn()
.mockResolvedValue(opts.approvedForYear ?? null),
update: jest.fn().mockImplementation((_id, patch) => {
current = { ...(current as Record<string, unknown>), ...patch };
return Promise.resolve(current);
}),
};
const budgetLines = {
findByBudget: jest.fn().mockResolvedValue(opts.lines ?? [{ id: "line-1" }]),
};
const service = new BudgetingService(
{} as never, // costCenters
budgets as never,
budgetLines as never,
{} as never, // accounts
{} as never, // periods
{} as never, // dataSource
);
return { service, budgets, budgetLines };
}
it("approves a DRAFT budget with lines and no prior approval for the year", async () => {
const { service, budgets } = build({});
const result = await service.approveBudget(actor, "budget-1");
expect(budgets.update).toHaveBeenCalledWith(
"budget-1",
expect.objectContaining({
status: "APPROVED",
approvedBy: "emp-1",
approvedAt: expect.any(Date),
}),
);
expect(result.status).toBe("APPROVED");
});
it("refuses to approve a budget that has no lines", async () => {
const { service, budgets } = build({ lines: [] });
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
BadRequestException,
);
expect(budgets.update).not.toHaveBeenCalled();
});
it("refuses a second APPROVED budget for the same fiscal year", async () => {
const { service, budgets } = build({
approvedForYear: draftBudget({ id: "budget-existing", status: "APPROVED", name: "Already approved" }),
});
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
ConflictException,
);
expect(budgets.update).not.toHaveBeenCalled();
});
it("refuses to approve a budget that is not DRAFT (e.g. already APPROVED)", async () => {
const { service, budgets } = build({
budget: draftBudget({ status: "APPROVED" }),
});
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(budgets.update).not.toHaveBeenCalled();
});
it("refuses a budget that does not exist", async () => {
const { service } = build({ budget: null });
await expect(service.approveBudget(actor, "missing")).rejects.toBeInstanceOf(
NotFoundException,
);
});
it("hides a budget belonging to another organization from a non-super-admin (404, not 403)", async () => {
const { service } = build({
budget: draftBudget({ organizationId: "org-OTHER" }),
});
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
NotFoundException,
);
});
it("lets a super admin approve a budget outside their own organization", async () => {
const superAdmin: ActorContext = { ...actor, isSuperAdmin: true, organizationId: "" };
const { service, budgets } = build({
budget: draftBudget({ organizationId: "org-OTHER" }),
});
await service.approveBudget(superAdmin, "budget-1");
expect(budgets.update).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,536 @@
import { BadRequestException } from "@nestjs/common";
import { CutoverService } from "./cutover.service";
import type { ActorContext } from "../../common/current-actor.util";
/**
* All fixture values below are synthetic — invented account codes
* (`TEST-...`), round test amounts, and a made-up organization id. None of
* this resembles any real organization's chart of accounts or balances; the
* plan this suite implements requires cutover testing to use synthetic data
* only.
*/
const ORG_ID = "org-synthetic-1";
function actor(overrides: Partial<ActorContext> = {}): ActorContext {
return {
employeeId: "emp-1",
userId: "user-1",
organizationId: ORG_ID,
isSuperAdmin: false,
...overrides,
};
}
function makeSettingsRepo(row: Record<string, unknown> | null) {
return {
findOne: jest.fn().mockResolvedValue(row),
save: jest.fn(async (r: Record<string, unknown>) => ({ id: "settings-1", ...r })),
create: jest.fn((r: Record<string, unknown>) => r),
};
}
function makeAccounts() {
return { findByCode: jest.fn() };
}
function makeJournals() {
return { create: jest.fn(), post: jest.fn() };
}
function makeDataSource(queryImpl: (...args: unknown[]) => unknown) {
return { query: jest.fn(queryImpl) };
}
describe("CutoverService.readiness", () => {
let settings: ReturnType<typeof makeSettingsRepo>;
let accounts: ReturnType<typeof makeAccounts>;
let journals: ReturnType<typeof makeJournals>;
let dataSource: { query: jest.Mock };
let service: CutoverService;
/**
* The service issues, in order: suspense query, [pre-cutover query if a date
* is set], asset-register query, [migrated-assets query if assets exist].
* Each test configures a queue of responses consumed in that call order.
*/
function queueQueries(responses: unknown[]) {
let i = 0;
dataSource = makeDataSource(() => Promise.resolve(responses[i++] ?? []));
}
beforeEach(() => {
accounts = makeAccounts();
journals = makeJournals();
});
function build(settingsRow: Record<string, unknown> | null) {
settings = makeSettingsRepo(settingsRow);
service = new CutoverService(
settings as never,
accounts as never,
journals as never,
dataSource as never,
);
}
it("PENDING on the date check when no cutover date is set, and skips the pre-cutover check entirely", async () => {
queueQueries([
[{ balance: "0", lines: "0" }], // suspense: nothing posted yet
[{ register: "0", assets: "0" }], // asset register: empty
]);
build(null);
const result = await service.readiness(actor());
const dateCheck = result.checks.find((c) => c.key === "cutover_date")!;
expect(dateCheck.status).toBe("PENDING");
expect(result.checks.find((c) => c.key === "no_pre_cutover")).toBeUndefined();
expect(result.ready).toBe(false);
});
it("PASS on the date check once a cutover date is set", async () => {
queueQueries([
[{ balance: "0", lines: "0" }],
[{ n: "0", first: null }],
[{ register: "0", assets: "0" }],
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const dateCheck = result.checks.find((c) => c.key === "cutover_date")!;
expect(dateCheck.status).toBe("PASS");
expect(dateCheck.detail).toContain("2026-07-08");
});
// ── Suspense: DRAFT-only vs posted-nonzero ──────────────────────────────
it("suspense reads PENDING when only DRAFT (unposted) lines exist — not FAIL", async () => {
// lines === 0 because the posted-only EXISTS filter excludes the DRAFT
// batch's lines entirely (the LEFT JOIN keeps the account row, the filter
// nulls only the entry side).
queueQueries([
[{ balance: "0", lines: "0" }],
[{ n: "0", first: null }],
[{ register: "0", assets: "0" }],
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const suspense = result.checks.find((c) => c.key === "suspense")!;
expect(suspense.status).toBe("PENDING");
expect(suspense.detail).toMatch(/nothing has been posted/i);
});
it("suspense FAILs with the exact drift amount when POSTED lines leave a nonzero balance", async () => {
queueQueries([
[{ balance: "125.50", lines: "3" }],
[{ n: "0", first: null }],
[{ register: "0", assets: "0" }],
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const suspense = result.checks.find((c) => c.key === "suspense")!;
expect(suspense.status).toBe("FAIL");
expect(suspense.detail).toContain("125.50");
expect(result.ready).toBe(false);
});
it("suspense PASSes when POSTED lines net to exactly zero", async () => {
queueQueries([
[{ balance: "0", lines: "4" }],
[{ n: "0", first: null }],
[{ register: "0", assets: "0" }],
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const suspense = result.checks.find((c) => c.key === "suspense")!;
expect(suspense.status).toBe("PASS");
expect(suspense.detail).toContain("4 line(s)");
});
// ── No non-OPENING entries before cutover ───────────────────────────────
it("PASS when nothing predates the cutover date", async () => {
queueQueries([
[{ balance: "0", lines: "2" }],
[{ n: "0", first: null }],
[{ register: "0", assets: "0" }],
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const early = result.checks.find((c) => c.key === "no_pre_cutover")!;
expect(early.status).toBe("PASS");
});
it("FAILs and names the count and earliest date when non-OPENING entries predate cutover", async () => {
queueQueries([
[{ balance: "0", lines: "2" }],
[{ n: "3", first: "2026-06-01" }],
[{ register: "0", assets: "0" }],
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const early = result.checks.find((c) => c.key === "no_pre_cutover")!;
expect(early.status).toBe("FAIL");
expect(early.detail).toContain("3");
expect(early.detail).toContain("2026-06-01");
expect(result.ready).toBe(false);
});
// ── Asset register agrees with the ledger ───────────────────────────────
it("asset register check is PENDING when the register is empty", async () => {
queueQueries([
[{ balance: "0", lines: "2" }],
[{ n: "0", first: null }],
[{ register: "0", assets: "0" }],
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
expect(assetCheck.status).toBe("PENDING");
expect(result.checks.find((c) => c.key === "migrated_assets")).toBeUndefined();
});
it("PASSes when the register and the ledger's contra-ASSET balance agree within 0.005", async () => {
queueQueries([
[{ balance: "0", lines: "2" }],
[{ n: "0", first: null }],
[{ register: "1000.00", assets: "2" }],
[{ balance: "1000.00" }], // ledger contra-asset balance
[{ n: "0", codes: null }], // migrated-assets sub-check: clean
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
expect(assetCheck.status).toBe("PASS");
expect(assetCheck.detail).toContain("1000.00");
});
it("FAILs with the register, ledger and drift figures when they disagree", async () => {
queueQueries([
[{ balance: "0", lines: "2" }],
[{ n: "0", first: null }],
[{ register: "1000.00", assets: "2" }],
[{ balance: "960.00" }],
[{ n: "0", codes: null }],
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
expect(assetCheck.status).toBe("FAIL");
expect(assetCheck.detail).toContain("1000.00");
expect(assetCheck.detail).toContain("960.00");
expect(assetCheck.detail).toContain("40.00");
});
it("migrated-assets sub-check FAILs distinctly for assets carrying accumulated depreciation with zero opening periods and no depreciation history", async () => {
queueQueries([
[{ balance: "0", lines: "2" }],
[{ n: "0", first: null }],
[{ register: "1000.00", assets: "2" }],
[{ balance: "1000.00" }], // register/ledger agree — this check PASSes
[{ n: "1", codes: "TEST-ASSET-01" }], // but a migrated asset is stalled
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
const migrated = result.checks.find((c) => c.key === "migrated_assets")!;
expect(assetCheck.status).toBe("PASS");
expect(migrated.status).toBe("FAIL");
expect(migrated.detail).toContain("TEST-ASSET-01");
expect(result.ready).toBe(false);
});
it("ready is true only when every check is PASS", async () => {
queueQueries([
[{ balance: "0", lines: "2" }],
[{ n: "0", first: null }],
[{ register: "500.00", assets: "1" }],
[{ balance: "500.00" }],
[{ n: "0", codes: null }],
]);
build({ cutoverDate: "2026-07-08", cutoverNote: null });
const result = await service.readiness(actor());
expect(result.checks.every((c) => c.status === "PASS")).toBe(true);
expect(result.ready).toBe(true);
});
it("resolves the organization from the token, not a client-supplied param, for a non-super-admin", async () => {
queueQueries([
[{ balance: "0", lines: "0" }],
[{ register: "0", assets: "0" }],
]);
build(null);
await expect(
service.readiness(actor({ isSuperAdmin: false }), "some-other-org"),
).rejects.toThrow(/only read or set the cutover for your own organization/i);
});
});
describe("CutoverService.importOpeningBalances", () => {
let settings: ReturnType<typeof makeSettingsRepo>;
let accounts: ReturnType<typeof makeAccounts>;
let journals: ReturnType<typeof makeJournals>;
let dataSource: { query: jest.Mock };
let service: CutoverService;
function testAccount(overrides: Record<string, unknown> = {}) {
return {
id: "acct-" + Math.random().toString(36).slice(2),
code: "TEST-1000",
isGroup: false,
isActive: true,
...overrides,
};
}
beforeEach(() => {
settings = makeSettingsRepo(null);
accounts = makeAccounts();
journals = makeJournals();
dataSource = { query: jest.fn() };
service = new CutoverService(
settings as never,
accounts as never,
journals as never,
dataSource as never,
);
journals.create.mockResolvedValue({
id: "entry-1",
status: "DRAFT",
entryNumber: "OPEN-2026-0001",
});
});
const dto = (lines: Record<string, unknown>[], overrides: Record<string, unknown> = {}) => ({
entryDate: "2026-07-08",
lines,
...overrides,
});
it("rejects a caller-supplied 3900 suspense line outright", async () => {
accounts.findByCode.mockResolvedValue(testAccount({ code: "TEST-1000" }));
await expect(
service.importOpeningBalances(
actor(),
dto([{ accountCode: "3900", debit: 100 }]) as never,
),
).rejects.toThrow(/is calculated, not entered/i);
expect(journals.create).not.toHaveBeenCalled();
});
it("rejects a group account", async () => {
accounts.findByCode.mockResolvedValue(testAccount({ isGroup: true }));
await expect(
service.importOpeningBalances(
actor(),
dto([{ accountCode: "TEST-1000", debit: 100 }]) as never,
),
).rejects.toThrow(/group account/i);
});
it("rejects an inactive account", async () => {
accounts.findByCode.mockResolvedValue(testAccount({ isActive: false }));
await expect(
service.importOpeningBalances(
actor(),
dto([{ accountCode: "TEST-1000", debit: 100 }]) as never,
),
).rejects.toThrow(/inactive/i);
});
it("rejects an unknown account code", async () => {
accounts.findByCode.mockResolvedValue(null);
await expect(
service.importOpeningBalances(
actor(),
dto([{ accountCode: "TEST-NOPE", debit: 100 }]) as never,
),
).rejects.toThrow(/no such account/i);
});
it("rejects a line carrying both debit and credit", async () => {
accounts.findByCode.mockResolvedValue(testAccount());
await expect(
service.importOpeningBalances(
actor(),
dto([{ accountCode: "TEST-1000", debit: 100, credit: 50 }]) as never,
),
).rejects.toThrow(/never both/i);
});
it("rejects a line carrying neither debit nor credit", async () => {
accounts.findByCode.mockResolvedValue(testAccount());
await expect(
service.importOpeningBalances(
actor(),
dto([{ accountCode: "TEST-1000" }]) as never,
),
).rejects.toThrow(/no amount/i);
});
it("computes the suspense plug as totalDebit - totalCredit and credits suspense when debits exceed credits", async () => {
// Synthetic unbalanced batch: 700 debit vs 300 credit → plug of 400,
// which must land as a 400 CREDIT to suspense (debits were the larger side).
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
const suspense = testAccount({ id: "acct-3900", code: "3900" });
accounts.findByCode.mockImplementation((_org: string, code: string) => {
if (code === "TEST-1000") return Promise.resolve(cash);
if (code === "TEST-3000") return Promise.resolve(equity);
if (code === "3900") return Promise.resolve(suspense);
return Promise.resolve(null);
});
const result = await service.importOpeningBalances(
actor(),
dto([
{ accountCode: "TEST-1000", debit: 700 },
{ accountCode: "TEST-3000", credit: 300 },
]) as never,
);
expect(result.suspensePlug).toBe(400);
expect(result.totalDebit).toBe(700);
expect(result.totalCredit).toBe(300);
const [, createDto] = journals.create.mock.calls[0];
const plugLine = createDto.lines.find(
(l: { accountId: string }) => l.accountId === "acct-3900",
);
expect(plugLine).toBeDefined();
expect(plugLine.debit).toBe(0);
expect(plugLine.credit).toBe(400);
});
it("computes the suspense plug as a DEBIT to suspense when credits exceed debits", async () => {
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
const suspense = testAccount({ id: "acct-3900", code: "3900" });
accounts.findByCode.mockImplementation((_org: string, code: string) => {
if (code === "TEST-1000") return Promise.resolve(cash);
if (code === "TEST-3000") return Promise.resolve(equity);
if (code === "3900") return Promise.resolve(suspense);
return Promise.resolve(null);
});
const result = await service.importOpeningBalances(
actor(),
dto([
{ accountCode: "TEST-1000", debit: 300 },
{ accountCode: "TEST-3000", credit: 700 },
]) as never,
);
expect(result.suspensePlug).toBe(-400);
const [, createDto] = journals.create.mock.calls[0];
const plugLine = createDto.lines.find(
(l: { accountId: string }) => l.accountId === "acct-3900",
);
expect(plugLine.debit).toBe(400);
expect(plugLine.credit).toBe(0);
});
it("omits the suspense line entirely when the batch already balances", async () => {
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
accounts.findByCode.mockImplementation((_org: string, code: string) => {
if (code === "TEST-1000") return Promise.resolve(cash);
if (code === "TEST-3000") return Promise.resolve(equity);
return Promise.resolve(null);
});
const result = await service.importOpeningBalances(
actor(),
dto([
{ accountCode: "TEST-1000", debit: 500 },
{ accountCode: "TEST-3000", credit: 500 },
]) as never,
);
expect(result.suspensePlug).toBe(0);
const [, createDto] = journals.create.mock.calls[0];
expect(createDto.lines).toHaveLength(2);
});
it("creates via journals.create producing a DRAFT and never calls journals.post directly", async () => {
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
accounts.findByCode.mockImplementation((_org: string, code: string) => {
if (code === "TEST-1000") return Promise.resolve(cash);
if (code === "TEST-3000") return Promise.resolve(equity);
return Promise.resolve(null);
});
const result = await service.importOpeningBalances(
actor(),
dto([
{ accountCode: "TEST-1000", debit: 500 },
{ accountCode: "TEST-3000", credit: 500 },
]) as never,
);
expect(journals.create).toHaveBeenCalledTimes(1);
const [, createDto] = journals.create.mock.calls[0];
expect(createDto.journalType).toBe("OPENING");
expect(result.entry.status).toBe("DRAFT");
expect(journals.post).not.toHaveBeenCalled();
});
it("raises a BadRequestException if the suspense account is missing from the chart when a plug is needed", async () => {
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
accounts.findByCode.mockImplementation((_org: string, code: string) => {
if (code === "TEST-1000") return Promise.resolve(cash);
if (code === "TEST-3000") return Promise.resolve(equity);
if (code === "3900") return Promise.resolve(null);
return Promise.resolve(null);
});
await expect(
service.importOpeningBalances(
actor(),
dto([
{ accountCode: "TEST-1000", debit: 700 },
{ accountCode: "TEST-3000", credit: 300 },
]) as never,
),
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -0,0 +1,582 @@
import { BadRequestException, ForbiddenException } from "@nestjs/common";
import type { ActorContext } from "../../common/current-actor.util";
import { JournalsService } from "./journals.service";
import { JournalEntry } from "./entities/journal-entry.entity";
/**
* Mirrors edr-freight-api's convention: plain `new ServiceClass(...)` with
* hand-built fakes passed positionally. No TestingModule, no real DB.
*/
const actor: ActorContext = {
employeeId: "emp-1",
userId: "user-1",
organizationId: "org-1",
isSuperAdmin: false,
};
function makeManager() {
const entryRepo = {
create: (data: Record<string, unknown>) => data,
save: jest
.fn()
.mockImplementation(async (data: Record<string, unknown>) => ({
id: (data.id as string) ?? "entry-1",
...data,
})),
update: jest.fn().mockResolvedValue(undefined),
};
const lineRepo = {
create: (data: Record<string, unknown>) => data,
save: jest.fn().mockImplementation(async (data: unknown) => data),
};
return {
getRepository: (entity: unknown) =>
entity === JournalEntry ? entryRepo : lineRepo,
query: jest.fn().mockResolvedValue([]),
entryRepo,
lineRepo,
};
}
function build() {
const entries = {
findById: jest.fn(),
findPage: jest.fn(),
nextEntryNumber: jest.fn().mockResolvedValue("JV-2026-000001"),
findBySource: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
hardDelete: jest.fn(),
};
const lines = {
findByEntry: jest.fn(),
findByEntryWithAccounts: jest.fn().mockResolvedValue([]),
};
const accounts = { assertPostable: jest.fn() };
const periods = { resolveOpenPeriodForDate: jest.fn() };
const manager = makeManager();
const dataSource = {
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
manager,
};
const service = new JournalsService(
entries as never,
lines as never,
accounts as never,
periods as never,
dataSource as never,
);
return { service, entries, lines, accounts, periods, dataSource, manager };
}
describe("JournalsService.post", () => {
const draftEntry = (over: Record<string, unknown> = {}) => ({
id: "entry-1",
entryNumber: "JV-2026-000001",
organizationId: "org-1",
entryDate: "2026-08-15",
status: "DRAFT",
totalDebit: 100,
totalCredit: 100,
...over,
});
const twoLines = () => [
{ id: "line-1", accountId: "acc-1", debit: 100, credit: 0 },
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 100 },
];
it("refuses posting an entry that is not DRAFT", async () => {
const { service, entries } = build();
entries.findById.mockResolvedValue(draftEntry({ status: "POSTED" }));
await expect(service.post(actor, "entry-1")).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it("requires at least two lines", async () => {
const { service, entries, lines, accounts, periods } = build();
entries.findById.mockResolvedValue(draftEntry());
lines.findByEntry.mockResolvedValue([twoLines()[0]]);
await expect(service.post(actor, "entry-1")).rejects.toBeInstanceOf(
BadRequestException,
);
expect(accounts.assertPostable).not.toHaveBeenCalled();
expect(periods.resolveOpenPeriodForDate).not.toHaveBeenCalled();
});
it("re-validates every line's account via accounts.assertPostable AT POSTING TIME — an account deactivated after the draft was written fails posting", async () => {
const { service, entries, lines, accounts, periods } = build();
entries.findById.mockResolvedValue(draftEntry());
lines.findByEntry.mockResolvedValue(twoLines());
// acc-1 was postable when the draft was written; acc-2 has since been
// deactivated. Nothing about the draft itself changed.
accounts.assertPostable.mockImplementation(
async (_a: unknown, accountId: string) => {
if (accountId === "acc-2") {
throw new BadRequestException(
"ACC-2 is inactive and cannot accept new postings",
);
}
return { id: accountId, code: accountId, isActive: true, isGroup: false };
},
);
await expect(service.post(actor, "entry-1")).rejects.toThrow(/inactive/);
// Never got as far as re-resolving the period or writing the update.
expect(periods.resolveOpenPeriodForDate).not.toHaveBeenCalled();
expect(entries.update).not.toHaveBeenCalled();
});
it("re-derives totals from the lines as they stand now, not the stored (possibly stale) header totals", async () => {
const { service, entries, lines, accounts, periods } = build();
// The header claims 100/100 from create time; the lines underneath it no
// longer agree.
entries.findById.mockResolvedValue(
draftEntry({ totalDebit: 100, totalCredit: 100 }),
);
lines.findByEntry.mockResolvedValue([
{ id: "line-1", accountId: "acc-1", debit: 150, credit: 0 },
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 100 },
]);
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({
id,
isActive: true,
isGroup: false,
}));
await expect(service.post(actor, "entry-1")).rejects.toThrow(
"Entry does not balance: debits 150.00, credits 100.00 — a difference of 50.00",
);
expect(periods.resolveOpenPeriodForDate).not.toHaveBeenCalled();
expect(entries.update).not.toHaveBeenCalled();
});
it("re-resolves the fiscal period from the entry date — a draft written before month-end close cannot slip into the now-closed period", async () => {
const { service, entries, lines, accounts, periods } = build();
entries.findById.mockResolvedValue(draftEntry({ entryDate: "2026-07-30" }));
lines.findByEntry.mockResolvedValue(twoLines());
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({
id,
isActive: true,
isGroup: false,
}));
periods.resolveOpenPeriodForDate.mockRejectedValue(
new BadRequestException(
"July 2026 is CLOSED — nothing further can be posted into it.",
),
);
await expect(service.post(actor, "entry-1")).rejects.toThrow(/CLOSED/);
expect(periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
"org-1",
"2026-07-30",
);
expect(entries.update).not.toHaveBeenCalled();
});
it("posts successfully: status POSTED, re-derived totals, the freshly-resolved period, and postedBy/postedAt stamped", async () => {
const { service, entries, lines, accounts, periods } = build();
entries.findById.mockResolvedValue(draftEntry());
lines.findByEntry.mockResolvedValue(twoLines());
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({
id,
isActive: true,
isGroup: false,
}));
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-2" });
await service.post(actor, "entry-1");
expect(entries.update).toHaveBeenCalledWith("entry-1", {
status: "POSTED",
fiscalPeriodId: "period-2",
totalDebit: 100,
totalCredit: 100,
postedBy: "emp-1",
postedAt: expect.any(Date),
});
});
});
describe("JournalsService.reverse", () => {
const originalEntry = (over: Record<string, unknown> = {}) => ({
id: "orig-1",
entryNumber: "JV-2026-000001",
organizationId: "org-1",
entryDate: "2026-01-10",
status: "POSTED",
reversedByEntryId: null,
totalDebit: 100,
totalCredit: 100,
reference: "REF-1",
sourceModule: "supplier-bill",
sourceId: "bill-42",
...over,
});
const originalLines = [
{ accountId: "acc-1", debit: 100, credit: 0, description: "Expense", costCenterId: null },
{ accountId: "acc-2", debit: 0, credit: 100, description: "Payable", costCenterId: null },
];
/** entries.findById is called once for the original, then once more inside
* the closing findOne() for the freshly-created reversal. */
function wireOriginalThenReversal(
entries: ReturnType<typeof build>["entries"],
original: ReturnType<typeof originalEntry>,
) {
entries.findById
.mockResolvedValueOnce(original)
.mockResolvedValueOnce({ id: "entry-1", organizationId: "org-1" });
}
it("refuses reversing a DRAFT — nothing has affected any balance yet", async () => {
const { service, entries, dataSource } = build();
entries.findById.mockResolvedValue(originalEntry({ status: "DRAFT" }));
await expect(
service.reverse(actor, "orig-1", { reason: "typo" }),
).rejects.toBeInstanceOf(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("refuses reversing an entry that has already been reversed (reversedByEntryId already set)", async () => {
const { service, entries, dataSource } = build();
entries.findById.mockResolvedValue(
originalEntry({ reversedByEntryId: "some-other-reversal" }),
);
await expect(
service.reverse(actor, "orig-1", { reason: "typo" }),
).rejects.toThrow(/already been reversed/);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("also refuses when status is already REVERSED", async () => {
const { service, entries, dataSource } = build();
entries.findById.mockResolvedValue(originalEntry({ status: "REVERSED" }));
await expect(
service.reverse(actor, "orig-1", { reason: "typo" }),
).rejects.toThrow(/already been reversed/);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("dates the reversal TODAY by default — never backdated onto the original's date", async () => {
const { service, entries, lines, periods, manager } = build();
const original = originalEntry({ entryDate: "2026-01-10" });
wireOriginalThenReversal(entries, original);
lines.findByEntry.mockResolvedValue(originalLines);
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
await service.reverse(actor, "orig-1", { reason: "correction" });
const expectedToday = new Date().toISOString().slice(0, 10);
expect(periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
"org-1",
expectedToday,
);
const savedArg = manager.entryRepo.save.mock.calls[0][0];
expect(savedArg.entryDate).toBe(expectedToday);
expect(savedArg.entryDate).not.toBe(original.entryDate);
});
it("uses an explicit reversalDate when supplied, instead of today", async () => {
const { service, entries, lines, periods, manager } = build();
wireOriginalThenReversal(entries, originalEntry());
lines.findByEntry.mockResolvedValue(originalLines);
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
await service.reverse(actor, "orig-1", {
reason: "correction",
reversalDate: "2026-02-01",
});
expect(periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
"org-1",
"2026-02-01",
);
const savedArg = manager.entryRepo.save.mock.calls[0][0];
expect(savedArg.entryDate).toBe("2026-02-01");
});
it("mirrors every line — debit and credit swapped", async () => {
const { service, entries, lines, periods, manager } = build();
wireOriginalThenReversal(entries, originalEntry());
lines.findByEntry.mockResolvedValue(originalLines);
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
await service.reverse(actor, "orig-1", { reason: "correction" });
const savedLines = manager.lineRepo.save.mock.calls[0][0];
expect(savedLines).toEqual([
expect.objectContaining({ accountId: "acc-1", debit: 0, credit: 100 }),
expect.objectContaining({ accountId: "acc-2", debit: 100, credit: 0 }),
]);
});
it("does NOT copy sourceModule/sourceId — that would collide with the idempotency key on the original", async () => {
const { service, entries, lines, periods, manager } = build();
wireOriginalThenReversal(
entries,
originalEntry({ sourceModule: "supplier-bill", sourceId: "bill-42" }),
);
lines.findByEntry.mockResolvedValue(originalLines);
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
await service.reverse(actor, "orig-1", { reason: "correction" });
const savedArg = manager.entryRepo.save.mock.calls[0][0];
expect(savedArg.sourceModule).toBeUndefined();
expect(savedArg.sourceId).toBeUndefined();
});
it("marks the original REVERSED with reversedByEntryId pointing at the new entry, all inside one transaction", async () => {
const { service, entries, lines, periods, manager, dataSource } = build();
wireOriginalThenReversal(entries, originalEntry());
lines.findByEntry.mockResolvedValue(originalLines);
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
await service.reverse(actor, "orig-1", { reason: "correction" });
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(manager.entryRepo.update).toHaveBeenCalledWith("orig-1", {
status: "REVERSED",
reversedByEntryId: "entry-1",
});
});
it("mirrors totalDebit/totalCredit too (credit becomes debit and vice versa) and journalType REVERSAL", async () => {
const { service, entries, lines, periods, manager } = build();
wireOriginalThenReversal(
entries,
originalEntry({ totalDebit: 400, totalCredit: 400 }),
);
lines.findByEntry.mockResolvedValue(originalLines);
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
await service.reverse(actor, "orig-1", { reason: "correction" });
const savedArg = manager.entryRepo.save.mock.calls[0][0];
expect(savedArg.totalDebit).toBe(400);
expect(savedArg.totalCredit).toBe(400);
expect(savedArg.journalType).toBe("REVERSAL");
expect(savedArg.reversesEntryId).toBe("orig-1");
});
});
describe("JournalsService.createPosted", () => {
const dto = () => ({
entryDate: "2026-03-05",
memo: "Test posting",
journalType: "GENERAL" as const,
lines: [
{ accountId: "acc-1", debit: 500 },
{ accountId: "acc-2", credit: 500 },
],
sourceModule: "supplier-bill",
sourceId: "bill-1",
});
function wireHappyPath(mocks: ReturnType<typeof build>) {
mocks.accounts.assertPostable.mockImplementation(
async (_a: unknown, id: string) => ({ id, isActive: true, isGroup: false }),
);
mocks.periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-1" });
}
it("with no manager: opens its own transaction and returns the full detail via findOne()", async () => {
const mocks = build();
wireHappyPath(mocks);
mocks.entries.findById.mockResolvedValue({
id: "entry-1",
organizationId: "org-1",
});
mocks.lines.findByEntryWithAccounts.mockResolvedValue([]);
const result = await mocks.service.createPosted(actor, dto());
expect(mocks.dataSource.transaction).toHaveBeenCalledTimes(1);
expect(result.id).toBe("entry-1");
expect(mocks.manager.entryRepo.save).toHaveBeenCalledTimes(1);
});
it("caller-supplied manager: writes on THAT manager and reads lines back via manager.query — never opens its own transaction", async () => {
const mocks = build();
wireHappyPath(mocks);
const callerManager = makeManager();
callerManager.query.mockResolvedValue([
{ id: "line-1", accountId: "acc-1", debit: 500, credit: 0 },
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 500 },
]);
const result = await mocks.service.createPosted(
actor,
dto(),
callerManager as never,
);
// The bug this guards against: a service opening its OWN nested
// transaction inside the caller's would commit independently and orphan
// rows on a later failure. It must write on the given manager instead.
expect(mocks.dataSource.transaction).not.toHaveBeenCalled();
expect(callerManager.entryRepo.save).toHaveBeenCalledTimes(1);
expect(callerManager.query).toHaveBeenCalledTimes(1);
expect(result.lines).toEqual([
{ id: "line-1", accountId: "acc-1", debit: 500, credit: 0 },
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 500 },
]);
// Nothing was written on the service's own default manager.
expect(mocks.manager.entryRepo.save).not.toHaveBeenCalled();
expect(mocks.entries.findById).not.toHaveBeenCalled();
});
it("caller-supplied manager: nextEntryNumber is generated on the SAME manager, not a fresh connection", async () => {
const mocks = build();
wireHappyPath(mocks);
const callerManager = makeManager();
await mocks.service.createPosted(actor, dto(), callerManager as never);
expect(mocks.entries.nextEntryNumber).toHaveBeenCalledWith(
"org-1",
2026,
callerManager,
);
});
it("sourceModule/sourceId form the idempotency key on the saved entry", async () => {
const mocks = build();
wireHappyPath(mocks);
const callerManager = makeManager();
await mocks.service.createPosted(actor, dto(), callerManager as never);
const savedArg = callerManager.entryRepo.save.mock.calls[0][0];
expect(savedArg.sourceModule).toBe("supplier-bill");
expect(savedArg.sourceId).toBe("bill-1");
expect(savedArg.status).toBe("POSTED");
expect(savedArg.postedAt).toBeInstanceOf(Date);
});
it("re-resolves the open period from the entry date, same as the human-posting path", async () => {
const mocks = build();
wireHappyPath(mocks);
const callerManager = makeManager();
await mocks.service.createPosted(actor, dto(), callerManager as never);
expect(mocks.periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
"org-1",
"2026-03-05",
);
});
});
describe("JournalsService — assertBalanced (private)", () => {
it("rejects when total debit is not positive", () => {
const { service } = build();
expect(() => (service as never as { assertBalanced: (d: number, c: number) => void }).assertBalanced(0, 0)).toThrow(
"An entry must move a non-zero amount",
);
});
it("uses balances()'s tolerant (sub-cent) comparison rather than strict equality", () => {
const { service } = build();
const assertBalanced = (
service as never as { assertBalanced: (d: number, c: number) => void }
).assertBalanced.bind(service);
// 0.004 difference is inside the < 0.005 tolerance from money.ts.
expect(() => assertBalanced(100, 100.004)).not.toThrow();
});
it("still rejects a genuine cent-or-more mismatch, naming both totals and the exact difference", () => {
const { service } = build();
const assertBalanced = (
service as never as { assertBalanced: (d: number, c: number) => void }
).assertBalanced.bind(service);
expect(() => assertBalanced(100, 105)).toThrow(
"Entry does not balance: debits 100.00, credits 105.00 — a difference of 5.00",
);
});
});
describe("JournalsService — normalizeLines (private)", () => {
type NormalizeLines = (
actor: ActorContext,
lines: unknown[],
) => Promise<{ accountId: string; debit: number; credit: number }[]>;
function normalizer(service: JournalsService) {
return (
service as never as { normalizeLines: NormalizeLines }
).normalizeLines.bind(service);
}
it("requires at least two lines", async () => {
const { service } = build();
await expect(
normalizer(service)(actor, [{ accountId: "a1", debit: 100 }]),
).rejects.toThrow("An entry needs at least two lines");
});
it("rejects a line carrying both a debit and a credit — a line must be one side or the other", async () => {
const { service, accounts } = build();
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
await expect(
normalizer(service)(actor, [
{ accountId: "a1", debit: 100, credit: 50 },
{ accountId: "a2", credit: 50 },
]),
).rejects.toThrow(/has both a debit and a credit/);
});
it("rejects a line carrying neither a debit nor a credit", async () => {
const { service, accounts } = build();
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
await expect(
normalizer(service)(actor, [
{ accountId: "a1" },
{ accountId: "a2", credit: 100 },
]),
).rejects.toThrow(/has no amount/);
});
it("rejects when total debit is not positive — e.g. every line is credit-only", async () => {
const { service, accounts } = build();
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
await expect(
normalizer(service)(actor, [
{ accountId: "a1", credit: 50 },
{ accountId: "a2", credit: 50 },
]),
).rejects.toThrow("An entry must move a non-zero amount");
});
it("resolves each account via accounts.assertPostable and rounds every amount to the cent", async () => {
const { service, accounts } = build();
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
const normalized = await normalizer(service)(actor, [
{ accountId: "a1", debit: 33.333333 },
{ accountId: "a2", credit: 33.333333 },
]);
expect(normalized[0]).toMatchObject({ accountId: "a1", debit: 33.33, credit: 0 });
expect(normalized[1]).toMatchObject({ accountId: "a2", debit: 0, credit: 33.33 });
expect(accounts.assertPostable).toHaveBeenCalledWith(actor, "a1");
expect(accounts.assertPostable).toHaveBeenCalledWith(actor, "a2");
});
});

View File

@@ -0,0 +1,444 @@
import { BadRequestException, ForbiddenException } from "@nestjs/common";
import type { ActorContext } from "../../common/current-actor.util";
import { PayablesService } from "./payables.service";
import { SupplierPayment } from "./entities/supplier-payment.entity";
import type { RecordPaymentDto } from "./dto/payables.dto";
const actor: ActorContext = {
employeeId: "emp-1",
userId: "user-1",
organizationId: "org-1",
isSuperAdmin: false,
};
function makeManager() {
const paymentRepo = {
create: (data: Record<string, unknown>) => data,
save: jest
.fn()
.mockImplementation(async (data: Record<string, unknown>) => ({
id: (data.id as string) ?? "payment-1",
...data,
})),
};
const billRepo = {
update: jest.fn().mockResolvedValue(undefined),
};
return {
getRepository: (entity: unknown) =>
entity === SupplierPayment ? paymentRepo : billRepo,
paymentRepo,
billRepo,
};
}
function build() {
const suppliers = {
findById: jest.fn(),
findByCode: jest.fn(),
findAllForOrg: jest.fn(),
countBills: jest.fn(),
create: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
};
const bills = {
findById: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
findPage: jest.fn(),
nextBillNumber: jest.fn(),
findPageWithSupplier: jest.fn(),
agingAsOf: jest.fn(),
};
const billLines = {
findByBill: jest.fn(),
findByBillWithAccounts: jest.fn().mockResolvedValue([]),
replaceForBill: jest.fn(),
};
const payments = {
findByBill: jest.fn().mockResolvedValue([]),
nextPaymentNumber: jest.fn().mockResolvedValue("PAY-2026-000001"),
};
const remittances = {
outstandingByAccount: jest.fn(),
findAllForOrg: jest.fn(),
create: jest.fn(),
};
const accounts = { findOne: jest.fn(), findByCode: jest.fn(), assertPostable: jest.fn() };
const journals = { createPosted: jest.fn(), findBySource: jest.fn() };
const manager = makeManager();
const dataSource = {
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
manager,
query: jest.fn(),
};
const service = new PayablesService(
suppliers as never,
bills as never,
billLines as never,
payments as never,
remittances as never,
accounts as never,
journals as never,
dataSource as never,
);
return {
service,
suppliers,
bills,
billLines,
payments,
remittances,
accounts,
journals,
dataSource,
manager,
};
}
function stubAccount(code: string, over: Record<string, unknown> = {}) {
return { id: code, code, accountType: "LIABILITY", isActive: true, isGroup: false, ...over };
}
describe("PayablesService.approveBill", () => {
const draftBill = (over: Record<string, unknown> = {}) => ({
id: "bill-1",
organizationId: "org-1",
billNumber: "BILL-2026-000001",
supplierInvoiceNumber: "INV-100",
billDate: "2026-08-01",
status: "DRAFT",
totalAmount: 1150,
taxAmount: 150,
withholdingAmount: 100,
supplierId: "sup-1",
...over,
});
const billLinesRows = [
{ accountId: "exp-1", amount: 1000, description: "Consulting" },
];
const supplierRow = (over: Record<string, unknown> = {}) => ({
id: "sup-1",
name: "Acme Supplies",
payableAccountId: null,
...over,
});
it("refuses approving a bill that is not DRAFT", async () => {
const { service, bills } = build();
bills.findById.mockResolvedValue(draftBill({ status: "APPROVED" }));
await expect(service.approveBill(actor, "bill-1")).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it("refuses when withholding exceeds the bill total", async () => {
const { service, bills, billLines, suppliers, journals, accounts } = build();
bills.findById.mockResolvedValue(
draftBill({ totalAmount: 500, withholdingAmount: 600 }),
);
billLines.findByBill.mockResolvedValue(billLinesRows);
suppliers.findById.mockResolvedValue(supplierRow());
// The trade-payables lookup runs before the withholding check — needs an
// account on file even though this test never reaches the journal build.
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
await expect(service.approveBill(actor, "bill-1")).rejects.toThrow(
/Withholding 600\.00 exceeds the bill total 500\.00/,
);
expect(journals.createPosted).not.toHaveBeenCalled();
});
it("refuses a bill with no lines", async () => {
const { service, bills, billLines } = build();
bills.findById.mockResolvedValue(draftBill());
billLines.findByBill.mockResolvedValue([]);
await expect(service.approveBill(actor, "bill-1")).rejects.toBeInstanceOf(
BadRequestException,
);
});
it("builds Dr expense + Dr VAT payable, Cr trade-payables (net of withholding) + Cr withholding-payable, posted with sourceModule/sourceId", async () => {
const { service, bills, billLines, suppliers, accounts, journals } = build();
bills.findById.mockResolvedValue(draftBill());
billLines.findByBill.mockResolvedValue(billLinesRows);
suppliers.findById.mockResolvedValue(supplierRow());
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
stubAccount(code),
);
journals.createPosted.mockResolvedValue({
id: "je-1",
entryNumber: "JV-2026-000001",
});
await service.approveBill(actor, "bill-1");
expect(journals.createPosted).toHaveBeenCalledWith(
actor,
expect.objectContaining({
entryDate: "2026-08-01",
journalType: "PURCHASE",
sourceModule: "supplier-bill",
sourceId: "bill-1",
lines: [
{ accountId: "exp-1", debit: 1000, description: "Consulting" },
{ accountId: "2123", debit: 150, description: "Input VAT" },
{
accountId: "2111",
credit: 1050,
description: "Acme Supplies — BILL-2026-000001",
},
{
accountId: "2124",
credit: 100,
description: "Withheld on BILL-2026-000001",
},
],
}),
expect.anything(),
);
});
it("omits the VAT and withholding lines when neither applies", async () => {
const { service, bills, billLines, suppliers, accounts, journals } = build();
bills.findById.mockResolvedValue(
draftBill({ totalAmount: 1000, taxAmount: 0, withholdingAmount: 0 }),
);
billLines.findByBill.mockResolvedValue(billLinesRows);
suppliers.findById.mockResolvedValue(supplierRow());
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
stubAccount(code),
);
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.approveBill(actor, "bill-1");
const call = journals.createPosted.mock.calls[0][1] as { lines: unknown[] };
expect(call.lines).toEqual([
{ accountId: "exp-1", debit: 1000, description: "Consulting" },
{ accountId: "2111", credit: 1000, description: "Acme Supplies — BILL-2026-000001" },
]);
});
it("credits the supplier's OWN payable account when one is configured, instead of the default trade-payables control account", async () => {
const { service, bills, billLines, suppliers, accounts, journals } = build();
bills.findById.mockResolvedValue(
draftBill({ totalAmount: 1000, taxAmount: 0, withholdingAmount: 0 }),
);
billLines.findByBill.mockResolvedValue(billLinesRows);
suppliers.findById.mockResolvedValue(
supplierRow({ payableAccountId: "custom-payable" }),
);
accounts.findOne.mockResolvedValue(
stubAccount("custom-payable", { id: "custom-payable" }),
);
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
stubAccount(code),
);
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.approveBill(actor, "bill-1");
expect(accounts.findOne).toHaveBeenCalledWith(actor, "custom-payable");
expect(accounts.findByCode).not.toHaveBeenCalledWith("org-1", "2111");
const call = journals.createPosted.mock.calls[0][1] as { lines: { accountId: string }[] };
expect(call.lines.some((l) => l.accountId === "custom-payable")).toBe(true);
});
it("writes the journal entry and the bill's APPROVED status/journalEntryId inside ONE transaction (not the nested-transaction trap)", async () => {
const { service, bills, billLines, suppliers, accounts, journals, dataSource, manager } =
build();
bills.findById.mockResolvedValue(draftBill());
billLines.findByBill.mockResolvedValue(billLinesRows);
suppliers.findById.mockResolvedValue(supplierRow());
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
stubAccount(code),
);
journals.createPosted.mockResolvedValue({
id: "je-1",
entryNumber: "JV-2026-000001",
});
await service.approveBill(actor, "bill-1");
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
// journals.createPosted must be given the SAME manager the transaction
// opened, not left to open its own nested one.
expect(journals.createPosted).toHaveBeenCalledWith(
actor,
expect.anything(),
manager,
);
expect(manager.billRepo.update).toHaveBeenCalledWith("bill-1", {
status: "APPROVED",
journalEntryId: "je-1",
approvedBy: "emp-1",
approvedAt: expect.any(Date),
});
// The old direct (unguarded, non-transactional) write path is gone.
expect(bills.update).not.toHaveBeenCalled();
});
});
describe("PayablesService.recordPayment", () => {
const approvedBill = (over: Record<string, unknown> = {}) => ({
id: "bill-1",
organizationId: "org-1",
billNumber: "BILL-2026-000001",
status: "APPROVED",
totalAmount: 1000,
paidAmount: 0,
supplierId: "sup-1",
...over,
});
const supplierRow = (over: Record<string, unknown> = {}) => ({
id: "sup-1",
name: "Acme Supplies",
payableAccountId: null,
...over,
});
const paymentDto = (over: Record<string, unknown> = {}) =>
({
paymentDate: "2026-08-15",
amount: 400,
method: "BANK",
paidFromAccountId: "cash-1",
...over,
}) as RecordPaymentDto;
it.each(["DRAFT", "PAID", "CANCELLED"])(
"refuses paying a %s bill — only APPROVED/PARTIALLY_PAID bills are payable",
async (status) => {
const { service, bills } = build();
bills.findById.mockResolvedValue(approvedBill({ status }));
await expect(
service.recordPayment(actor, "bill-1", paymentDto()),
).rejects.toBeInstanceOf(BadRequestException);
},
);
it("allows paying a PARTIALLY_PAID bill", async () => {
const { service, bills, suppliers, accounts, journals } = build();
bills.findById.mockResolvedValue(
approvedBill({ status: "PARTIALLY_PAID", paidAmount: 400 }),
);
suppliers.findById.mockResolvedValue(supplierRow());
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await expect(
service.recordPayment(actor, "bill-1", paymentDto({ amount: 200 })),
).resolves.toBeDefined();
});
it("refuses a payment that exceeds the outstanding balance (totalAmount - paidAmount)", async () => {
const { service, bills, suppliers, accounts } = build();
bills.findById.mockResolvedValue(approvedBill({ totalAmount: 1000, paidAmount: 800 }));
suppliers.findById.mockResolvedValue(supplierRow());
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
await expect(
service.recordPayment(actor, "bill-1", paymentDto({ amount: 300 })),
).rejects.toThrow(/exceeds the 200\.00 still outstanding/);
});
it("requires paidFromAccountId to resolve to an ASSET account", async () => {
const { service, bills, accounts } = build();
bills.findById.mockResolvedValue(approvedBill());
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "LIABILITY" }));
await expect(
service.recordPayment(actor, "bill-1", paymentDto()),
).rejects.toThrow(/must come from an ASSET/);
});
it("posts Dr payable / Cr cash with sourceModule 'supplier-payment' and sourceId '<billId>:<paymentNumber>'", async () => {
const { service, bills, suppliers, accounts, journals, payments } = build();
bills.findById.mockResolvedValue(approvedBill());
suppliers.findById.mockResolvedValue(supplierRow());
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
payments.nextPaymentNumber.mockResolvedValue("PAY-2026-000007");
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.recordPayment(actor, "bill-1", paymentDto({ amount: 400 }));
expect(journals.createPosted).toHaveBeenCalledWith(
actor,
expect.objectContaining({
journalType: "CASH_PAYMENT",
sourceModule: "supplier-payment",
sourceId: "bill-1:PAY-2026-000007",
lines: [
{ accountId: "2111", debit: 400, description: "Settle BILL-2026-000001" },
{ accountId: "cash-1", credit: 400, description: "BANK" },
],
}),
expect.anything(),
);
});
it("updates paidAmount and flips to PAID once paidAmount >= totalAmount, all inside one transaction", async () => {
const { service, bills, suppliers, accounts, journals, dataSource, manager } = build();
bills.findById.mockResolvedValue(approvedBill({ totalAmount: 400, paidAmount: 0 }));
suppliers.findById.mockResolvedValue(supplierRow());
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.recordPayment(actor, "bill-1", paymentDto({ amount: 400 }));
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(manager.billRepo.update).toHaveBeenCalledWith("bill-1", {
paidAmount: 400,
status: "PAID",
});
});
it("flips to PARTIALLY_PAID when a balance remains after the payment", async () => {
const { service, bills, suppliers, accounts, journals, manager } = build();
bills.findById.mockResolvedValue(approvedBill({ totalAmount: 1000, paidAmount: 0 }));
suppliers.findById.mockResolvedValue(supplierRow());
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.recordPayment(actor, "bill-1", paymentDto({ amount: 400 }));
expect(manager.billRepo.update).toHaveBeenCalledWith("bill-1", {
paidAmount: 400,
status: "PARTIALLY_PAID",
});
});
it("passes the transaction's manager into journals.createPosted — the same nested-transaction trap the depreciation run guards against", async () => {
const { service, bills, suppliers, accounts, journals, dataSource, manager } = build();
bills.findById.mockResolvedValue(approvedBill());
suppliers.findById.mockResolvedValue(supplierRow());
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.recordPayment(actor, "bill-1", paymentDto());
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(journals.createPosted).toHaveBeenCalledWith(
actor,
expect.anything(),
manager,
);
});
});

View File

@@ -346,23 +346,33 @@ export class PayablesService {
});
}
const entry = await this.journals.createPosted(actor, {
entryDate: bill.billDate,
journalType: "PURCHASE",
memo: `${supplier?.name ?? "Supplier"} bill ${bill.billNumber}${
bill.supplierInvoiceNumber ? ` (inv ${bill.supplierInvoiceNumber})` : ""
}`,
reference: bill.supplierInvoiceNumber ?? bill.billNumber,
sourceModule: "supplier-bill",
sourceId: bill.id,
lines: journalLines,
});
await this.dataSource.transaction(async (manager) => {
// The manager is passed through so the journal entry is written on THIS
// transaction. Without it the entry committed on its own connection, and
// a failure in the bill update below left a posted purchase entry with
// no bill ever marked APPROVED to explain it.
const entry = await this.journals.createPosted(
actor,
{
entryDate: bill.billDate,
journalType: "PURCHASE",
memo: `${supplier?.name ?? "Supplier"} bill ${bill.billNumber}${
bill.supplierInvoiceNumber ? ` (inv ${bill.supplierInvoiceNumber})` : ""
}`,
reference: bill.supplierInvoiceNumber ?? bill.billNumber,
sourceModule: "supplier-bill",
sourceId: bill.id,
lines: journalLines,
},
manager,
);
await this.bills.update(id, {
status: "APPROVED",
journalEntryId: entry.id,
approvedBy: actor.employeeId,
approvedAt: new Date(),
await manager.getRepository(SupplierBill).update(id, {
status: "APPROVED",
journalEntryId: entry.id,
approvedBy: actor.employeeId,
approvedAt: new Date(),
});
});
return this.findBill(actor, id);
@@ -420,26 +430,34 @@ export class PayablesService {
manager,
);
const entry = await this.journals.createPosted(actor, {
entryDate: dto.paymentDate,
journalType: "CASH_PAYMENT",
memo: `Payment to ${supplier?.name ?? "supplier"} for ${bill.billNumber}`,
reference: dto.reference ?? paymentNumber,
sourceModule: "supplier-payment",
sourceId: `${bill.id}:${paymentNumber}`,
lines: [
{
accountId: payableAccount.id,
debit: amount,
description: `Settle ${bill.billNumber}`,
},
{
accountId: cashAccount.id,
credit: amount,
description: `${dto.method} ${dto.reference ?? ""}`.trim(),
},
],
});
// The manager is passed through so the journal entry is written on THIS
// transaction. Without it the entry committed on its own connection, and
// a failure in the payment/bill writes below left a posted cash-payment
// entry with no payment record and no paidAmount update to explain it.
const entry = await this.journals.createPosted(
actor,
{
entryDate: dto.paymentDate,
journalType: "CASH_PAYMENT",
memo: `Payment to ${supplier?.name ?? "supplier"} for ${bill.billNumber}`,
reference: dto.reference ?? paymentNumber,
sourceModule: "supplier-payment",
sourceId: `${bill.id}:${paymentNumber}`,
lines: [
{
accountId: payableAccount.id,
debit: amount,
description: `Settle ${bill.billNumber}`,
},
{
accountId: cashAccount.id,
credit: amount,
description: `${dto.method} ${dto.reference ?? ""}`.trim(),
},
],
},
manager,
);
const repo = manager.getRepository(SupplierPayment);
const payment = await repo.save(

View File

@@ -0,0 +1,390 @@
import { BadRequestException } from "@nestjs/common";
import type { ActorContext } from "../../common/current-actor.util";
import { PayrollPostingService } from "./payroll-posting.service";
const actor: ActorContext = {
employeeId: "emp-1",
userId: "user-1",
organizationId: "org-1",
isSuperAdmin: false,
};
function stubAccount(code: string) {
return { id: `acct-${code}`, code, isActive: true, isGroup: false };
}
function build() {
const dataSource = { query: jest.fn() };
const accounts = {
findByCode: jest
.fn()
.mockImplementation(async (_org: string, code: string) => stubAccount(code)),
};
const journals = { findBySource: jest.fn(), createPosted: jest.fn() };
const service = new PayrollPostingService(
dataSource as never,
accounts as never,
journals as never,
);
return { service, dataSource, accounts, journals };
}
const AVAILABLE_ROW = [{ ok: true }];
function runRow(over: Record<string, unknown> = {}) {
return [
{
periodStart: "2026-07-01",
periodEnd: "2026-07-31",
paymentDate: "2026-08-05",
status: "APPROVED",
...over,
},
];
}
/**
* A payroll that reconciles: gross - totalDeductions = netPay, exactly.
* allowances = gross - basic = 12000 - 9000 = 3000
* otherDeductions = totalDeductions - incomeTax - pensionEmployee
* = 1430 - 800 - 630 = 0
*/
function payslipTotalsRow(over: Record<string, unknown> = {}) {
return [
{
payslipCount: 3,
basic: 9000,
gross: 12000,
incomeTax: 800,
pensionEmployee: 630,
pensionEmployer: 840,
totalDeductions: 1430,
netPay: 10570,
...over,
},
];
}
/** Wires the fixed dataSource.query call sequence postRun makes: available(),
* loadRun(), then the payslip aggregation. */
function wireQueries(
dataSource: ReturnType<typeof build>["dataSource"],
totals: unknown[] = payslipTotalsRow(),
run: unknown[] = runRow(),
) {
dataSource.query
.mockResolvedValueOnce(AVAILABLE_ROW)
.mockResolvedValueOnce(run)
.mockResolvedValueOnce(totals);
}
describe("PayrollPostingService.postRun", () => {
it("refuses when HR's payroll tables are not present in this database", async () => {
const { service, dataSource, journals } = build();
dataSource.query.mockResolvedValueOnce([{ ok: false }]);
await expect(service.postRun(actor, "run-1")).rejects.toBeInstanceOf(
BadRequestException,
);
expect(journals.createPosted).not.toHaveBeenCalled();
});
it.each(["DRAFT", "CALCULATED", "CANCELLED"])(
"refuses posting a %s run — only APPROVED/PAID runs are postable",
async (status) => {
const { service, dataSource, journals } = build();
dataSource.query
.mockResolvedValueOnce(AVAILABLE_ROW)
.mockResolvedValueOnce(runRow({ status }));
await expect(service.postRun(actor, "run-1")).rejects.toBeInstanceOf(
BadRequestException,
);
expect(journals.findBySource).not.toHaveBeenCalled();
expect(journals.createPosted).not.toHaveBeenCalled();
},
);
it("allows posting a PAID run, not just APPROVED", async () => {
const { service, dataSource, journals } = build();
dataSource.query
.mockResolvedValueOnce(AVAILABLE_ROW)
.mockResolvedValueOnce(runRow({ status: "PAID" }))
.mockResolvedValueOnce(payslipTotalsRow());
journals.findBySource.mockResolvedValue(null);
journals.createPosted.mockResolvedValue({
id: "je-1",
entryNumber: "JV-2026-000001",
});
await expect(service.postRun(actor, "run-1")).resolves.toBeDefined();
});
it("refuses a second post — the idempotency check via journals.findBySource('hr-payroll', runId)", async () => {
const { service, dataSource, journals } = build();
dataSource.query
.mockResolvedValueOnce(AVAILABLE_ROW)
.mockResolvedValueOnce(runRow());
journals.findBySource.mockResolvedValue({
entryNumber: "JV-2026-000005",
});
await expect(service.postRun(actor, "run-1")).rejects.toThrow(
/already posted as JV-2026-000005/,
);
expect(journals.findBySource).toHaveBeenCalledWith("org-1", "hr-payroll", "run-1");
// The payslip aggregation query is never reached once the idempotency
// guard has already refused.
expect(dataSource.query).toHaveBeenCalledTimes(2);
expect(journals.createPosted).not.toHaveBeenCalled();
});
it("refuses a run with no payslips", async () => {
const { service, dataSource, journals } = build();
wireQueries(dataSource, [{ payslipCount: 0 }]);
journals.findBySource.mockResolvedValue(null);
await expect(service.postRun(actor, "run-1")).rejects.toThrow(
/has no payslips/,
);
expect(journals.createPosted).not.toHaveBeenCalled();
});
it("aggregates from hr.payslips, not the run header — the query sums SUM(p.*) from hr.payslips, and that sum alone drives every posted figure", async () => {
const { service, dataSource, journals } = build();
wireQueries(dataSource);
journals.findBySource.mockResolvedValue(null);
journals.createPosted.mockResolvedValue({
id: "je-1",
entryNumber: "JV-2026-000001",
});
const result = await service.postRun(actor, "run-1");
// The aggregation query itself reads from hr.payslips.
const aggSql = dataSource.query.mock.calls[2][0] as string;
expect(aggSql).toMatch(/FROM hr\.payslips/);
// loadRun's row (the "run header" stand-in) never even exposes total
// fields — everything posted is derivable ONLY from the payslip sum.
expect(runRow()[0]).not.toHaveProperty("totalGross");
// total = gross + employer pension, straight from the payslip aggregation.
expect(result.total).toBe(12000 + 840);
});
it("derives allowances = gross - basic and otherDeductions = totalDeductions - incomeTax - pensionEmployee", async () => {
const { service, dataSource, journals } = build();
wireQueries(
dataSource,
payslipTotalsRow({
basic: 9000,
gross: 12500, // allowances = 3500
incomeTax: 800,
pensionEmployee: 630,
totalDeductions: 1530, // otherDeductions = 1530-800-630 = 100
netPay: 10970, // 12500-1530
}),
);
journals.findBySource.mockResolvedValue(null);
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.postRun(actor, "run-1");
const lines = journals.createPosted.mock.calls[0][1].lines as {
accountId: string;
debit?: number;
credit?: number;
description: string;
}[];
expect(lines).toContainEqual(
expect.objectContaining({
accountId: "acct-5120",
debit: 3500,
description: "Allowances and other earnings",
}),
);
expect(lines).toContainEqual(
expect.objectContaining({
accountId: "acct-2160",
credit: 100,
description: "Other deductions withheld",
}),
);
});
it("employer pension is BOTH a debit (expense) AND part of the pension-payable credit — the classic sign error the identity check guards against", async () => {
const { service, dataSource, journals } = build();
wireQueries(dataSource); // pensionEmployee 630, pensionEmployer 840
journals.findBySource.mockResolvedValue(null);
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.postRun(actor, "run-1");
const lines = journals.createPosted.mock.calls[0][1].lines as {
accountId: string;
debit?: number;
credit?: number;
}[];
const pensionExpenseLine = lines.find((l) => l.accountId === "acct-5140");
expect(pensionExpenseLine).toEqual(
expect.objectContaining({ debit: 840 }),
);
const pensionPayableLine = lines.find((l) => l.accountId === "acct-2122");
// employee (630) + employer (840) = 1470, both rolled into the one
// credit to the fund.
expect(pensionPayableLine).toEqual(
expect.objectContaining({ credit: 1470 }),
);
// The entry still balances by CONSTRUCTION (dual role deliberately, not
// by an accidental sign cancellation elsewhere).
const totalDebit = lines.reduce((s, l) => s + (l.debit ?? 0), 0);
const totalCredit = lines.reduce((s, l) => s + (l.credit ?? 0), 0);
expect(totalDebit).toBe(totalCredit);
});
it("omits zero-value lines from the journal entirely", async () => {
const { service, dataSource, journals } = build();
wireQueries(
dataSource,
payslipTotalsRow({
basic: 12000,
gross: 12000, // allowances = 0 -> omitted
incomeTax: 800,
pensionEmployee: 630,
totalDeductions: 1430, // otherDeductions = 0 -> omitted
netPay: 10570,
}),
);
journals.findBySource.mockResolvedValue(null);
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.postRun(actor, "run-1");
const lines = journals.createPosted.mock.calls[0][1].lines as {
accountId: string;
}[];
expect(lines.some((l) => l.accountId === "acct-5120")).toBe(false); // allowances
expect(lines.some((l) => l.accountId === "acct-2160")).toBe(false); // other deductions
expect(lines.length).toBe(5); // basic, pension-employer, tax, pension-payable, salaries-payable
});
it("dates the entry the period END, not the payment date", async () => {
const { service, dataSource, journals } = build();
wireQueries(dataSource, payslipTotalsRow(), runRow({
periodEnd: "2026-07-31",
paymentDate: "2026-08-05",
}));
journals.findBySource.mockResolvedValue(null);
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
await service.postRun(actor, "run-1");
expect(journals.createPosted.mock.calls[0][1]).toMatchObject({
entryDate: "2026-07-31",
sourceModule: "hr-payroll",
sourceId: "run-1",
});
});
});
describe("PayrollPostingService — assertPayrollIdentity (private)", () => {
type Totals = {
gross: number;
totalDeductions: number;
netPay: number;
allowances: number;
otherDeductions: number;
};
function identityCheck(service: PayrollPostingService) {
return (
service as never as { assertPayrollIdentity: (t: Totals) => void }
).assertPayrollIdentity.bind(service);
}
it("passes when gross - totalDeductions == netPay exactly", () => {
const { service } = build();
expect(() =>
identityCheck(service)({
gross: 12000,
totalDeductions: 1430,
netPay: 10570,
allowances: 3000,
otherDeductions: 0,
}),
).not.toThrow();
});
it("tolerates a sub-half-cent discrepancy (< 0.005)", () => {
const { service } = build();
expect(() =>
identityCheck(service)({
gross: 12000,
totalDeductions: 1430,
netPay: 10570.004,
allowances: 3000,
otherDeductions: 0,
}),
).not.toThrow();
});
it("rejects a genuine mismatch between gross - totalDeductions and netPay", () => {
const { service } = build();
expect(() =>
identityCheck(service)({
gross: 12000,
totalDeductions: 1430,
netPay: 10600, // should be 10570
allowances: 3000,
otherDeductions: 0,
}),
).toThrow(/does not reconcile/);
});
it("rejects negative allowances — basic salary exceeding gross pay", () => {
const { service } = build();
expect(() =>
identityCheck(service)({
gross: 9000,
totalDeductions: 1000,
netPay: 8000,
allowances: -500, // basic > gross
otherDeductions: 0,
}),
).toThrow(/Basic salary exceeds gross pay/);
});
it("rejects negative otherDeductions — income tax and pension exceeding total deductions", () => {
const { service } = build();
expect(() =>
identityCheck(service)({
gross: 9000,
totalDeductions: 500,
netPay: 8500,
allowances: 1000,
otherDeductions: -200,
}),
).toThrow(/Income tax and pension exceed total deductions/);
});
it("checks the identity BEFORE any account is resolved or anything is posted", async () => {
const { service, dataSource, journals, accounts } = build();
wireQueries(
dataSource,
payslipTotalsRow({ netPay: 999999 }), // deliberately broken identity
);
journals.findBySource.mockResolvedValue(null);
await expect(service.postRun(actor, "run-1")).rejects.toThrow(
/does not reconcile/,
);
expect(accounts.findByCode).not.toHaveBeenCalled();
expect(journals.createPosted).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,163 @@
import { Nack } from "@golevelup/nestjs-rabbitmq";
import { PaymentReferenceType, PaymentService, ProviderMethod } from "@edr/types";
import type { PaymentSucceededEvent } from "@edr/types";
import { PaymentEventsConsumer } from "./payment-events.consumer";
import type { PaymentPostingResult } from "./revenue-posting.service";
/** Synthetic payment event — no resemblance to any real transaction. */
function syntheticEvent(
overrides: Partial<PaymentSucceededEvent> = {},
): PaymentSucceededEvent {
return {
version: 1,
eventId: "evt-synthetic-1",
eventType: "payment.succeeded",
occurredAt: "2026-07-10T00:00:00.000Z",
service: PaymentService.FREIGHT,
intentId: "intent-synthetic-1",
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: "shipment-synthetic-1",
merchantOrderId: "order-synthetic-1",
provider: ProviderMethod.CARD,
amountMinor: 100,
currency: "ETB",
paidAt: "2026-07-10T00:00:00.000Z",
...overrides,
};
}
function makeManager() {
return {};
}
function makeInbound() {
return {
claim: jest.fn(),
settle: jest.fn().mockResolvedValue(undefined),
};
}
function makePosting() {
return { postPaymentReceipt: jest.fn() };
}
function makeDataSource() {
const manager = makeManager();
return {
transaction: jest.fn((cb: (m: unknown) => unknown) => cb(manager)),
};
}
describe("PaymentEventsConsumer.handle", () => {
let inbound: ReturnType<typeof makeInbound>;
let posting: ReturnType<typeof makePosting>;
let dataSource: ReturnType<typeof makeDataSource>;
let consumer: PaymentEventsConsumer;
beforeEach(() => {
inbound = makeInbound();
posting = makePosting();
dataSource = makeDataSource();
consumer = new PaymentEventsConsumer(
inbound as never,
posting as never,
dataSource as never,
);
});
it("dead-letters an event with no eventId — cannot dedupe without a key", async () => {
const event = syntheticEvent({ eventId: undefined as unknown as string });
const result = await consumer.handle(event);
expect(result).toBeInstanceOf(Nack);
expect((result as Nack).requeue).toBe(false);
expect(inbound.claim).not.toHaveBeenCalled();
expect(posting.postPaymentReceipt).not.toHaveBeenCalled();
});
it("acks and skips without posting when claim() returns null — a redelivery already seen", async () => {
inbound.claim.mockResolvedValue(null);
const result = await consumer.handle(syntheticEvent());
expect(result).toBeUndefined();
expect(posting.postPaymentReceipt).not.toHaveBeenCalled();
expect(inbound.settle).not.toHaveBeenCalled();
});
it("dead-letters when the claim insert itself fails — the broker keeps the only copy", async () => {
dataSource.transaction.mockImplementation(() => {
throw new Error("connection terminated");
});
const result = await consumer.handle(syntheticEvent());
expect(result).toBeInstanceOf(Nack);
expect((result as Nack).requeue).toBe(false);
expect(posting.postPaymentReceipt).not.toHaveBeenCalled();
});
it("acks a successful claim followed by a POSTED outcome, and records it", async () => {
inbound.claim.mockResolvedValue("inbound-1");
posting.postPaymentReceipt.mockResolvedValue({
outcome: "POSTED",
journalEntryId: "entry-1",
entryNumber: "CR-2026-0001",
} satisfies PaymentPostingResult);
const result = await consumer.handle(syntheticEvent());
expect(result).toBeUndefined();
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "POSTED", {
journalEntryId: "entry-1",
});
});
it("acks a successful claim followed by a SKIPPED outcome, and records it", async () => {
inbound.claim.mockResolvedValue("inbound-1");
posting.postPaymentReceipt.mockResolvedValue({
outcome: "SKIPPED",
reason: "already captured by opening balances",
} satisfies PaymentPostingResult);
const result = await consumer.handle(syntheticEvent());
expect(result).toBeUndefined();
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "SKIPPED", {
error: "already captured by opening balances",
});
});
it("acks — never nacks — a successful claim followed by a FAILED posting outcome", async () => {
inbound.claim.mockResolvedValue("inbound-1");
posting.postPaymentReceipt.mockResolvedValue({
outcome: "FAILED",
reason: "period is closed",
} satisfies PaymentPostingResult);
const result = await consumer.handle(syntheticEvent());
// The deliberate part: a FAILED posting outcome is still acked (recorded
// for manual replay), NOT nacked/dead-lettered.
expect(result).toBeUndefined();
expect(result).not.toBeInstanceOf(Nack);
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "FAILED", {
error: "period is closed",
});
});
it("acks even when posting itself throws, and records the row as FAILED best-effort", async () => {
inbound.claim.mockResolvedValue("inbound-1");
posting.postPaymentReceipt.mockRejectedValue(new Error("unexpected crash"));
const result = await consumer.handle(syntheticEvent());
expect(result).toBeUndefined();
expect(result).not.toBeInstanceOf(Nack);
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "FAILED", {
error: "unexpected crash",
});
});
});

View File

@@ -0,0 +1,524 @@
import { Logger } from "@nestjs/common";
import { PaymentReferenceType, PaymentService, ProviderMethod } from "@edr/types";
import type { PaymentFailedEvent, PaymentSucceededEvent } from "@edr/types";
import { RevenuePostingService } from "./revenue-posting.service";
import type { ActorContext } from "../../common/current-actor.util";
/**
* All test fixtures below are synthetic — invented account codes, round
* amounts and made-up organization/revenue-key names. Nothing here resembles
* a real organization's revenue figures.
*/
const ORG_ID = "org-synthetic-1";
function actor(overrides: Partial<ActorContext> = {}): ActorContext {
return {
employeeId: null,
userId: "",
organizationId: ORG_ID,
isSuperAdmin: true,
...overrides,
};
}
function syntheticSucceeded(
overrides: Partial<PaymentSucceededEvent> = {},
): PaymentSucceededEvent {
return {
version: 1,
eventId: "evt-synthetic-1",
eventType: "payment.succeeded",
occurredAt: "2026-07-10T00:00:00.000Z",
service: PaymentService.FREIGHT,
intentId: "intent-synthetic-1",
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: "shipment-synthetic-1",
merchantOrderId: "order-synthetic-1",
provider: ProviderMethod.CARD,
amountMinor: 500,
currency: "ETB",
paidAt: "2026-07-10T00:00:00.000Z",
...overrides,
};
}
function syntheticFailed(
overrides: Partial<PaymentFailedEvent> = {},
): PaymentFailedEvent {
return {
version: 1,
eventId: "evt-synthetic-2",
eventType: "payment.failed",
occurredAt: "2026-07-10T00:00:00.000Z",
service: PaymentService.FREIGHT,
intentId: "intent-synthetic-2",
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: "shipment-synthetic-2",
merchantOrderId: "order-synthetic-2",
provider: ProviderMethod.CARD,
amountMinor: 500,
currency: "ETB",
failureCode: "DECLINED",
...overrides,
};
}
function account(id: string, code: string) {
return { id, code, name: { am: "", en: "" } };
}
function makeDeps() {
const mappings = { findMatch: jest.fn() };
const accounts = { findByCode: jest.fn(), findOne: jest.fn() };
const journals = { createPosted: jest.fn() };
const projection = { revenueByKey: jest.fn() };
const cutover = { cutoverDateFor: jest.fn().mockResolvedValue(null) };
return { mappings, accounts, journals, projection, cutover };
}
function build(deps: ReturnType<typeof makeDeps>) {
return new RevenuePostingService(
deps.mappings as never,
deps.accounts as never,
deps.journals as never,
deps.projection as never,
deps.cutover as never,
);
}
describe("RevenuePostingService.postPaymentReceipt", () => {
let deps: ReturnType<typeof makeDeps>;
let service: RevenuePostingService;
beforeEach(() => {
deps = makeDeps();
service = build(deps);
deps.accounts.findByCode.mockImplementation(
(_org: string, code: string) => {
if (code === "1114") return Promise.resolve(account("acct-clearing", "1114"));
if (code === "1121") return Promise.resolve(account("acct-recv-freight", "1121"));
if (code === "1122")
return Promise.resolve(account("acct-recv-passenger", "1122"));
return Promise.resolve(null);
},
);
});
it("SKIPs non-'payment.succeeded' events and writes no journal", async () => {
const result = await service.postPaymentReceipt(actor(), syntheticFailed());
expect(result.outcome).toBe("SKIPPED");
expect(deps.journals.createPosted).not.toHaveBeenCalled();
});
it("posts normally when cutoverDate is null — no boundary set", async () => {
deps.cutover.cutoverDateFor.mockResolvedValue(null);
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "CR-2026-0001",
});
const result = await service.postPaymentReceipt(actor(), syntheticSucceeded());
expect(result.outcome).toBe("POSTED");
expect(deps.journals.createPosted).toHaveBeenCalledTimes(1);
});
it("SKIPs a payment settled before the cutover date — already captured by opening balances", async () => {
deps.cutover.cutoverDateFor.mockResolvedValue("2026-08-01");
const result = await service.postPaymentReceipt(
actor(),
syntheticSucceeded({ paidAt: "2026-07-01T00:00:00.000Z" }),
);
expect(result.outcome).toBe("SKIPPED");
if (result.outcome === "SKIPPED") {
expect(result.reason).toMatch(/before the cutover/i);
}
expect(deps.journals.createPosted).not.toHaveBeenCalled();
});
it("posts a payment settled ON or after the cutover date", async () => {
deps.cutover.cutoverDateFor.mockResolvedValue("2026-07-01");
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "CR-2026-0001",
});
const result = await service.postPaymentReceipt(
actor(),
syntheticSucceeded({ paidAt: "2026-07-01T00:00:00.000Z" }),
);
expect(result.outcome).toBe("POSTED");
});
it("FAILs a non-ETB event rather than silently converting at a guessed rate", async () => {
const result = await service.postPaymentReceipt(
actor(),
syntheticSucceeded({ currency: "USD" }),
);
expect(result.outcome).toBe("FAILED");
if (result.outcome === "FAILED") {
expect(result.reason).toMatch(/USD/);
expect(result.reason).toMatch(/ETB/);
}
expect(deps.journals.createPosted).not.toHaveBeenCalled();
});
it("FAILs a non-positive amountMinor (which carries MAJOR units on this path, despite the name)", async () => {
const result = await service.postPaymentReceipt(
actor(),
syntheticSucceeded({ amountMinor: 0 }),
);
expect(result.outcome).toBe("FAILED");
if (result.outcome === "FAILED") {
expect(result.reason).toMatch(/non-positive/i);
}
expect(deps.journals.createPosted).not.toHaveBeenCalled();
});
it("FAILs a negative amountMinor too", async () => {
const result = await service.postPaymentReceipt(
actor(),
syntheticSucceeded({ amountMinor: -50 }),
);
expect(result.outcome).toBe("FAILED");
});
it("posts Dr 1114 gateway-clearing / Cr 1121 receivable for a FREIGHT service payment, with the idempotency key set", async () => {
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "CR-2026-0001",
});
const event = syntheticSucceeded({ service: PaymentService.FREIGHT, amountMinor: 750 });
const result = await service.postPaymentReceipt(actor(), event);
expect(result.outcome).toBe("POSTED");
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
expect(postedDto.sourceModule).toBe("payment");
expect(postedDto.sourceId).toBe(event.eventId);
const debitLine = postedDto.lines.find((l: { debit?: number }) => l.debit);
const creditLine = postedDto.lines.find((l: { credit?: number }) => l.credit);
expect(debitLine.accountId).toBe("acct-clearing");
expect(debitLine.debit).toBe(750);
expect(creditLine.accountId).toBe("acct-recv-freight");
expect(creditLine.credit).toBe(750);
});
it("posts Cr 1122 receivable for a PASSENGER service payment", async () => {
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "CR-2026-0001",
});
const event = syntheticSucceeded({ service: PaymentService.PASSENGER });
await service.postPaymentReceipt(actor(), event);
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
const creditLine = postedDto.lines.find((l: { credit?: number }) => l.credit);
expect(creditLine.accountId).toBe("acct-recv-passenger");
});
it("catches any exception during posting and returns a FAILED result instead of throwing", async () => {
deps.journals.createPosted.mockRejectedValue(
new Error("period 2026-07 is closed"),
);
const result = await service.postPaymentReceipt(actor(), syntheticSucceeded());
expect(result.outcome).toBe("FAILED");
if (result.outcome === "FAILED") {
expect(result.reason).toBe("period 2026-07 is closed");
}
});
it("does not throw/reject even though createPosted rejected", async () => {
deps.journals.createPosted.mockRejectedValue(new Error("boom"));
await expect(
service.postPaymentReceipt(actor(), syntheticSucceeded()),
).resolves.toMatchObject({ outcome: "FAILED" });
});
});
describe("RevenuePostingService.recognizeRevenue", () => {
let deps: ReturnType<typeof makeDeps>;
let service: RevenuePostingService;
const receivableFreight = account("acct-recv-freight", "1121");
const unclassified = account("acct-4900", "4900");
beforeEach(() => {
deps = makeDeps();
service = build(deps);
deps.accounts.findByCode.mockImplementation(
(_org: string, code: string) => {
if (code === "1121") return Promise.resolve(receivableFreight);
if (code === "1122") return Promise.resolve(account("acct-recv-passenger", "1122"));
if (code === "4900") return Promise.resolve(unclassified);
return Promise.resolve(null);
},
);
});
afterEach(() => {
jest.useRealTimers();
});
it("filters buckets to the requested sourceModule only", async () => {
deps.projection.revenueByKey.mockResolvedValue([
{
sourceModule: "freight",
revenueKey: "TEST-FREIGHT-CHARGE",
currency: "ETB",
amount: 1000,
documentCount: 1,
postable: true,
},
{
sourceModule: "passenger",
revenueKey: "TEST-PASSENGER-FARE",
currency: "ETB",
amount: 2000,
documentCount: 1,
postable: true,
},
]);
deps.mappings.findMatch.mockResolvedValue(null);
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "SALES-2026-0001",
});
const result = await service.recognizeRevenue(actor(), {
sourceModule: "freight",
period: "2026-07",
});
expect(result.total).toBe(1000);
expect(deps.mappings.findMatch).toHaveBeenCalledWith(
ORG_ID,
"freight",
"TEST-FREIGHT-CHARGE",
);
expect(deps.mappings.findMatch).not.toHaveBeenCalledWith(
ORG_ID,
"freight",
"TEST-PASSENGER-FARE",
);
});
it("excludes non-ETB and non-positive buckets into excluded[] instead of failing the whole run", async () => {
deps.projection.revenueByKey.mockResolvedValue([
{
sourceModule: "freight",
revenueKey: "TEST-USD-CHARGE",
currency: "USD",
amount: 500,
documentCount: 1,
postable: false,
},
{
sourceModule: "freight",
revenueKey: "TEST-ZERO-CHARGE",
currency: "ETB",
amount: 0,
documentCount: 1,
postable: true,
},
{
sourceModule: "freight",
revenueKey: "TEST-GOOD-CHARGE",
currency: "ETB",
amount: 300,
documentCount: 1,
postable: true,
},
]);
deps.mappings.findMatch.mockResolvedValue(null);
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "SALES-2026-0001",
});
const result = await service.recognizeRevenue(actor(), {
sourceModule: "freight",
period: "2026-07",
});
expect(result.posted).toBe(true);
expect(result.total).toBe(300);
expect(result.excluded).toHaveLength(2);
expect(result.excluded.map((e) => e.revenueKey).sort()).toEqual(
["TEST-USD-CHARGE", "TEST-ZERO-CHARGE"].sort(),
);
});
it("returns posted:false with no journal call when every bucket is excluded", async () => {
deps.projection.revenueByKey.mockResolvedValue([
{
sourceModule: "freight",
revenueKey: "TEST-USD-CHARGE",
currency: "USD",
amount: 500,
documentCount: 1,
postable: false,
},
]);
const result = await service.recognizeRevenue(actor(), {
sourceModule: "freight",
period: "2026-07",
});
expect(result.posted).toBe(false);
expect(deps.journals.createPosted).not.toHaveBeenCalled();
});
it("falls back an unmapped revenue key to 4900 Unclassified Revenue and logs a warning, without failing", async () => {
const warnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
deps.projection.revenueByKey.mockResolvedValue([
{
sourceModule: "freight",
revenueKey: "TEST-UNMAPPED-CHARGE",
currency: "ETB",
amount: 400,
documentCount: 1,
postable: true,
},
]);
deps.mappings.findMatch.mockResolvedValue(null);
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "SALES-2026-0001",
});
const result = await service.recognizeRevenue(actor(), {
sourceModule: "freight",
period: "2026-07",
});
expect(result.posted).toBe(true);
expect(result.lines).toHaveLength(1);
expect(result.lines[0].accountCode).toBe("4900");
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it("summarizes multiple revenue keys mapped to the same account into ONE journal line", async () => {
deps.projection.revenueByKey.mockResolvedValue([
{
sourceModule: "freight",
revenueKey: "TEST-CHARGE-A",
currency: "ETB",
amount: 100,
documentCount: 1,
postable: true,
},
{
sourceModule: "freight",
revenueKey: "TEST-CHARGE-B",
currency: "ETB",
amount: 250,
documentCount: 1,
postable: true,
},
]);
const revenueAccount = account("acct-revenue-x", "4100");
deps.mappings.findMatch.mockImplementation(
(_org: string, _src: string, key: string) =>
Promise.resolve({ accountId: revenueAccount.id, matchValue: key }),
);
deps.accounts.findOne.mockResolvedValue(revenueAccount);
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "SALES-2026-0001",
});
const result = await service.recognizeRevenue(actor(), {
sourceModule: "freight",
period: "2026-07",
});
expect(result.lines).toHaveLength(1);
expect(result.lines[0].accountCode).toBe("4100");
expect(result.lines[0].amount).toBe(350);
expect(result.lines[0].revenueKey).toContain("TEST-CHARGE-A");
expect(result.lines[0].revenueKey).toContain("TEST-CHARGE-B");
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
const revenueCreditLines = postedDto.lines.filter(
(l: { accountId: string }) => l.accountId === revenueAccount.id,
);
expect(revenueCreditLines).toHaveLength(1);
expect(revenueCreditLines[0].credit).toBe(350);
});
it("dates the entry the LAST DAY OF THE PERIOD, not today", async () => {
// "Today" is deliberately a different month than the period being
// recognized, so a bug that used `new Date()` for entryDate would fail
// this assertion.
jest.useFakeTimers().setSystemTime(new Date("2026-01-15T00:00:00.000Z"));
deps.projection.revenueByKey.mockResolvedValue([
{
sourceModule: "freight",
revenueKey: "TEST-CHARGE-A",
currency: "ETB",
amount: 100,
documentCount: 1,
postable: true,
},
]);
deps.mappings.findMatch.mockResolvedValue(null);
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "SALES-2026-0001",
});
await service.recognizeRevenue(actor(), {
sourceModule: "freight",
period: "2026-07",
});
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
expect(postedDto.entryDate).toBe("2026-07-31");
});
it("Dr's the receivable control account for the total and Cr's each revenue line", async () => {
deps.projection.revenueByKey.mockResolvedValue([
{
sourceModule: "freight",
revenueKey: "TEST-CHARGE-A",
currency: "ETB",
amount: 100,
documentCount: 1,
postable: true,
},
]);
deps.mappings.findMatch.mockResolvedValue(null);
deps.journals.createPosted.mockResolvedValue({
id: "entry-1",
entryNumber: "SALES-2026-0001",
});
await service.recognizeRevenue(actor(), {
sourceModule: "freight",
period: "2026-07",
});
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
const receivableLine = postedDto.lines.find(
(l: { accountId: string }) => l.accountId === receivableFreight.id,
);
expect(receivableLine.debit).toBe(100);
});
});