This commit enhances the stock item management by introducing automatic calculation of selling price and markup based on the cost price. Key changes include: - Integration of `applyDefaultMarkupToPatch` to automatically set `selling_price` and `markup` at 80% of the cost price when they are not explicitly provided during stock item creation and updates. - Updates to the `create` and `update` functions in the stock items service to ensure that existing values are respected and not overwritten if they are already set by the operator. - Tests added to verify the correct behavior of auto-markup during both creation and updates of stock items, ensuring that the functionality works as intended across various scenarios. This feature aims to streamline the pricing process for stock items, reducing manual input and potential errors.
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
/**
|
|
* Tests for the pure default-markup helper.
|
|
*
|
|
* Mirrors the test style of `scripts/apply-default-markup.test.ts`:
|
|
* no PB stub needed (the helper is pure), exported `applyDefaultMarkupToPatch`
|
|
* + `round2` are the surface under test. Each test pins one of the
|
|
* helper's documented rules — keep them in lockstep with the
|
|
* JSDoc bullets in `stock-default-markup-service.ts`.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
applyDefaultMarkupToPatch,
|
|
round2,
|
|
DEFAULT_MARKUP_PERCENT,
|
|
DEFAULT_MARKUP_MULTIPLIER,
|
|
DEFAULT_COST_HISTORY_PURCHASE_ID,
|
|
} from "./stock-default-markup-service";
|
|
|
|
// A fixed clock so the optional `cost_history` seed entry's
|
|
// `purchased_at` is deterministic across runs.
|
|
const NOW = "2026-09-16T08:50:00.000Z";
|
|
|
|
describe("round2", () => {
|
|
it("rounds 117.39 * 1.80 to two decimals (211.302 → 211.30)", () => {
|
|
expect(round2(117.39 * DEFAULT_MARKUP_MULTIPLIER)).toBe(211.30);
|
|
});
|
|
|
|
it("rounds 33.33 * 1.80 (59.994 → 59.99)", () => {
|
|
expect(round2(33.33 * DEFAULT_MARKUP_MULTIPLIER)).toBe(59.99);
|
|
});
|
|
|
|
it("preserves a value that's already at 2dp", () => {
|
|
expect(round2(50.55 * DEFAULT_MARKUP_MULTIPLIER)).toBe(90.99);
|
|
});
|
|
|
|
it("returns 0 for non-finite input", () => {
|
|
expect(round2(Number.NaN)).toBe(0);
|
|
expect(round2(Number.POSITIVE_INFINITY)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("applyDefaultMarkupToPatch", () => {
|
|
// 1. Guard: cost_price = 0 in patch → helper does nothing.
|
|
it("returns the patch unchanged when cost_price in the patch is 0", () => {
|
|
const out = applyDefaultMarkupToPatch({ cost_price: 0 }, undefined, NOW);
|
|
expect(out).toEqual({ cost_price: 0 });
|
|
expect(out.selling_price).toBeUndefined();
|
|
expect(out.markup).toBeUndefined();
|
|
});
|
|
|
|
// 2. Happy path: cost > 0, no existing row, no selling/markup in patch.
|
|
it("populates selling_price and markup when cost_price is positive and selling/markup are unset", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 100 },
|
|
undefined,
|
|
NOW,
|
|
);
|
|
expect(out.selling_price).toBe(180);
|
|
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
|
expect(out.cost_price).toBe(100);
|
|
});
|
|
|
|
// 3. Idempotency — existing row has selling_price > 0.
|
|
it("does NOT overwrite selling_price when the existing row already has a non-zero selling_price", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 100 },
|
|
{ cost_price: 100, selling_price: 150, markup: 0 },
|
|
NOW,
|
|
);
|
|
expect(out.selling_price).toBeUndefined(); // helper never writes the field
|
|
expect(out.markup).toBeUndefined(); // and never the markup either (selling was already set)
|
|
});
|
|
|
|
// 4. Idempotency — existing row has markup > 0.
|
|
it("does NOT overwrite markup when the existing row already has a non-zero markup", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 100 },
|
|
{ cost_price: 100, selling_price: 0, markup: 50 },
|
|
NOW,
|
|
);
|
|
expect(out.markup).toBeUndefined();
|
|
expect(out.selling_price).toBeUndefined();
|
|
});
|
|
|
|
// 5. Idempotency — patch already sets selling_price explicitly.
|
|
it("does NOT overwrite selling_price when the patch sets it to a non-zero value", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 100, selling_price: 222 },
|
|
undefined,
|
|
NOW,
|
|
);
|
|
expect(out.selling_price).toBe(222); // patch's value, untouched
|
|
expect(out.markup).toBeUndefined(); // operator chose the shelf price directly; markup left for them
|
|
});
|
|
|
|
// 5b. Idempotency — patch sets markup explicitly (and selling_price is zero).
|
|
it("does NOT add selling_price when the patch sets markup to a non-zero value", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 100, markup: 25 },
|
|
undefined,
|
|
NOW,
|
|
);
|
|
expect(out.markup).toBe(25); // patch's value, untouched
|
|
expect(out.selling_price).toBeUndefined();
|
|
});
|
|
|
|
// 6. cost_history bootstrap — patch sets cost_price > 0, existing row has no history.
|
|
it("seeds cost_history with a single entry when cost_price is positive and the existing row has no history", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 18.5 },
|
|
undefined,
|
|
NOW,
|
|
);
|
|
expect(Array.isArray(out.cost_history)).toBe(true);
|
|
expect(out.cost_history).toHaveLength(1);
|
|
expect(out.cost_history).toEqual([
|
|
{
|
|
cost_price: 18.5,
|
|
quantity: 0,
|
|
purchased_at: NOW,
|
|
purchase_id: DEFAULT_COST_HISTORY_PURCHASE_ID,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("seeds cost_history using the existing row's cost when the patch only adds markup (no patch.cost_price)", () => {
|
|
// Edge: the patch is just `{ markup: 50 }` on a row that already
|
|
// has cost_price > 0 and a populated history. The history
|
|
// preservation rule fires (don't overwrite history). The cost
|
|
// we record in the seed would be the existing row's value.
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ markup: 50 },
|
|
{ cost_price: 25, selling_price: 0, markup: 0, cost_history: [] },
|
|
NOW,
|
|
);
|
|
// Patch set markup explicitly → helper doesn't auto-set markup.
|
|
expect(out.markup).toBe(50);
|
|
// Patch did NOT set cost_price, so the cost_history bootstrap
|
|
// is a no-op (rule: only seed when the patch is the source of
|
|
// the cost).
|
|
expect(out.cost_history).toBeUndefined();
|
|
});
|
|
|
|
// 7. Existing history preserved.
|
|
it("does NOT seed cost_history when the existing row already has history", () => {
|
|
const existingHistory = [
|
|
{
|
|
cost_price: 17.0,
|
|
quantity: 5,
|
|
purchased_at: "2026-08-01T08:00:00.000Z",
|
|
purchase_id: "PO26080101STK",
|
|
},
|
|
];
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 18.5 },
|
|
{ cost_price: 18.5, selling_price: 0, markup: 0, cost_history: existingHistory },
|
|
NOW,
|
|
);
|
|
expect(out.cost_history).toBeUndefined(); // helper didn't touch it
|
|
});
|
|
|
|
// 7b. cost_history=null on existing row → treated as no history → seed fires.
|
|
it("seeds cost_history when existing.cost_history is null", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 18.5 },
|
|
{ cost_price: 18.5, selling_price: 0, markup: 0, cost_history: null },
|
|
NOW,
|
|
);
|
|
expect(Array.isArray(out.cost_history)).toBe(true);
|
|
expect(out.cost_history).toHaveLength(1);
|
|
});
|
|
|
|
// 7c. Patch already has non-empty cost_history → helper doesn't seed.
|
|
it("does NOT seed cost_history when the patch already has a non-empty history (patch wins)", () => {
|
|
// The booking flow appends to existing history on the patch
|
|
// before calling the helper. The helper must not overwrite
|
|
// that — the patch's array is what we're committing to.
|
|
const appendedEntry = {
|
|
cost_price: 19.0,
|
|
quantity: 10,
|
|
purchased_at: "2026-09-01T08:00:00.000Z",
|
|
purchase_id: "PO26090101STK",
|
|
};
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 18.5, cost_history: [appendedEntry] },
|
|
{ cost_price: 0, selling_price: 0, markup: 0, cost_history: [] },
|
|
NOW,
|
|
);
|
|
expect(out.cost_history).toEqual([appendedEntry]);
|
|
});
|
|
|
|
// 8. Rounding: cost_price = 117.39 → selling_price = 211.30.
|
|
it("rounds 117.39 → 211.30 (the exact case the operator script pins)", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 117.39 },
|
|
undefined,
|
|
NOW,
|
|
);
|
|
expect(out.selling_price).toBe(211.30);
|
|
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
|
});
|
|
|
|
// 9. Edge: cost_price = 7.80 → selling_price = 14.04.
|
|
it("rounds 7.80 → 14.04", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 7.80 },
|
|
undefined,
|
|
NOW,
|
|
);
|
|
expect(out.selling_price).toBe(14.04);
|
|
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
|
});
|
|
|
|
// 10. Edge: very small cost like 0.93 → selling_price = 1.67.
|
|
it("rounds 0.93 → 1.67 (cents-rounding sanity check)", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 0.93 },
|
|
undefined,
|
|
NOW,
|
|
);
|
|
expect(out.selling_price).toBe(1.67);
|
|
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
|
});
|
|
|
|
// Bonus: a patch that includes unrelated fields keeps them intact.
|
|
it("leaves unrelated fields in the patch untouched", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 50, name: "Widget", sku: "WID-1" },
|
|
undefined,
|
|
NOW,
|
|
);
|
|
expect(out.name).toBe("Widget");
|
|
expect(out.sku).toBe("WID-1");
|
|
expect(out.selling_price).toBe(90); // 50 * 1.80
|
|
expect(out.markup).toBe(DEFAULT_MARKUP_PERCENT);
|
|
});
|
|
|
|
// Bonus: existing cost_history is an empty array → seed fires.
|
|
it("seeds cost_history when existing.cost_history is an empty array", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 9.99 },
|
|
{ cost_price: 9.99, selling_price: 0, markup: 0, cost_history: [] },
|
|
NOW,
|
|
);
|
|
const history = out.cost_history as unknown[];
|
|
expect(Array.isArray(history)).toBe(true);
|
|
expect(history).toHaveLength(1);
|
|
expect((history[0] as { cost_price: number }).cost_price).toBe(9.99);
|
|
});
|
|
|
|
// Bonus: when no `now` is supplied, the helper still writes a
|
|
// valid ISO `purchased_at`. We don't pin a specific value
|
|
// (it's `Date.now()`-driven), just the shape.
|
|
it("generates a fresh ISO timestamp when `now` is omitted", () => {
|
|
const out = applyDefaultMarkupToPatch({ cost_price: 10 }, undefined);
|
|
const history = out.cost_history as unknown[];
|
|
expect(Array.isArray(history)).toBe(true);
|
|
expect(typeof (history[0] as { purchased_at: unknown }).purchased_at).toBe(
|
|
"string",
|
|
);
|
|
expect(
|
|
new Date(
|
|
(history[0] as { purchased_at: string }).purchased_at,
|
|
).toString(),
|
|
).not.toBe("Invalid Date");
|
|
});
|
|
|
|
// Bonus: when patch.cost_price is undefined and existing has no
|
|
// cost, the helper short-circuits (no auto-markup, no history seed).
|
|
it("does nothing when neither the patch nor the existing row have a cost", () => {
|
|
const out = applyDefaultMarkupToPatch({}, undefined, NOW);
|
|
expect(out).toEqual({});
|
|
});
|
|
|
|
// Bonus: when the operator pre-sets selling_price AND markup on a
|
|
// create, the helper still seeds cost_history (the audit log is
|
|
// independent from the pricing block). The helper does NOT touch
|
|
// selling_price / markup when the operator has set them.
|
|
it("preserves operator-set selling_price/markup while still seeding cost_history", () => {
|
|
const out = applyDefaultMarkupToPatch(
|
|
{ cost_price: 100, selling_price: 175, markup: 75 },
|
|
undefined,
|
|
NOW,
|
|
);
|
|
expect(out.selling_price).toBe(175);
|
|
expect(out.markup).toBe(75);
|
|
expect(Array.isArray(out.cost_history)).toBe(true);
|
|
expect(out.cost_history).toHaveLength(1);
|
|
});
|
|
});
|