From 85f238d3e0acbd5985e9f68ec1e9137efe17fa79 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 31 Aug 2026 16:37:02 +0200 Subject: [PATCH] feat(stock): add stock-pricing-service scaffold with happy-path --- src/lib/server/stock-pricing-service.test.ts | 49 ++++++ src/lib/server/stock-pricing-service.ts | 151 +++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/lib/server/stock-pricing-service.test.ts create mode 100644 src/lib/server/stock-pricing-service.ts diff --git a/src/lib/server/stock-pricing-service.test.ts b/src/lib/server/stock-pricing-service.test.ts new file mode 100644 index 0000000..875bbcf --- /dev/null +++ b/src/lib/server/stock-pricing-service.test.ts @@ -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 }, + }); + }); +}); diff --git a/src/lib/server/stock-pricing-service.ts b/src/lib/server/stock-pricing-service.ts new file mode 100644 index 0000000..b1dc10c --- /dev/null +++ b/src/lib/server/stock-pricing-service.ts @@ -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: (id: string, opts?: Record) => Promise; + update: (id: string, patch: Record) => Promise; + create: (data: Record) => Promise; + }; +}; + +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 { + 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)[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 = {}; + 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 };