taler-typescript-core

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

commit 755bb130613fb4dc5116ff9f26b88bde2b61614e
parent 88bf1eef50f5b7137618c4d8a94aadd89bdf31ac
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 22:34:51 +0100

merchant web UI: build and validate inventory template contracts

Diffstat:
Apackages/taler-merchant-webui/src/utils/inventoryTemplates.test.ts | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/utils/inventoryTemplates.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/utils/templates.ts | 44+++++++++++++++++++++++++++++++-------------
3 files changed, 300 insertions(+), 13 deletions(-)

diff --git a/packages/taler-merchant-webui/src/utils/inventoryTemplates.test.ts b/packages/taler-merchant-webui/src/utils/inventoryTemplates.test.ts @@ -0,0 +1,151 @@ +/* + 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 { + TalerMerchantApi, + TemplateType, + TalerMerchantInstanceHttpClient, + type AccessToken, +} from "@gnu-taler/taler-util"; +import { + inventoryTemplateDraft, + validateInventoryTemplate, + applyInventoryTemplate, +} from "./inventoryTemplates.js"; +import { FakeHttpLib, ok, noContent } from "../testing/fake-http.js"; +import { unwrap } from "../api/failure.js"; +import type { TranslateFn } from "../context/translation.js"; +const t = ((s: TemplateStringsArray) => s.join("")) as TranslateFn; + +const full = { + template_type: TemplateType.INVENTORY_CART, + selected_products: ["coffee", "missing"], + selected_categories: [0, 7], + choose_one: true, + request_tip: true, + currency: "CHF", + min_amount: "CHF:0", + max_amount: "CHF:20", + max_pickup_duration: { d_us: 0 }, +} satisfies TalerMerchantApi.TemplateContractInventoryCart; + +test("all inventory options survive creation, client decoding and an edited PATCH", async () => { + let saved: TalerMerchantApi.TemplateAddDetails | undefined; + const http = new FakeHttpLib().handle((r) => { + if (r.method === "GET") + return ok({ + template_description: saved?.template_description, + template_contract: saved?.template_contract, + }); + saved = r.body as TalerMerchantApi.TemplateAddDetails; + return noContent(); + }); + const client = new TalerMerchantInstanceHttpClient( + "https://example.test/", + http, + ); + const token = "secret-token:t" as AccessToken; + unwrap( + await client.addTemplate(token, { + template_id: "drinks", + template_description: "Drinks", + template_contract: full, + }), + ); + const detail = unwrap(await client.getTemplateDetails(token, "drinks")); + assert.deepEqual(JSON.parse(JSON.stringify(detail.template_contract)), full); + const contract = { ...detail.template_contract } as Record<string, unknown>; + applyInventoryTemplate(contract, inventoryTemplateDraft(contract)); + unwrap( + await client.updateTemplate(token, "drinks", { + template_description: "New name", + template_contract: + contract as unknown as TalerMerchantApi.TemplateContractDetails, + }), + ); + assert.deepEqual(JSON.parse(JSON.stringify(saved?.template_contract)), full); +}); + +for (const [products, categories] of [ + [["coffee"], []], + [[], [7]], + [["coffee"], [7]], +] as const) { + test(`filtered selection ${JSON.stringify([products, categories])}`, () => { + const d = { + ...inventoryTemplateDraft(), + all: false, + products: [...products], + categories: [...categories], + }; + assert.equal(validateInventoryTemplate(d, t), undefined); + const c: Record<string, unknown> = { + selected_all: true, + amount: "CHF:2", + inventory_payload: {}, + }; + applyInventoryTemplate(c, d); + assert.deepEqual(c, { + selected_products: [...products], + selected_categories: [...categories], + }); + }); +} + +test("all inventory clears filters and permits an empty catalogue", () => { + const d = { + ...inventoryTemplateDraft(full), + all: true, + minAmount: "", + maxAmount: "", + currency: "", + pickupDuration: undefined, + }; + const c: Record<string, unknown> = { ...full }; + applyInventoryTemplate(c, d); + assert.equal(validateInventoryTemplate(d, t), undefined); + assert.deepEqual(c, { + template_type: TemplateType.INVENTORY_CART, + selected_all: true, + choose_one: true, + request_tip: true, + }); +}); + +test("validate empty filters, currency and inclusive bounds, and pickup durations", () => { + const d = inventoryTemplateDraft(full); + for (const change of [ + { products: [], categories: [] }, + { currency: "" }, + { currency: "EUR" }, + { minAmount: "CHF:-1" }, + { maxAmount: "garbage" }, + { minAmount: "CHF:21" }, + { pickupDuration: -1 }, + { pickupDuration: 1.5 }, + { pickupDuration: Infinity }, + ]) + assert.ok(validateInventoryTemplate({ ...d, ...change }, t)); + for (const pickupDuration of [undefined, 0, 60_000_000, "forever"] as const) + assert.equal( + validateInventoryTemplate( + { ...d, minAmount: "CHF:20", pickupDuration }, + t, + ), + undefined, + ); +}); diff --git a/packages/taler-merchant-webui/src/utils/inventoryTemplates.ts b/packages/taler-merchant-webui/src/utils/inventoryTemplates.ts @@ -0,0 +1,118 @@ +/* + 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 } from "@gnu-taler/taler-util"; +import type { TalerMerchantApi } from "@gnu-taler/taler-util"; +import type { DurationValue } from "../ui/DurationInput.js"; +import type { TranslateFn } from "../context/translation.js"; + +export interface InventoryTemplateDraft { + all: boolean; + products: string[]; + categories: number[]; + chooseOne: boolean; + requestTip: boolean; + currency: string; + minAmount: string; + maxAmount: string; + pickupDuration?: DurationValue; +} + +export function inventoryTemplateDraft( + contract?: Record<string, unknown>, + currency = "", +): InventoryTemplateDraft { + const c = contract as + | Partial<TalerMerchantApi.TemplateContractInventoryCart> + | undefined; + return { + all: c ? c.selected_all === true : true, + products: c?.selected_products ?? [], + categories: c?.selected_categories ?? [], + chooseOne: c?.choose_one ?? false, + requestTip: c?.request_tip ?? false, + currency: c ? (c.currency ?? "") : currency, + minAmount: c?.min_amount ?? "", + maxAmount: c?.max_amount ?? "", + pickupDuration: c?.max_pickup_duration?.d_us, + }; +} + +export function validateInventoryTemplate( + d: InventoryTemplateDraft, + t: TranslateFn, +): string | undefined { + if (!d.all && !d.products.length && !d.categories.length) + return t`Select at least one product or category.`; + const bounds = [d.minAmount, d.maxAmount].filter(Boolean); + if (bounds.length && !d.currency) + return t`Choose a currency before setting cart limits.`; + if ( + bounds.some( + (v) => !Amounts.checkString(v) || Amounts.currencyOf(v) !== d.currency, + ) + ) + return t`Enter valid cart limits in the selected currency.`; + if ( + d.minAmount && + d.maxAmount && + Amounts.cmp( + Amounts.parseOrThrow(d.minAmount), + Amounts.parseOrThrow(d.maxAmount), + ) > 0 + ) + return t`The minimum cart total must not exceed the maximum.`; + if ( + d.pickupDuration !== undefined && + d.pickupDuration !== "forever" && + (!Number.isSafeInteger(d.pickupDuration) || d.pickupDuration < 0) + ) + return t`Enter a valid pickup or access duration.`; + return undefined; +} + +/** Apply only fields owned by the inventory editor; keep unrelated contract data. */ +export function applyInventoryTemplate( + contract: Record<string, unknown>, + d: InventoryTemplateDraft, +): void { + for (const key of [ + "amount", + "inventory_payload", + "selected_all", + "selected_products", + "selected_categories", + "currency", + "min_amount", + "max_amount", + "max_pickup_duration", + ]) + delete contract[key]; + if (d.all) contract.selected_all = true; + else { + contract.selected_products = [...new Set(d.products)]; + contract.selected_categories = [...new Set(d.categories)]; + } + // Omit false flags unless already stored, keeping unrelated edits stable. + if (d.chooseOne || "choose_one" in contract) + contract.choose_one = d.chooseOne; + if (d.requestTip || "request_tip" in contract) + contract.request_tip = d.requestTip; + if (d.currency) contract.currency = d.currency; + if (d.minAmount) contract.min_amount = d.minAmount; + if (d.maxAmount) contract.max_amount = d.maxAmount; + if (d.pickupDuration !== undefined) + contract.max_pickup_duration = { d_us: d.pickupDuration }; +} diff --git a/packages/taler-merchant-webui/src/utils/templates.ts b/packages/taler-merchant-webui/src/utils/templates.ts @@ -14,9 +14,18 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, TalerUriAction, TalerUris } from "@gnu-taler/taler-util"; +import { + Amounts, + TalerUriAction, + TalerUris, + TemplateType, +} from "@gnu-taler/taler-util"; import type { HostPortPath, TalerMerchantApi } from "@gnu-taler/taler-util"; import type { TranslateFn } from "../context/translation.js"; +import { + applyInventoryTemplate, + type InventoryTemplateDraft, +} from "./inventoryTemplates.js"; import type { DurationValue } from "../ui/DurationInput.js"; /** Keep the spelling of existing name-derived template IDs. */ @@ -106,7 +115,7 @@ export function templatePayUri( export interface TemplateContractEdits { /** The contract as stored, if this is an edit. Everything else is kept. */ storedContract?: Record<string, unknown>; - /** Type of the stored template; a new one is a fixed order. */ + /** Type of the stored template, before applying the selling choice. */ templateType: TalerMerchantApi.TemplateType; /** Whether the form may set the amount and currency for this type. */ canChooseWhatItSells: boolean; @@ -122,6 +131,7 @@ export interface TemplateContractEdits { payDurationUs?: DurationValue; /** Minimum age in years; 0 for no restriction. */ minimumAge: number; + inventory?: InventoryTemplateDraft; } /** @@ -129,10 +139,9 @@ export interface TemplateContractEdits { * * A PATCH replaces the contract rather than merging into it, so this starts * from the stored one and overwrites only the fields the form has: everything - * else — a website template's choices and address pattern, a catalogue's - * product selection, a pickup deadline or tip request the portal has no field - * for — is written back as it was. A field the form owns and the merchant left - * empty is removed rather than left stale. + * else, such as a website template's choices and address pattern, is kept. + * Inventory fields are applied only when the inventory editor supplies them. + * A field the form owns and the merchant left empty is removed. */ export function buildTemplateContract( edits: TemplateContractEdits, @@ -145,6 +154,15 @@ export function buildTemplateContract( else delete contract.summary; if (edits.canChooseWhatItSells) { + contract.template_type = + edits.selType === "catalogue" + ? TemplateType.INVENTORY_CART + : TemplateType.FIXED_ORDER; + if (edits.selType === "catalogue") { + // Default for callers without inventory edits; the full editor applies + // its selection below. + contract.selected_all = true; + } if (edits.selType === "fixed") { if (!edits.amount || !Amounts.checkString(edits.amount)) { throw new Error("A fixed template requires a valid amount"); @@ -156,7 +174,7 @@ export function buildTemplateContract( } contract.amount = edits.amount; delete contract.currency; - } else { + } else if (!edits.inventory) { if (!edits.currency.trim()) throw new Error("Template currency is required"); // No fixed amount, so the template has to name the currency it charges @@ -168,15 +186,13 @@ export function buildTemplateContract( if ( edits.payDurationUs !== undefined && - edits.payDurationUs !== "forever" && - (!Number.isSafeInteger(edits.payDurationUs) || edits.payDurationUs <= 0) + (edits.payDurationUs === "forever" || + !Number.isSafeInteger(edits.payDurationUs) || + edits.payDurationUs <= 0) ) { throw new Error("Payment duration must be a positive finite integer"); } - if ( - edits.payDurationUs === "forever" || - (edits.payDurationUs !== undefined && edits.payDurationUs > 0) - ) { + if (edits.payDurationUs !== undefined && edits.payDurationUs > 0) { contract.pay_duration = { d_us: edits.payDurationUs }; } else { delete contract.pay_duration; @@ -192,6 +208,8 @@ export function buildTemplateContract( if (edits.minimumAge > 0) contract.minimum_age = edits.minimumAge; else delete contract.minimum_age; + if (edits.inventory) applyInventoryTemplate(contract, edits.inventory); + delete contract.inventory_payload; return contract; }