Test file
Ledger invariants
The stock rules on their own: a balance always equals its movements, a version applies once, an oversell opens one incident, and refunds can’t add stock.
tests/inventory.test.ts · 183 lines
import { describe, it, expect } from "vitest";
import fc from "fast-check";
import {
seedDomain,
balance,
movement,
applySale,
applyRefund,
resolveIncident,
type DomainState,
type Sale,
} from "../packages/domain/src";
const now = "2026-10-11T10:00:00Z";
// A new workspace opens with 30 bags of each product at the roastery and
// 20 at the cafe.
const opening = 30;
const sale = (
key: string,
quantity: number,
occurredAt = now,
updatedAt = now,
): Sale => ({
key,
source: key.startsWith("square") ? "square" : "shopify",
sourceId: key,
locationId: "roastery",
updatedAt,
occurredAt,
lines: [{ sku: "espresso-250", quantity }],
cause: key,
cancelled: false,
// The till's sales here were made while it was offline.
offline: key.startsWith("square"),
});
const espresso = (state: DomainState, location = "roastery") =>
balance(state, "espresso-250", location);
describe("append-only inventory and oversells", () => {
it("keeps the balance equal to the sum of any sequence of movements", () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: -20, max: 20 }), { maxLength: 60 }),
(deltas) => {
const state = seedDomain("Test cafe", now);
for (const delta of deltas)
movement(state, {
sku: "espresso-250",
locationId: "roastery",
kind: "adjusted",
stockDelta: delta,
reservedDelta: 0,
cause: "test",
at: now,
note: "Test adjustment",
});
const expected = opening + deltas.reduce((a, b) => a + b, 0);
expect(espresso(state)).toEqual({
onHand: expected,
reserved: 0,
available: expected,
});
},
),
);
});
it("applies a version once, even when the newest arrives first", () => {
const state = seedDomain("Test cafe", now);
const newest = sale("shopify:1", 4, now, "2026-10-11T10:05:00Z");
applySale(state, newest);
const written = state.movements.length;
const older = sale("shopify:1", 1);
expect(applySale(state, older).late).toBe(true);
applySale(state, newest);
expect(state.movements).toHaveLength(written);
expect(espresso(state).available).toBe(opening - 4);
});
it("opens one incident for a late offline sale, in sale order, and resolves it by transfer", () => {
const state = seedDomain("Test cafe", now);
movement(state, {
sku: "espresso-250",
locationId: "roastery",
kind: "adjusted",
stockDelta: -28,
reservedDelta: 0,
cause: "prepare",
at: now,
note: "Two bags remain",
});
applySale(state, sale("shopify:1", 1, "2026-10-11T09:50:00Z"));
// Made at 09:00 on the till, delivered after the 09:50 online sale.
const offline = sale("square:1", 2, "2026-10-11T09:00:00Z");
expect(applySale(state, offline).late).toBe(true);
applySale(state, offline);
expect(state.incidents).toHaveLength(1);
const [incident] = state.incidents;
expect(incident.sales.map((sold) => sold.source)).toEqual([
"square",
"shopify",
]);
resolveIncident(
state,
incident.id,
"transfer",
"manager",
"Transfer one bag",
now,
"cafe",
);
expect(espresso(state).available).toBe(0);
expect(espresso(state, "cafe").available).toBe(19);
expect(state.incidents[0].status).toBe("resolved");
});
it("keeps reservations apart from stock on hand and refuses a double release", () => {
const state = seedDomain("Test cafe", now);
const reservation = {
sku: "espresso-250",
locationId: "roastery",
cause: "order",
at: now,
note: "Reserve",
};
movement(state, {
...reservation,
kind: "reserved",
stockDelta: 0,
reservedDelta: 3,
});
expect(espresso(state)).toEqual({
onHand: opening,
reserved: 3,
available: opening - 3,
});
const release = {
...reservation,
kind: "released",
stockDelta: 0,
reservedDelta: -3,
} as const;
movement(state, release);
expect(() => movement(state, release)).toThrow();
});
it("never lets refunds inflate stock, nor a later snapshot restock twice", () => {
const state = seedDomain("Test cafe", now);
const order = sale("shopify-refund", 2);
applySale(state, order);
const refund = (id: string, quantity: number) =>
applyRefund(
state,
"shopify",
id,
order.sourceId,
[{ sku: "espresso-250", quantity }],
id,
now,
);
refund("refund-1", 1);
expect(espresso(state).onHand).toBe(opening - 1);
applySale(state, { ...order, updatedAt: "2026-10-11T10:01:00Z" });
expect(espresso(state).onHand).toBe(opening - 1);
// One of the two bags is already back on the shelf.
expect(() => refund("refund-2", 2)).toThrow("Restock exceeds");
applySale(state, {
...order,
cancelled: true,
updatedAt: "2026-10-11T10:02:00Z",
});
expect(espresso(state).onHand).toBe(opening);
});
});