This commit is contained in:
seb
2026-10-17 19:03:24 +02:00
parent f8cc01baf0
commit ab42a3ffb4
7 changed files with 2152 additions and 0 deletions

446
test/ledger.test.js Normal file
View File

@@ -0,0 +1,446 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { createTestStore, createTenant, createArticle, createCustomer, isoNow } from './helpers.js';
import { computePrevHash, INIT_HASH } from '../src/store/ledger/add.js';
describe('store.ledger', () => {
let ctx;
let store;
let tenant;
let other;
let gid;
let supplier;
let warehouse;
let warehouse2;
let customer;
let seq = 0;
const uniqueSku = (p) => `${p}-${++seq}`;
const makeDelivery = async (sku, amount = 5, extra = {}) => {
const d = await store.delivery.create(gid, {
supplierId: supplier.id,
date: isoNow(),
lines: [{ sku, name: `Line ${sku}`, pu: 1, amount, net: 10, taxrate: 19, ...extra }],
});
return { delivery: d, lineId: d.lines[0].id };
};
const makeShipmentLine = async (sku, orderAmount, shipAmount) => {
seq += 1;
const order = await store.order.create(gid, {
number: `LG-O-${seq}`,
date: '2026-10-12',
entityId: customer.id,
lines: [{ sku, name: `Order ${sku}`, pu: 1, amount: orderAmount, net: 25, taxrate: 19 }],
});
const shipment = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku, amount: shipAmount }] });
return { order, shipment, lineId: shipment.lines[0].id };
};
const stockOf = async (sku, warehouseId = warehouse.id) => {
const art = (await store.article.getBySku(gid, sku)).article;
return art.warehouses.find((w) => w.id === warehouseId)?.stock;
};
before(async () => {
ctx = await createTestStore('dash-ledger-');
store = ctx.store;
tenant = await createTenant(store, { name: 'Ledger Test GmbH' });
other = await createTenant(store, { name: 'Ledger Other GmbH' });
gid = tenant.googleAuthId;
supplier = await store.supplier.create(gid, { name: 'Ledger Supplier' });
warehouse = await store.warehouse.create(gid, { name: 'Hauptlager' });
warehouse2 = await store.warehouse.create(gid, { name: 'Nebenlager' });
customer = await createCustomer(store, tenant, 'Ledger Customer');
});
after(async () => {
await ctx.cleanup();
});
describe('add — delivery lines', () => {
it('untracked article: single ACCEPTANCE row without warehouse', async () => {
const sku = uniqueSku('UNTRACKED');
await createArticle(store, tenant, { sku });
const { delivery, lineId } = await makeDelivery(sku, 5);
const row = await store.ledger.add(gid, {
manifestarticle_id: lineId,
transaction_type: 'ACCEPTANCE',
amount: 5,
transaction_text: `Lieferung ${delivery.number}`,
});
assert.equal(typeof row.id, 'number');
assert.equal(row.manifestarticle_id, lineId);
assert.equal(row.warehouse_id, null);
assert.equal(row.transaction_type, 'ACCEPTANCE');
assert.equal(row.amount, 5);
assert.equal(row.transaction_text, `Lieferung ${delivery.number}`);
assert.equal(row.hash_version, 1);
assert.match(row.prev_hash, /^[0-9a-f]{128}$|^TODO_TSE_BASED_INITHASH$/);
assert.ok(!Number.isNaN(Date.parse(row.created_at)));
const entries = await store.ledger.listByManifestarticleId(gid, lineId);
assert.equal(entries.length, 1);
});
it('tracked article: ACCEPTANCE + WAREHOUSE rows and stock increase', async () => {
const sku = uniqueSku('TRACKED');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 10);
const row = await store.ledger.add(gid, {
manifestarticle_id: lineId,
warehouse_id: warehouse.id,
transaction_type: 'ACCEPTANCE',
amount: 10,
transaction_text: 'Eingang',
});
assert.equal(row.warehouse_id, null);
assert.equal(row.transaction_type, 'ACCEPTANCE');
const entries = await store.ledger.listByManifestarticleId(gid, lineId);
assert.deepEqual(entries.map((e) => e.transaction_type), ['ACCEPTANCE', 'WAREHOUSE']);
assert.deepEqual(entries.map((e) => e.warehouse_id), [null, warehouse.id]);
assert.deepEqual(entries.map((e) => e.amount), [10, 10]);
assert.equal(entries[1].id, entries[0].id + 1);
assert.equal(await stockOf(sku), 10);
});
it('uses the default warehouse when none is given', async () => {
const sku = uniqueSku('DEFWH');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 3);
await store.ledger.add(gid, { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 3, transaction_text: 'x' });
const entries = await store.ledger.listByManifestarticleId(gid, lineId);
assert.equal(entries.length, 2);
assert.ok(entries[1].warehouse_id > 0);
});
it('stores acceptance amounts as positive numbers', async () => {
const sku = uniqueSku('ABS');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 4);
const row = await store.ledger.add(gid, { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: -4, transaction_text: 'x' });
assert.equal(row.amount, 4);
});
it('rejects double booking of a delivery line', async () => {
const sku = uniqueSku('DOUBLE');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 2);
const input = { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 2, transaction_text: 'x' };
await store.ledger.add(gid, input);
await assert.rejects(() => store.ledger.add(gid, input), { message: 'Delivery line already booked on ledger' });
assert.equal((await store.ledger.listByManifestarticleId(gid, lineId)).length, 1);
});
it('rejects an unknown warehouse and rolls back', async () => {
const sku = uniqueSku('BADWH');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 2);
await assert.rejects(
() => store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: 999999, transaction_type: 'ACCEPTANCE', amount: 2, transaction_text: 'x' }),
{ message: 'Warehouse not found' },
);
assert.equal((await store.ledger.listByManifestarticleId(gid, lineId)).length, 0, 'transaction rolled back');
});
it('accepts camelCase aliases', async () => {
const sku = uniqueSku('CAMEL');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
const row = await store.ledger.add(gid, { manifestarticleId: lineId, transactionType: 'ACCEPTANCE', amount: 1, transactionText: 'alias' });
assert.equal(row.manifestarticle_id, lineId);
assert.equal(row.transaction_text, 'alias');
});
});
describe('add — shipment lines', () => {
it('books SHIPMENT (negative) + WAREHOUSE rows and reduces stock', async () => {
const sku = uniqueSku('SHIP');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId: inLine } = await makeDelivery(sku, 10);
await store.ledger.add(gid, { manifestarticle_id: inLine, warehouse_id: warehouse.id, transaction_type: 'ACCEPTANCE', amount: 10, transaction_text: 'in' });
const { lineId } = await makeShipmentLine(sku, 5, 3);
const row = await store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: warehouse.id, transaction_type: 'SHIPMENT', amount: 3, transaction_text: 'out' });
assert.equal(row.transaction_type, 'SHIPMENT');
assert.equal(row.warehouse_id, null);
assert.equal(row.amount, -3);
const entries = await store.ledger.listByManifestarticleId(gid, lineId);
assert.deepEqual(entries.map((e) => [e.transaction_type, e.warehouse_id, e.amount]), [
['SHIPMENT', null, -3],
['WAREHOUSE', warehouse.id, -3],
]);
assert.equal(await stockOf(sku), 7);
});
it('rejects double booking of a shipment line', async () => {
const sku = uniqueSku('SHIPDBL');
await createArticle(store, tenant, { sku });
const { lineId } = await makeShipmentLine(sku, 2, 2);
const input = { manifestarticle_id: lineId, transaction_type: 'SHIPMENT', amount: 2, transaction_text: 'x' };
await store.ledger.add(gid, input);
await assert.rejects(() => store.ledger.add(gid, input), { message: 'Shipment line already booked on ledger' });
});
});
describe('add — guards', () => {
it('refuses to book quote and order lines', async () => {
const sku = uniqueSku('DOC');
await createArticle(store, tenant, { sku });
const line = { sku, name: 'x', pu: 1, amount: 1, net: 1, taxrate: 19 };
const quote = await store.quote.create(gid, { number: `LG-Q-${sku}`, date: isoNow(), entityId: customer.id, lines: [line] });
const order = await store.order.create(gid, { number: `LG-O-${sku}`, date: isoNow(), entityId: customer.id, lines: [line] });
const base = { transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' };
await assert.rejects(() => store.ledger.add(gid, { ...base, manifestarticle_id: quote.lines[0].id }), { message: 'Cannot book ledger entries for quote' });
await assert.rejects(() => store.ledger.add(gid, { ...base, manifestarticle_id: order.lines[0].id }), { message: 'Cannot book ledger entries for order' });
});
it('validates the payload', async () => {
const sku = uniqueSku('VALID');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
const ok = { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' };
const bad = (patch) => store.ledger.add(gid, { ...ok, ...patch });
const err = { message: 'Invalid ledger entry' };
await assert.rejects(() => bad({ manifestarticle_id: 0 }), err);
await assert.rejects(() => bad({ manifestarticle_id: 1.5 }), err);
await assert.rejects(() => bad({ manifestarticle_id: 'x' }), err);
await assert.rejects(() => bad({ warehouse_id: -1 }), err);
await assert.rejects(() => bad({ warehouse_id: 'x' }), err);
await assert.rejects(() => bad({ transaction_type: ' ' }), err);
await assert.rejects(() => bad({ transaction_type: 'T'.repeat(101) }), err);
await assert.rejects(() => bad({ transaction_type: 5 }), err);
await assert.rejects(() => bad({ amount: 'abc' }), err);
await assert.rejects(() => bad({ amount: Number.POSITIVE_INFINITY }), err);
await assert.rejects(() => bad({ transaction_text: undefined }), err);
await assert.rejects(() => bad({ transaction_text: null }), err);
});
it('rejects unknown manifest articles and ones of other companies', async () => {
const sku = uniqueSku('FOREIGN');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
const input = { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' };
await assert.rejects(() => store.ledger.add(gid, { ...input, manifestarticle_id: 999999 }), { message: 'Manifest article not found' });
await assert.rejects(() => store.ledger.add(other.googleAuthId, input), { message: 'Manifest article not found' });
assert.equal((await store.ledger.listByManifestarticleId(gid, lineId)).length, 0);
});
});
describe('hash chain', () => {
it('chains every row to the SHA-512 hash of the previous row', async () => {
const t = await createTenant(store, { name: 'Chain GmbH' });
await store.warehouse.create(t.googleAuthId, { name: 'Chain WH' });
await createArticle(store, t, { sku: 'CHAIN-1', track_stock: true });
const rows = [];
for (let i = 1; i <= 3; i += 1) {
const d = await store.delivery.create(t.googleAuthId, {
date: isoNow(),
lines: [{ sku: 'CHAIN-1', name: 'c', pu: 1, amount: i, net: 1, taxrate: 19 }],
});
await store.ledger.add(t.googleAuthId, { manifestarticle_id: d.lines[0].id, transaction_type: 'ACCEPTANCE', amount: i, transaction_text: `c${i}` });
rows.push(...(await store.ledger.listByManifestarticleId(t.googleAuthId, d.lines[0].id)));
}
assert.equal(rows.length, 6);
for (let i = 1; i < rows.length; i += 1) {
assert.equal(rows[i].prev_hash, computePrevHash(rows[i - 1]), `row ${rows[i].id} links to row ${rows[i - 1].id}`);
assert.ok(rows[i].id > rows[i - 1].id);
assert.equal(rows[i].hash_version, 1);
}
});
it('computePrevHash returns INIT_HASH for no row and a deterministic sha512 otherwise', () => {
assert.equal(computePrevHash(null), INIT_HASH);
assert.equal(computePrevHash(undefined), INIT_HASH);
const row = {
id: 7, manifestarticle_id: 3, warehouse_id: undefined, transaction_type: 'ACCEPTANCE',
amount: 2, transaction_text: 'x', hash_version: 1, prev_hash: INIT_HASH,
created_at: new Date('2026-01-01T00:00:00.000Z'),
};
const expected = createHash('sha512').update(JSON.stringify({
id: 7, manifestarticle_id: 3, warehouse_id: null, transaction_type: 'ACCEPTANCE',
amount: 2, transaction_text: 'x', hash_version: 1, prev_hash: INIT_HASH,
created_at: '2026-01-01T00:00:00.000Z',
}), 'utf8').digest('hex');
assert.equal(computePrevHash(row), expected);
assert.equal(computePrevHash({ ...row, created_at: '2026-01-01T00:00:00.000Z' }), expected);
assert.notEqual(computePrevHash({ ...row, amount: 3 }), expected);
});
});
describe('listByManifestarticleId', () => {
it('filters by transaction type and warehouse', async () => {
const sku = uniqueSku('FILTER');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 4);
await store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: warehouse2.id, transaction_type: 'ACCEPTANCE', amount: 4, transaction_text: 'x' });
const byType = await store.ledger.listByManifestarticleId(gid, lineId, 'WAREHOUSE');
assert.deepEqual(byType.map((e) => e.transaction_type), ['WAREHOUSE']);
const byObjType = await store.ledger.listByManifestarticleId(gid, lineId, { transactionType: 'ACCEPTANCE' });
assert.deepEqual(byObjType.map((e) => e.transaction_type), ['ACCEPTANCE']);
const byWh = await store.ledger.listByManifestarticleId(gid, lineId, { warehouse_id: warehouse2.id });
assert.deepEqual(byWh.map((e) => e.warehouse_id), [warehouse2.id]);
const none = await store.ledger.listByManifestarticleId(gid, lineId, { warehouseId: warehouse.id });
assert.deepEqual(none, []);
assert.ok(byType[0].sku === sku);
});
it('rejects invalid, unknown and foreign lines', async () => {
const sku = uniqueSku('LISTERR');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
await assert.rejects(() => store.ledger.listByManifestarticleId(gid, 0), { message: 'Invalid manifest article id' });
await assert.rejects(() => store.ledger.listByManifestarticleId(gid, 'x'), { message: 'Invalid manifest article id' });
await assert.rejects(() => store.ledger.listByManifestarticleId(gid, 999999), { message: 'Manifest article not found' });
await assert.rejects(() => store.ledger.listByManifestarticleId(other.googleAuthId, lineId), { message: 'Manifest article not found' });
});
});
describe('listBySku', () => {
it('lists all movements of an article in booking order', async () => {
const sku = uniqueSku('BYSKU');
await createArticle(store, tenant, { sku, track_stock: true });
const { lineId } = await makeDelivery(sku, 8);
await store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: warehouse.id, transaction_type: 'ACCEPTANCE', amount: 8, transaction_text: 'in' });
const ship = await makeShipmentLine(sku, 5, 2);
await store.ledger.add(gid, { manifestarticle_id: ship.lineId, warehouse_id: warehouse.id, transaction_type: 'SHIPMENT', amount: 2, transaction_text: 'out' });
const all = await store.ledger.listBySku(gid, sku);
assert.deepEqual(all.map((e) => e.transaction_type), ['ACCEPTANCE', 'WAREHOUSE', 'SHIPMENT', 'WAREHOUSE']);
assert.ok(all.every((e) => e.sku === sku));
assert.equal(all.reduce((sum, e) => sum + (e.warehouse_id ? e.amount : 0), 0), 6, 'warehouse rows sum to stock');
const shipments = await store.ledger.listBySku(gid, sku, 'SHIPMENT');
assert.equal(shipments.length, 1);
assert.equal(shipments[0].amount, -2);
const whRows = await store.ledger.listBySku(gid, sku, { warehouseId: warehouse.id });
assert.equal(whRows.length, 2);
});
it('trims the SKU, rejects blanks and does not leak across companies', async () => {
const sku = uniqueSku('BYSKU2');
await createArticle(store, tenant, { sku });
const { lineId } = await makeDelivery(sku, 1);
await store.ledger.add(gid, { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' });
assert.equal((await store.ledger.listBySku(gid, ` ${sku} `)).length, 1);
assert.deepEqual(await store.ledger.listBySku(other.googleAuthId, sku), []);
await assert.rejects(() => store.ledger.listBySku(gid, ' '), { message: 'Invalid SKU' });
await assert.rejects(() => store.ledger.listBySku(gid, null), { message: 'Invalid SKU' });
assert.deepEqual(await store.ledger.listBySku(gid, 'NO-SUCH-SKU'), []);
});
});
describe('stock.adjust (adjustment manifests)', () => {
it('books a warehouse movement via the ledger and updates stock', async () => {
const sku = uniqueSku('ADJ');
await createArticle(store, tenant, { sku, track_stock: true });
const res = await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 12, text: 'Inventur' });
assert.equal(res.manifest.type, 'adjustment');
assert.equal(res.ledger.length, 1);
assert.equal(res.ledger[0].warehouse_id, warehouse.id);
assert.equal(res.ledger[0].amount, 12);
assert.equal(await stockOf(sku), 12);
await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: -5 });
assert.equal(await stockOf(sku), 7);
assert.equal((await store.ledger.listBySku(gid, sku)).length, 2);
});
it('rejects adjustments for untracked articles and bad amounts', async () => {
const sku = uniqueSku('ADJBAD');
await createArticle(store, tenant, { sku });
await assert.rejects(
() => store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 1 }),
{ message: `Stock tracking is disabled for article (${sku})` },
);
const tracked = uniqueSku('ADJBAD2');
await createArticle(store, tenant, { sku: tracked, track_stock: true });
await assert.rejects(() => store.stock.adjust(gid, { sku: tracked, warehouseId: warehouse.id, amount: 0 }), /non-zero/);
});
it('disabling tracking writes a zeroing ledger entry', async () => {
const sku = uniqueSku('ADJOFF');
await createArticle(store, tenant, { sku, track_stock: true });
await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 9 });
assert.equal(await stockOf(sku), 9);
await store.article.setTrackStockBySku(gid, sku, false);
assert.equal(await stockOf(sku), 0);
});
});
describe('stock.accept / stock.ship', () => {
it('accept books positive lines as ACCEPTANCE and negative lines as RETURN', async () => {
const sku = uniqueSku('ACCEPT');
await createArticle(store, tenant, { sku, track_stock: true });
const d = await store.delivery.create(gid, {
supplierId: supplier.id,
date: isoNow(),
lines: [
{ sku, name: 'in', pu: 1, amount: 6, net: 10, taxrate: 19 },
{ sku, name: 'ret', pu: 1, amount: -2, net: 10, taxrate: 19 },
],
});
const pos = await store.stock.accept(gid, { manifestarticleId: d.lines[0].id, warehouseId: warehouse.id });
assert.equal(pos.document_ledger.transaction_type, 'ACCEPTANCE');
assert.equal(pos.document_ledger.amount, 6);
assert.equal(pos.adjust_ledger.amount, 6);
assert.equal(pos.adjust_ledger.warehouse_id, warehouse.id);
const neg = await store.stock.accept(gid, { manifestarticleId: d.lines[1].id, warehouseId: warehouse.id });
assert.equal(neg.document_ledger.transaction_type, 'RETURN');
assert.equal(neg.document_ledger.amount, -2);
assert.equal(await stockOf(sku), 4);
});
it('ship books positive lines as SHIPMENT and negative lines as RETOURE', async () => {
const sku = uniqueSku('SHIPSTK');
await createArticle(store, tenant, { sku, track_stock: true });
await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 20 });
seq += 1;
const order = await store.order.create(gid, {
number: `LG-SO-${seq}`, date: '2026-10-12', entityId: customer.id,
lines: [{ sku, name: 'o', pu: 1, amount: 10, net: 1, taxrate: 19 }],
});
const shipment = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku, amount: 4 }, { sku, amount: -1 }] });
const pos = await store.stock.ship(gid, { manifestarticleId: shipment.lines[0].id, warehouseId: warehouse.id });
assert.equal(pos.document_ledger.transaction_type, 'SHIPMENT');
assert.equal(pos.document_ledger.amount, -4);
const neg = await store.stock.ship(gid, { manifestarticleId: shipment.lines[1].id, warehouseId: warehouse.id });
assert.equal(neg.document_ledger.transaction_type, 'RETOURE');
assert.equal(neg.document_ledger.amount, 1);
assert.equal(await stockOf(sku), 17);
});
});
describe('append-only', () => {
it('exposes no mutating API besides add', () => {
assert.deepEqual(Object.keys(store.ledger).sort(), ['add', 'listByManifestarticleId', 'listBySku']);
});
});
});