feat(stock): add stock-pricing-service scaffold with happy-path

This commit is contained in:
Dave 2026-08-31 16:37:02 +02:00
parent e66437322f
commit 85f238d3e0
2 changed files with 200 additions and 0 deletions

View file

@ -0,0 +1,49 @@
import { describe, it, expect, vi } from "vitest";
import {
updateStockItemsPricing,
type UpdateStockItemsPricingInput,
} from "./stock-pricing-service";
describe("updateStockItemsPricing — happy path", () => {
it("patches all three pricing fields and reports per-row previous", async () => {
const before = {
id: "abcdefghijklmno",
cost_price: 2.5,
selling_price: 3.25,
markup: 30,
};
const updated = { ...before, cost_price: 3.2, selling_price: 4.16, markup: 30 };
const pb = {
collection: (name: string) => {
if (name === "stock_items") {
return {
getOne: vi.fn().mockResolvedValue(before),
update: vi.fn().mockResolvedValue(updated),
};
}
if (name === "stock_ledger") {
return { create: vi.fn() };
}
throw new Error(`unexpected collection ${name}`);
},
};
const result = await updateStockItemsPricing(
{
pb: pb as never,
updates: [
{ id: "abcdefghijklmno", costPrice: 3.2, sellingPrice: 4.16, markup: 30 },
],
actor: { id: "useropid1234567", name: "Gary" },
} as UpdateStockItemsPricingInput,
);
expect(result.summary).toEqual({ total: 1, succeeded: 1, failed: 0 });
expect(result.results[0]).toMatchObject({
id: "abcdefghijklmno",
ok: true,
previous: { cost_price: 2.5, selling_price: 3.25, markup: 30 },
});
});
});

View file

@ -0,0 +1,151 @@
import type { StockItem } from "@/lib/types";
export type StockPricingUpdate = {
id: string;
costPrice?: number;
sellingPrice?: number;
markup?: number;
};
export type UpdateStockItemsPricingInput = {
/** PB client; passed in so tests can swap a stub. */
pb: StockItemsPb;
updates: StockPricingUpdate[];
actor: { id: string; name: string };
};
export type StockItemsPb = {
collection: (id: string) => {
getOne: <T = unknown>(id: string, opts?: Record<string, unknown>) => Promise<T>;
update: (id: string, patch: Record<string, unknown>) => Promise<unknown>;
create: (data: Record<string, unknown>) => Promise<unknown>;
};
};
export type UpdateStockItemsPricingResultRow =
| {
id: string;
ok: true;
item: StockItem;
previous: StockItemPricingSnapshot;
}
| { id: string; ok: false; reason: string; message: string };
export type StockItemPricingSnapshot = {
cost_price?: number;
selling_price?: number;
markup?: number;
};
export type UpdateStockItemsPricingResult = {
results: UpdateStockItemsPricingResultRow[];
summary: { total: number; succeeded: number; failed: number };
};
const FORBIDDEN_KEYS = ["id", "quantity", "created", "updated", "is_active"] as const;
const PATCHABLE_KEYS = ["costPrice", "sellingPrice", "markup"] as const;
export async function updateStockItemsPricing(
input: UpdateStockItemsPricingInput,
): Promise<UpdateStockItemsPricingResult> {
const results: UpdateStockItemsPricingResultRow[] = [];
for (const update of input.updates) {
// Whitelist check (route also enforces — service is defensive).
for (const key of Object.keys(update) as (keyof StockPricingUpdate)[]) {
if (
key !== "id" &&
!(PATCHABLE_KEYS as readonly string[]).includes(key)
) {
results.push({
id: update.id,
ok: false,
reason: "forbidden_field",
message: `forbidden_field: ${key}`,
});
continue;
}
const value = (update as Record<string, unknown>)[key];
if (key !== "id" && value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || (value as number) < 0)) {
results.push({
id: update.id,
ok: false,
reason: "validation_error",
message: `${key} must be a finite number >= 0`,
});
continue;
}
}
// Continue only if validation above did not push a failure for this row.
if (results.length > 0 && results[results.length - 1]?.id === update.id) {
continue;
}
// 1. Load existing row for previous-snapshot + not_found detection.
let before: StockItem;
try {
before = (await input.pb
.collection("stock_items")
.getOne(update.id)) as StockItem;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (/not found|404/i.test(message)) {
results.push({
id: update.id,
ok: false,
reason: "not_found",
message: `stock_items row ${update.id} not found`,
});
continue;
}
results.push({
id: update.id,
ok: false,
reason: "pb_error",
message: `Failed to load: ${message}`,
});
continue;
}
// 2. Build the PB patch — only fields the caller passed.
const patch: Record<string, number> = {};
if (update.costPrice !== undefined) patch.cost_price = update.costPrice;
if (update.sellingPrice !== undefined) patch.selling_price = update.sellingPrice;
if (update.markup !== undefined) patch.markup = update.markup;
// 3. Persist the row update.
let updated: StockItem;
try {
updated = (await input.pb
.collection("stock_items")
.update(update.id, patch)) as StockItem;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
results.push({
id: update.id,
ok: false,
reason: "pb_error",
message: `Failed to update: ${message}`,
});
continue;
}
// 4. Capture previous snapshot for the response.
const previous: StockItemPricingSnapshot = {
cost_price: before.cost_price,
selling_price: before.selling_price,
markup: before.markup,
};
results.push({ id: update.id, ok: true, item: updated, previous });
}
const summary = {
total: input.updates.length,
succeeded: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
};
return { results, summary };
}
// Silence unused-import warning — kept available for the bulk endpoint.
export { FORBIDDEN_KEYS };