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; }; }; /** * Each failure has a stable reason code so the route can map to HTTP status * (not_found → 404, forbidden_field / validation_error → 400, pb_error → 422) * without parsing the message string. */ export type UpdateStockItemsPricingFailureReason = | "forbidden_field" | "validation_error" | "not_found" | "pb_error"; export type UpdateStockItemsPricingResultRow = | { id: string; ok: true; item: StockItem; previous: StockItemPricingSnapshot; } | { id: string; ok: false; reason: UpdateStockItemsPricingFailureReason; 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 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). // Single explicit failure flag per row avoids coupling to push order. let validationFailed = false; 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}`, }); validationFailed = true; 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`, }); validationFailed = true; continue; } } if (validationFailed) 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; } // 3.5. Cost audit — write a stock_ledger row ONLY when costPrice was // supplied and differs from the existing cost_price. Side-effect: // a failed ledger write aborts THIS row (we won't half-write the // patch without recording the audit trail). if ( update.costPrice !== undefined && before.cost_price !== update.costPrice ) { try { await input.pb.collection("stock_ledger").create({ stock_item: update.id, change_qty: 0, unit_cost: update.costPrice, reason: "adjustment", reference_type: "manual", notes: `${input.actor.name} edited cost from /stock`, created_by: input.actor.id, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); results.push({ id: update.id, ok: false, reason: "pb_error", message: `Patched row but failed to write stock_ledger: ${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 }; }