taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 53557ac3c2c93f2b5ea4e7979460c63454dcc266
parent d27e6cbaeb5c23abd58165dbf594863f6d3d3749
Author: Florian Dold <dold@taler.net>
Date:   Wed,  4 Nov 2026 00:17:05 +0100

merchant web UI: fix inventory accounting and updates

Diffstat:
Mpackages/taler-merchant-webui/src/api/cache.test.tsx | 13+++++++++++++
Mpackages/taler-merchant-webui/src/api/hooks/useInventory.ts | 112++++++++++++++++++++++++++-----------------------------------------------------
Mpackages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-merchant-webui/src/screens/CreateProductScreen.tsx | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-merchant-webui/src/screens/InventoryScreen.tsx | 26++++++++++++++++++--------
Mpackages/taler-merchant-webui/src/screens/screens.test.tsx | 11+++--------
Mpackages/taler-merchant-webui/src/types/domain.ts | 4++++
Apackages/taler-merchant-webui/src/utils/inventory.test.ts | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/utils/inventory.ts | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 618 insertions(+), 105 deletions(-)

diff --git a/packages/taler-merchant-webui/src/api/cache.test.tsx b/packages/taler-merchant-webui/src/api/cache.test.tsx @@ -64,6 +64,19 @@ for (const entity of [ for (const k of [...affected, ...untouched]) await seed(k, { name: "Old" }); const http = new FakeHttpLib().handle((request) => { + if (request.method === "GET" && entity === "product") + return ok({ + product_name: "Old", + description: "Old", + description_i18n: {}, + unit: "Piece", + price: "EUR:2", + image: "", + categories: [], + total_stock: 10, + total_sold: 0, + total_lost: 0, + }); if (request.method === "GET") return ok({ slug: "item", diff --git a/packages/taler-merchant-webui/src/api/hooks/useInventory.ts b/packages/taler-merchant-webui/src/api/hooks/useInventory.ts @@ -26,6 +26,12 @@ import { remoteResource } from "../contracts.js"; import { useTranslation } from "../../context/translation.js"; import { mapWithConcurrency } from "../concurrency.js"; +import { + loadInventoryEntries, + productItem, + mergeProductUpdate, +} from "../../utils/inventory.js"; + export type { CategoryItem }; export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { @@ -39,46 +45,30 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { : null, async () => { const client = merchantClient(config); - let rawProducts: Array< - | TalerMerchantApi.MerchantPosProductDetail - | (TalerMerchantApi.ProductDetailResponse & { product_id: string }) - | { product_id: string; detailError: true } - > = []; - - try { - const posRes = await client.getPointOfSaleInventory( - token as AccessToken, - ); - const posBody = unwrap(posRes); - if (posBody && Array.isArray(posBody.products)) { - rawProducts = posBody.products; - } - } catch { - /* fallback below */ - } - - if (rawProducts.length === 0) { - const listRes = await client.listProducts(token as AccessToken); - const listBody = unwrap(listRes); - if (listBody && Array.isArray(listBody.products)) { - const detailed = await mapWithConcurrency( - listBody.products, - 4, - async (p) => { - try { - const detRes = await client.getProductDetails( - token as AccessToken, - p.product_id, - ); - return { product_id: p.product_id, ...unwrap(detRes) }; - } catch { - return { product_id: p.product_id, detailError: true as const }; - } - }, - ); - rawProducts = detailed; + const entries = await loadInventoryEntries(async (offset) => + unwrap( + await client.listProducts(token as AccessToken, { + limit: 100, + order: "asc", + offset, + }), + ), + ); + const rawProducts = await mapWithConcurrency(entries, 4, async (p) => { + try { + return { + product_id: p.product_id, + ...unwrap( + await client.getProductDetails( + token as AccessToken, + p.product_id, + ), + ), + }; + } catch { + return { product_id: p.product_id, detailError: true as const }; } - } + }); const categoryNames = new Map<number, string>(); let categoryError = false; @@ -109,26 +99,8 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { }; } return { - id: productId, - name: p.product_name || p.description || productId, - description: p.description, - image: p.image || undefined, - price: p.price, - stock: - p.total_stock !== undefined && p.total_stock >= 0 - ? t`${p.total_stock} in stock` - : "", - stockTracked: p.total_stock !== undefined && p.total_stock >= 0, - soldCount: "total_sold" in p ? p.total_sold : undefined, - category: - (p.categories.length > 0 - ? categoryNames.get(Number(p.categories[0])) - : undefined) ?? "", - categories: p.categories, - unit: p.unit, - totalStock: p.total_stock, - minimumAge: p.minimum_age, - nextRestock: "next_restock" in p ? p.next_restock : undefined, + ...productItem(productId, p, t), + category: categoryNames.get(Number(p.categories[0])) ?? "", categoryError, }; }); @@ -162,7 +134,10 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { ) => { if (!token) return; const client = merchantClient(config); - const body: TalerMerchantApi.ProductPatchDetailRequest = { ...detail }; + const current = unwrap( + await client.getProductDetails(token as AccessToken, productId), + ); + const body = mergeProductUpdate(current, detail); if (detail.categories !== undefined) { body.categories = detail.categories.filter((c) => Number(c) !== 0); } @@ -238,22 +213,7 @@ export function useProductDetails(productId?: string) { const detail = unwrap( await client.getProductDetails(token as AccessToken, productId!), ); - return { - id: productId!, - name: detail.product_name || detail.description || productId!, - description: detail.description, - image: detail.image || undefined, - price: detail.price, - stock: detail.total_stock >= 0 ? t`${detail.total_stock} in stock` : "", - stockTracked: detail.total_stock >= 0, - soldCount: detail.total_sold, - category: "", - categories: detail.categories, - unit: detail.unit, - totalStock: detail.total_stock, - minimumAge: detail.minimum_age, - nextRestock: detail.next_restock, - }; + return productItem(productId!, detail, t); }, ); const { data, error, isLoading, mutate } = swr; diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx @@ -117,7 +117,7 @@ for (const mode of ["untracked", "tracked", "create"] as const) { ); } await submit(container); - assert.deepEqual(stocks, [mode === "untracked" ? -1 : 20]); + assert.deepEqual(stocks, [mode === "untracked" ? undefined : 20]); } finally { act(() => render(null, container)); container.remove(); @@ -221,7 +221,10 @@ for (const scenario of [ await submit(container); if (scenario.expected !== undefined) { assert.equal(patches.length, 1); - assert.equal(patches[0]!.price, scenario.expected); + assert.equal( + patches[0]!.price, + scenario.name === "unchanged price" ? undefined : scenario.expected, + ); } else { assert.equal(patches.length, 0); assert.match(container.textContent ?? "", /Enter a valid price\./); @@ -354,3 +357,105 @@ test("product save failures preserve edits through dismissal, validation, and re container.remove(); } }); + +for (const timestamp of [ + undefined, + { t_s: 0 }, + { t_s: "never" as const }, + { t_s: 1794301234 }, +]) { + test(`unrelated edits preserve restock timestamp ${JSON.stringify(timestamp)}`, async () => { + const container = document.body.appendChild(document.createElement("div")); + let patch: TalerMerchantApi.ProductPatchDetailRequest | undefined; + try { + await act(() => + render( + <CreateProductScreen + editId={product.id} + currency="EUR" + initialProduct={{ + ...product, + stockTracked: true, + totalStock: 10, + nextRestock: timestamp, + }} + onUpdateProduct={async (_, value) => { + patch = value; + }} + />, + container, + ), + ); + setInput( + container.querySelector<HTMLInputElement>("#prod_name_input")!, + "Updated", + ); + await submit(container); + assert.equal(patch?.product_name, "Updated"); + assert.equal("next_restock" in patch!, false); + assert.equal("total_stock" in patch!, false); + if (timestamp?.t_s === 0) assert.match(container.textContent!, /Unknown/); + } finally { + act(() => render(null, container)); + container.remove(); + } + }); +} + +test("stock decreases are rejected and fractional stock remains read-only", async () => { + const container = document.body.appendChild(document.createElement("div")); + let writes = 0; + try { + await act(() => + render( + <CreateProductScreen + editId={product.id} + currency="EUR" + initialProduct={{ ...product, stockTracked: true, totalStock: 10 }} + onUpdateProduct={async () => { + writes++; + }} + />, + container, + ), + ); + setInput( + container.querySelector<HTMLInputElement>("#prod_stock_count_input")!, + "9", + ); + await submit(container); + assert.equal(writes, 0); + assert.match(container.textContent!, /cannot decrease/); + act(() => render(null, container)); + await act(() => + render( + <CreateProductScreen + editId={product.id} + currency="EUR" + initialProduct={{ + ...product, + stockTracked: true, + totalStock: 10, + unitTotalStock: "10.125", + fractionalStock: true, + }} + onUpdateProduct={async (_, patch) => { + assert.equal(patch.total_stock, undefined); + writes++; + }} + />, + container, + ), + ); + const input = container.querySelector<HTMLInputElement>( + "#prod_stock_count_input", + )!; + assert.equal(input.readOnly, true); + assert.equal(input.value, "10.125"); + await submit(container); + assert.equal(writes, 1); + } finally { + act(() => render(null, container)); + container.remove(); + } +}); diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx @@ -178,7 +178,7 @@ const PREDEFINED_UNIT_SLUGS = new Set([ ]); function dateInputValue(timestamp: TalerProtocolTimestamp | undefined): string { - if (!timestamp || timestamp.t_s === "never") return ""; + if (!timestamp || timestamp.t_s === "never" || timestamp.t_s === 0) return ""; return new Date(timestamp.t_s * 1000).toISOString().slice(0, 10); } @@ -256,6 +256,8 @@ function CreateProductForm({ const isEditing = !!editId; const existing = initialProduct || productResource?.data; + const [originalProduct] = useState(existing); + const fractionalStock = existing?.fractionalStock === true; const existingUnit = existing?.unit || "Piece"; // Form State (Spec 07 § 7.2) @@ -306,7 +308,8 @@ function CreateProductForm({ existing.totalStock >= 0, ); // The backend cannot enable stock tracking for an existing untracked product. - const stockTrackingDisabled = isEditing && !existingStockTracked; + const stockTrackingDisabled = + isEditing && (!existingStockTracked || fractionalStock); const [countStock, setCountStock] = useState<boolean>(existingStockTracked); const [totalStock, setTotalStock] = useState<string>(() => existingStockTracked ? String(existing!.totalStock) : "", @@ -392,6 +395,20 @@ function CreateProductForm({ ); return; } + if (isSubmitting) return; + if ( + countStock && + !fractionalStock && + existing?.totalStock !== undefined && + totalStock !== String(originalProduct?.totalStock) && + Number(totalStock) < existing.totalStock + ) { + setErrorMsg( + t`Total units stocked cannot decrease. Record a loss instead.`, + "prod_stock_count_input", + ); + return; + } setErrorMsg(""); setIsSubmitting(true); @@ -424,11 +441,31 @@ function CreateProductForm({ image: image as ImageDataUrl, unit: detail.unit, price: detail.price, - total_stock: detail.total_stock, + ...(countStock !== originalProduct?.stockTracked || + (countStock && + !fractionalStock && + String(originalProduct?.totalStock) !== totalStock) + ? { total_stock: detail.total_stock } + : {}), minimum_age: detail.minimum_age, categories: detail.categories, - next_restock: dateInputTimestamp(nextRestock), + ...(nextRestock !== dateInputValue(originalProduct?.nextRestock) + ? { next_restock: dateInputTimestamp(nextRestock) } + : {}), }; + if (name === originalProduct?.name) delete patch.product_name; + if (description === (originalProduct?.description || "")) + delete patch.description; + if (image === (originalProduct?.image || "")) delete patch.image; + if (finalUnit === (originalProduct?.unit || "Piece")) delete patch.unit; + if (formattedPrice === originalProduct?.price) delete patch.price; + if (minimumAge === (originalProduct?.minimumAge ?? 0)) + delete patch.minimum_age; + if ( + JSON.stringify(detail.categories) === + JSON.stringify(originalProduct?.categories ?? []) + ) + delete patch.categories; await onUpdateProduct?.(editId, patch); } else { await onCreateProduct?.(finalProductId, detail); @@ -440,9 +477,19 @@ function CreateProductForm({ name, description, price: formattedPrice, - stock: countStock ? t`${totalStock} in stock` : "", + stock: !countStock + ? t`Unlimited` + : fractionalStock + ? t`Exact stock balance unavailable` + : t`${stockNum - (existing?.soldCount ?? 0) - (existing?.lostCount ?? 0)} stock balance`, + totalStock: stockNum, + unitTotalStock: fractionalStock + ? existing?.unitTotalStock + : undefined, + fractionalStock, stockTracked: countStock, - soldCount: 0, + soldCount: existing?.soldCount ?? 0, + lostCount: existing?.lostCount ?? 0, category: selectedCategoryIds.length > 0 ? String(selectedCategoryIds[0]) @@ -641,9 +688,11 @@ function CreateProductForm({ <span>{t`Count inventory stock for this product`}</span> </label> <p class="text-xs text-gray-500 mt-1 pl-6"> - {stockTrackingDisabled - ? t`Stock tracking cannot be enabled for an existing product with unlimited inventory.` - : t`Enable to track quantity in stock and reserve items during checkout.`} + {fractionalStock + ? t`Fractional stock is read-only. Exact remaining stock is unavailable.` + : stockTrackingDisabled + ? t`Stock tracking cannot be enabled for an existing product with unlimited inventory.` + : t`Enable to track quantity in stock and reserve items during checkout.`} </p> </div> @@ -654,19 +703,29 @@ function CreateProductForm({ htmlFor="prod_stock_count_input" class="block text-xs font-bold text-gray-700 mb-1" > - {t`Units in Stock`} + {t`Total units stocked`} </label> <input {...fieldProps("prod_stock_count_input")} id="prod_stock_count_input" type="text" - value={totalStock} + readOnly={fractionalStock} + value={ + fractionalStock + ? (existing?.unitTotalStock ?? totalStock) + : totalStock + } onInput={(e) => setTotalStock((e.target as HTMLInputElement).value) } class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg font-mono" /> {fieldError("prod_stock_count_input")} + <p class="text-xs text-gray-500"> + {fractionalStock + ? t`Fractional stock is read-only. Exact remaining stock is unavailable.` + : t`Lifetime units received, including units already sold or lost.`} + </p> </div> <div> @@ -676,6 +735,9 @@ function CreateProductForm({ > {t`Next Delivery Date`} </label> + {originalProduct?.nextRestock?.t_s === 0 && !nextRestock && ( + <p class="text-xs text-gray-500">{t`Unknown`}</p> + )} <input id="prod_restock_date_input" type="date" diff --git a/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx b/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx @@ -421,17 +421,22 @@ export function InventoryScreen({ </dd> </div> <div> - <dt class="text-gray-500">{t`Stock / sold`}</dt> + <dt class="text-gray-500">{t`Stock balance / sold`}</dt> <dd class="mt-0.5 text-gray-800"> - {prod.stockTracked - ? prod.stock - : t`Stock not tracked`}{" "} + {prod.detailError + ? t`Unavailable` + : prod.stockTracked + ? prod.stock + : t`Unlimited`}{" "} ·{" "} {prod.soldCount === undefined ? t`Sold count unavailable` : prod.soldCount === 1 ? t`1 unit` : t`${prod.soldCount} units`} + {prod.lostCount !== undefined && ( + <span class="block text-xs text-gray-500">{t`${prod.lostCount} lost`}</span> + )} </dd> </div> </dl> @@ -446,7 +451,7 @@ export function InventoryScreen({ <tr> <th class="px-4 py-3 text-left">{t`Product Name & ID`}</th> <th class="px-4 py-3 text-left">{t`Price`}</th> - <th class="px-4 py-3 text-left">{t`Stock / sold`}</th> + <th class="px-4 py-3 text-left">{t`Stock balance / sold`}</th> <th class="w-14 px-3 py-3 text-right"> <span class="sr-only">{t`Actions`}</span> </th> @@ -554,9 +559,11 @@ export function InventoryScreen({ </td> <td class="px-4 py-3 text-gray-600 text-xs font-medium whitespace-nowrap"> <div> - {prod.stockTracked - ? prod.stock - : t`Stock not tracked`} + {prod.detailError + ? t`Unavailable` + : prod.stockTracked + ? prod.stock + : t`Unlimited`} </div> <div class="text-2xs text-gray-500 mt-0.5"> {t`Sold`}:{" "} @@ -565,6 +572,9 @@ export function InventoryScreen({ : prod.soldCount === 1 ? t`1 unit` : t`${prod.soldCount} units`} + {prod.lostCount !== undefined && ( + <span class="block text-xs text-gray-500">{t`${prod.lostCount} lost`}</span> + )} </div> </td> <td class="px-3 py-3 text-right whitespace-nowrap"> diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx @@ -2078,7 +2078,7 @@ test("InventoryScreen renders products and categories tabs", () => { assert.match(container.innerHTML, /Inventory/); assert.match(container.innerHTML, /Coffee beans 500 g/); - assert.match(container.innerHTML, /Stock not tracked/); + assert.match(container.innerHTML, /Unlimited/); assert.match(container.innerHTML, /Categories/); render(null, container); // unmount so effect cleanups run @@ -4507,13 +4507,8 @@ test("CreateProductScreen hydrates authoritative fields and preserves empty cate }); assert.strictEqual(updatedId, "bulk-tea"); - assert.strictEqual(updated?.unit, "sachet"); - assert.strictEqual(updated?.total_stock, 7); - assert.strictEqual(updated?.minimum_age, 18); - assert.deepStrictEqual(updated?.next_restock, { t_s: 1_789_430_400 }); - assert.deepStrictEqual(updated?.categories, []); - assert.strictEqual("total_sold" in (updated ?? {}), false); - assert.strictEqual("description_i18n" in (updated ?? {}), false); + // Untouched fields are merged from a fresh detail read by the mutation hook. + assert.deepStrictEqual(updated, {}); render(null, container); document.body.removeChild(container); diff --git a/packages/taler-merchant-webui/src/types/domain.ts b/packages/taler-merchant-webui/src/types/domain.ts @@ -166,6 +166,10 @@ export interface ProductItem { categories?: (number | string)[]; unit?: string; totalStock?: number; + unitTotalStock?: string; + fractionalStock?: boolean; + lostCount?: number; + remainingStock?: number; minimumAge?: number; nextRestock?: TalerProtocolTimestamp; detailError?: boolean; diff --git a/packages/taler-merchant-webui/src/utils/inventory.test.ts b/packages/taler-merchant-webui/src/utils/inventory.test.ts @@ -0,0 +1,201 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { TalerMerchantApi } from "@gnu-taler/taler-util"; +import type { TranslateFn } from "../context/translation.js"; +import { + loadInventoryEntries, + productItem, + mergeProductUpdate, +} from "./inventory.js"; +const t = ((strings: TemplateStringsArray, ...values: unknown[]) => + strings.reduce((a, s, i) => a + s + (values[i] ?? ""), "")) as TranslateFn; +const detail: TalerMerchantApi.ProductDetailResponse = { + product_name: "Tea", + description: "Tea", + description_i18n: { de: "Tee" }, + categories: [2], + unit: "Piece", + price: "EUR:2", + image: "", + total_stock: 100, + total_sold: 30, + total_lost: 5, + taxes: [{ name: "VAT", tax: "EUR:0.1" }], + address: { country: "CH" }, + product_group_id: 3, + money_pot_id: 4, + next_restock: { t_s: 1794301234 }, + minimum_age: 18, + unit_price: ["EUR:2", "CHF:3"], + unit_total_stock: "100", + unit_allow_fraction: false, + unit_precision_level: 0, + price_is_net: false, +}; + +for (const count of [0, 21, 100, 205]) + test(`inventory loads ${count} products across all pages`, async () => { + const offsets: number[] = []; + const products = await loadInventoryEntries(async (cursor) => { + const offset = Number(cursor); + offsets.push(offset); + return { + products: Array.from( + { length: Math.min(100, count - offset) }, + (_, i) => ({ + product_serial: offset + i + 1, + product_id: String(offset + i + 1), + }), + ), + }; + }); + assert.equal(products.length, count); + assert.equal(offsets.at(-1), count); + }); + +test("pagination deduplicates, rejects stalled cursors and propagates page errors", async () => { + const rows = await loadInventoryEntries(async (offset) => ({ + products: + offset === "0" + ? [ + { product_id: "a", product_serial: 1 }, + { product_id: "a", product_serial: 2 }, + ] + : [], + })); + assert.equal(rows.length, 1); + await assert.rejects( + loadInventoryEntries(async () => ({ + products: [{ product_id: "a", product_serial: 1 }], + })), + /did not advance/, + ); + await assert.rejects( + loadInventoryEntries(async (offset) => { + if (offset !== "0") throw new Error("page failed"); + return { products: [{ product_id: "a", product_serial: 1 }] }; + }), + /page failed/, + ); +}); + +test("balances distinguish cumulative stock, losses, unlimited and fractional stock", () => { + const p = productItem("tea", detail, t); + assert.equal(p.remainingStock, 65); + assert.equal(p.lostCount, 5); + assert.match(p.stock, /65 stock balance/); + assert.equal( + productItem( + "tea", + { ...detail, total_stock: -1, unit_total_stock: "-1" }, + t, + ).stockTracked, + false, + ); + assert.equal( + productItem("tea", { ...detail, unit_allow_fraction: true }, t) + .remainingStock, + undefined, + ); + assert.equal( + productItem("tea", { ...detail, unit_total_stock: "100.5" }, t) + .remainingStock, + undefined, + ); + assert.equal( + productItem("tea", { ...detail, total_sold: NaN }, t).remainingStock, + undefined, + ); +}); + +test("full updates preserve hidden fields, precision, timestamp and additional prices", () => { + const current = { + ...detail, + unit_total_stock: "100.125", + unit_allow_fraction: true, + }; + const patch = mergeProductUpdate(current, { product_name: "New tea" }); + assert.equal(patch.unit_total_stock, "100.125"); + assert.equal(patch.total_stock, undefined); + assert.equal("total_sold" in patch, false); + for (const key of [ + "description_i18n", + "taxes", + "address", + "product_group_id", + "money_pot_id", + "next_restock", + "unit_price", + "unit_precision_level", + "price_is_net", + ] as const) + assert.deepEqual(patch[key], current[key]); + const price = mergeProductUpdate(detail, { price: "EUR:4" }); + assert.deepEqual(price.unit_price, ["EUR:4", "CHF:3"]); + assert.throws( + () => mergeProductUpdate(detail, { price: "CHF:4" }), + /already has a price/, + ); + assert.throws( + () => mergeProductUpdate(detail, { total_stock: 99 }), + /cannot decrease/, + ); + assert.equal( + mergeProductUpdate(detail, { total_stock: 110 }).unit_total_stock, + "110", + ); + assert.equal( + mergeProductUpdate(detail, { total_stock: -1 }).unit_total_stock, + "-1", + ); + for (const next_restock of [undefined, { t_s: 0 }, { t_s: "never" as const }]) + assert.deepEqual( + mergeProductUpdate({ ...detail, next_restock }, {}).next_restock, + next_restock, + ); + assert.deepEqual( + mergeProductUpdate(detail, { next_restock: { t_s: "never" } }).next_restock, + { t_s: "never" }, + ); +}); + +for (const counters of [ + { total_stock: -1, unit_total_stock: undefined }, + { total_stock: -1, unit_total_stock: "-1" }, + // Pre-fractional inventory migrated with total_stock_frac = 0. + { total_stock: -1, unit_total_stock: "9223372036854775807" }, + { total_stock: 0, unit_total_stock: "-1" }, +]) + test(`unlimited markers take precedence over stock arithmetic: ${JSON.stringify(counters)}`, () => { + const current = { ...detail, ...counters }; + const item = productItem("unlimited", current, t); + assert.equal(item.stockTracked, false); + assert.equal(item.stock, "Unlimited"); + assert.equal(item.remainingStock, undefined); + assert.equal(item.totalStock, -1); + assert.equal(item.soldCount, 30); + assert.equal(item.lostCount, 5); + const patch = mergeProductUpdate(current, { product_name: "Renamed" }); + if (counters.unit_total_stock === undefined) { + assert.equal(patch.total_stock, -1); + } else { + assert.equal(item.unitTotalStock, "-1"); + assert.equal(patch.unit_total_stock, "-1"); + assert.equal(patch.total_stock, undefined); + } + }); diff --git a/packages/taler-merchant-webui/src/utils/inventory.ts b/packages/taler-merchant-webui/src/utils/inventory.ts @@ -0,0 +1,163 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { Amounts, type TalerMerchantApi } from "@gnu-taler/taler-util"; +import type { ProductItem } from "../types/domain.js"; +import type { TranslateFn } from "../context/translation.js"; + +type Detail = TalerMerchantApi.ProductDetailResponse; +type Patch = TalerMerchantApi.ProductPatchDetailRequest; + +/** Exhaust the cursor even when a server returns fewer rows than requested. */ +export async function loadInventoryEntries( + page: (offset: string) => Promise<TalerMerchantApi.InventorySummaryResponse>, +): Promise<TalerMerchantApi.InventoryEntry[]> { + const entries = new Map<string, TalerMerchantApi.InventoryEntry>(); + let offset = 0; + for (;;) { + const { products } = await page(String(offset)); + if (!products.length) return [...entries.values()]; + const next = Math.max(...products.map((p) => p.product_serial)); + if (!Number.isSafeInteger(next) || next <= offset) + throw new Error("Inventory pagination did not advance."); + for (const product of products) entries.set(product.product_id, product); + offset = next; + } +} + +/** Older unlimited products can have a decimal counter containing INT64_MAX + * instead of "-1". Either API representation can identify unlimited stock. */ +function stockIsUnlimited(detail: Detail): boolean { + return detail.total_stock === -1 || detail.unit_total_stock === "-1"; +} + +export function productItem( + id: string, + detail: Detail, + t: TranslateFn, +): ProductItem { + const fractional = + detail.unit_allow_fraction === true || + (detail.unit_total_stock !== undefined && + /\.[0-9]*[1-9]/.test(detail.unit_total_stock)); + const tracked = !stockIsUnlimited(detail); + const counters = [detail.total_stock, detail.total_sold, detail.total_lost]; + const remaining = + tracked && !fractional && counters.every(Number.isSafeInteger) + ? detail.total_stock - detail.total_sold - detail.total_lost + : undefined; + return { + id, + name: detail.product_name || detail.description || id, + description: detail.description, + image: detail.image || undefined, + price: detail.price, + unit: detail.unit, + categories: detail.categories, + category: "", + stockTracked: tracked, + totalStock: tracked ? detail.total_stock : -1, + unitTotalStock: + !tracked && detail.unit_total_stock !== undefined + ? "-1" + : detail.unit_total_stock, + fractionalStock: fractional, + soldCount: detail.total_sold, + lostCount: detail.total_lost, + remainingStock: remaining, + stock: !tracked + ? t`Unlimited` + : remaining === undefined + ? t`Exact stock balance unavailable` + : t`${remaining} stock balance`, + minimumAge: detail.minimum_age, + nextRestock: detail.next_restock, + }; +} + +/** The backend currently requires a full writable record even for PATCH. */ +export function mergeProductUpdate(current: Detail, changes: Patch): Patch { + if ( + changes.total_stock !== undefined && + changes.total_stock !== -1 && + (!Number.isSafeInteger(changes.total_stock) || + changes.total_stock < current.total_stock) + ) + throw new Error("Total units stocked cannot decrease."); + const body: Patch = {}; + const keys = [ + "product_name", + "description", + "description_i18n", + "unit", + "categories", + "price", + "image", + "taxes", + "total_stock", + "total_lost", + "address", + "next_restock", + "minimum_age", + "product_group_id", + "money_pot_id", + "unit_price", + "unit_total_stock", + "unit_allow_fraction", + "unit_precision_level", + "price_is_net", + ] as const; + for (const key of keys) { + if (current[key] !== undefined) + Object.assign(body, { [key]: current[key] }); + if (changes[key] !== undefined) + Object.assign(body, { [key]: changes[key] }); + } + if ( + changes.price !== undefined && + changes.unit_price === undefined && + current.unit_price + ) { + const rest = current.unit_price.slice(1); + if ( + rest.some( + (p) => Amounts.currencyOf(p) === Amounts.currencyOf(changes.price!), + ) + ) + throw new Error( + "This product already has a price in the selected currency.", + ); + body.unit_price = [changes.price, ...rest]; + } else if (changes.unit_price !== undefined) { + body.price = changes.unit_price[0]; + } + if ( + changes.total_stock !== undefined && + current.unit_total_stock !== undefined + ) + body.unit_total_stock = String(changes.total_stock); + if ( + changes.total_stock === undefined && + changes.unit_total_stock === undefined && + stockIsUnlimited(current) + ) { + body.total_stock = -1; + if (body.unit_total_stock !== undefined) body.unit_total_stock = "-1"; + } + // Only send the precise representation: the legacy counter truncates fractions. + if (body.unit_total_stock !== undefined) delete body.total_stock; + return body; +} +