mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
Adding all functionalities
This commit is contained in:
416
apps/finance-api/src/modules/assets/assets.service.spec.ts
Normal file
416
apps/finance-api/src/modules/assets/assets.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
140
apps/finance-api/src/modules/budgeting/budgeting.service.spec.ts
Normal file
140
apps/finance-api/src/modules/budgeting/budgeting.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
536
apps/finance-api/src/modules/cutover/cutover.service.spec.ts
Normal file
536
apps/finance-api/src/modules/cutover/cutover.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
582
apps/finance-api/src/modules/journals/journals.service.spec.ts
Normal file
582
apps/finance-api/src/modules/journals/journals.service.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
444
apps/finance-api/src/modules/payables/payables.service.spec.ts
Normal file
444
apps/finance-api/src/modules/payables/payables.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user