commit 7db9786fff1f0981f69869287b1d8ae01f444686
parent 3b11b38eaf6ae1f00a44681fea8739ad0b4f56b2
Author: Florian Dold <dold@taler.net>
Date: Sat, 5 Sep 2026 17:39:48 +0200
merchant web UI: save product prices in the selected currency
Allow creating and editing products with a price currency different from
the merchant default. Preserve the selected currency when the numeric
price is cleared, including when selecting a currency for an empty price.
Issue: https://bugs.taler.net/n/9857
Diffstat:
2 files changed, 156 insertions(+), 2 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx
@@ -0,0 +1,153 @@
+/*
+ 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 { render } from "preact";
+import { act } from "preact/test-utils";
+import type { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { CreateProductScreen } from "./CreateProductScreen.js";
+
+const product = {
+ id: "eur-product",
+ name: "EUR product",
+ price: "EUR:2.50",
+ stock: "",
+ stockTracked: false,
+ soldCount: undefined,
+ category: "",
+ categories: [],
+ unit: "piece",
+ totalStock: -1,
+};
+
+function setInput(input: HTMLInputElement, value: string): void {
+ act(() => {
+ input.value = value;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+}
+
+async function submit(container: HTMLElement): Promise<void> {
+ await act(async () => {
+ container
+ .querySelector("form")!
+ .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ });
+}
+
+for (const scenario of [
+ { name: "unchanged price", inputs: [], expected: "EUR:2.50" },
+ { name: "updated price", inputs: ["3.75"], expected: "EUR:3.75" },
+ {
+ name: "cleared and retyped price",
+ inputs: ["", "4.25"],
+ expected: "EUR:4.25",
+ },
+ { name: "zero price", inputs: ["0"], expected: "EUR:0" },
+ { name: "empty price", inputs: [""], expected: undefined },
+ { name: "negative price", inputs: ["-1"], expected: undefined },
+ { name: "excess precision", inputs: ["1.123456789"], expected: undefined },
+]) {
+ test(`CreateProductScreen edits a non-default currency: ${scenario.name}`, async () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const patches: TalerMerchantApi.ProductPatchDetailRequest[] = [];
+ try {
+ await act(async () => {
+ render(
+ <CreateProductScreen
+ editId={product.id}
+ initialProduct={product}
+ currency="KUDOS"
+ categories={[]}
+ onUpdateProduct={async (id, patch) => {
+ assert.equal(id, product.id);
+ patches.push(patch);
+ }}
+ />,
+ container,
+ );
+ });
+ const currency = container.querySelector<HTMLSelectElement>(
+ 'select[aria-label="Currency"]',
+ )!;
+ const input =
+ container.querySelector<HTMLInputElement>("#prod_price_input")!;
+ assert.equal(currency.value, "EUR");
+ for (const value of scenario.inputs) {
+ setInput(input, value);
+ assert.equal(currency.value, "EUR");
+ }
+ await submit(container);
+ if (scenario.expected !== undefined) {
+ assert.equal(patches.length, 1);
+ assert.equal(patches[0]!.price, scenario.expected);
+ } else {
+ assert.equal(patches.length, 0);
+ assert.match(container.textContent ?? "", /Enter a valid price\./);
+ }
+ } finally {
+ act(() => render(null, container));
+ container.remove();
+ }
+ });
+}
+
+test("CreateProductScreen creates a product in a selected payout currency", async () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const products: Omit<
+ TalerMerchantApi.ProductAddDetailRequest,
+ "product_id"
+ >[] = [];
+ try {
+ await act(async () => {
+ render(
+ <CreateProductScreen
+ currency="KUDOS"
+ payoutCurrencies={["EUR"]}
+ onCreateProduct={async (_id, detail) => {
+ products.push(detail);
+ }}
+ />,
+ container,
+ );
+ });
+ setInput(
+ container.querySelector<HTMLInputElement>("#prod_name_input")!,
+ "EUR product",
+ );
+ const currency = container.querySelector<HTMLSelectElement>(
+ 'select[aria-label="Currency"]',
+ )!;
+ act(() => {
+ currency.value = "EUR";
+ currency.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ const input =
+ container.querySelector<HTMLInputElement>("#prod_price_input")!;
+ setInput(input, "");
+ assert.equal(currency.value, "EUR");
+ setInput(input, "3.75");
+ await submit(container);
+ assert.equal(products.length, 1);
+ assert.equal(products[0]!.price, "EUR:3.75");
+ } 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
@@ -371,8 +371,8 @@ export function CreateProductScreen({
setErrorMsg(t`Remove or replace the product image before saving.`);
return;
}
- if (!Amounts.checkString(price) || Amounts.currencyOf(price) !== currency) {
- setErrorMsg(t`Enter a valid price in the merchant currency.`);
+ if (!Amounts.checkString(price)) {
+ setErrorMsg(t`Enter a valid price.`);
return;
}
if (
@@ -550,6 +550,7 @@ export function CreateProductScreen({
label={t`Price per unit`}
required
value={price}
+ preserveCurrencyWhenEmpty
onChange={(val) => setPrice(val)}
primaryCurrency={currency}
payoutCurrencies={payoutCurrencies}