commit 73b45c5d43967c8dbd744e019d120ebd3f7e4d1f
parent 6165dd0727fcfd3ed9625b0708568ebfe3da59aa
Author: Sebastian <sebasjm@taler-systems.com>
Date: Fri, 17 Jul 2026 14:52:41 -0300
fix #11642
Diffstat:
7 files changed, 376 insertions(+), 55 deletions(-)
diff --git a/packages/taler-merchant-webui/src/Routing.tsx b/packages/taler-merchant-webui/src/Routing.tsx
@@ -824,6 +824,9 @@ export function Routing(_p: Props): VNode {
onBack={() => {
route(InstancePaths.templates_list);
}}
+ onNewOrder={(id:string)=>{
+ route(InstancePaths.order_details.replace(":oid", id));
+ }}
/>
<Route
diff --git a/packages/taler-merchant-webui/src/paths/instance/templates/list/Table.tsx b/packages/taler-merchant-webui/src/paths/instance/templates/list/Table.tsx
@@ -37,7 +37,7 @@ interface Props {
templates: Entity[];
onDelete: (e: Entity) => void;
onSelect: (e: Entity) => void;
- onNewOrder: (e: Entity) => void;
+ // onNewOrder: (e: Entity) => void;
onQR: (e: Entity) => void;
onCreate: () => void;
paginator: PaginationControl;
@@ -49,7 +49,7 @@ export function CardTable({
onDelete,
onSelect,
onQR,
- onNewOrder,
+ // onNewOrder,
paginator,
}: Props): VNode {
const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
@@ -84,7 +84,7 @@ export function CardTable({
instances={templates}
onDelete={onDelete}
onSelect={onSelect}
- onNewOrder={onNewOrder}
+ // onNewOrder={onNewOrder}
onQR={onQR}
rowSelection={rowSelection}
rowSelectionHandler={rowSelectionHandler}
@@ -103,7 +103,7 @@ interface TableProps {
rowSelection: string[];
instances: Entity[];
onDelete: (e: Entity) => void;
- onNewOrder: (e: Entity) => void;
+ // onNewOrder: (e: Entity) => void;
onQR: (e: Entity) => void;
onSelect: (e: Entity) => void;
rowSelectionHandler: StateUpdater<string[]>;
@@ -113,7 +113,7 @@ interface TableProps {
function Table({
instances,
onDelete,
- onNewOrder,
+ // onNewOrder,
onQR,
onSelect,
paginator,
@@ -163,7 +163,7 @@ function Table({
<i18n.Translate>Delete</i18n.Translate>
</button>
</Tooltip>
- <Tooltip text={i18n.str`Use template to create new order`}>
+ {/* <Tooltip text={i18n.str`Use template to create new order`}>
<button
type="button"
class="button is-info is-small"
@@ -171,7 +171,7 @@ function Table({
>
<i18n.Translate>Test</i18n.Translate>
</button>
- </Tooltip>
+ </Tooltip> */}
<Tooltip
text={i18n.str`Generate a QR code for the template.`}
>
diff --git a/packages/taler-merchant-webui/src/paths/instance/templates/list/index.tsx b/packages/taler-merchant-webui/src/paths/instance/templates/list/index.tsx
@@ -123,9 +123,9 @@ export default function ListTemplates({
onSelect={(e) => {
onSelect(e.template_id);
}}
- onNewOrder={(e) => {
- onNewOrder(e.template_id);
- }}
+ // onNewOrder={(e) => {
+ // onNewOrder(e.template_id);
+ // }}
onQR={(e) => {
onQR(e.template_id);
}}
diff --git a/packages/taler-merchant-webui/src/paths/instance/templates/qr/QrPage.tsx b/packages/taler-merchant-webui/src/paths/instance/templates/qr/QrPage.tsx
@@ -20,16 +20,33 @@
*/
import {
+ assertUnreachable,
+ CancellationToken,
+ Duration,
HostPortPath,
TalerMerchantApi,
TalerUri,
TalerUriAction,
TalerUris,
+ TemplateContractPaivana
} from "@gnu-taler/taler-util";
-import { QR_Taler, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { h, VNode } from "preact";
-import { useRef } from "preact/hooks";
+import { HttpRequestLibrary, HttpResponse } from "@gnu-taler/taler-util/http";
+import {
+ BrowserFetchHttpLib,
+ LONG_POLL_DELAY,
+ QR_Taler,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useEffect, useMemo, useRef, useState } from "preact/hooks";
+import { Paivana } from "../../../../../../web-util/src/paivana.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
import { useSessionContext } from "../../../../context/session.js";
+import { undefinedIfEmpty } from "../../../../utils/table.js";
const TALER_SCREEN_ID = 64;
@@ -38,10 +55,180 @@ const TALER_SCREEN_ID = 64;
interface Props {
contract: TalerMerchantApi.TemplateContractDetails;
id: string;
+ onOrderCreated?: (id: string) => void;
onBack?: () => void;
}
+export function QrPage(props: Props): VNode {
+ switch (props.contract.template_type) {
+ case TalerMerchantApi.TemplateType.FIXED_ORDER:
+ return UseFixedQrPage(props);
+ case TalerMerchantApi.TemplateType.INVENTORY_CART:
+ return UseInventoryQrPage(props);
+ case TalerMerchantApi.TemplateType.PAIVANA:
+ return UsePaivanaQrPage({
+ contract: props.contract,
+ id: props.id,
+ onBack: props.onBack,
+ onOrderCreated: props.onOrderCreated,
+ });
+ default:
+ assertUnreachable(props.contract);
+ }
+}
+type Entity = {
+ website: string;
+};
+const httpLib: HttpRequestLibrary = new BrowserFetchHttpLib();
+const nonceBuf = new Uint8Array(16);
+crypto.getRandomValues(nonceBuf);
+
+function UsePaivanaQrPage({
+ id: templateId,
+ contract,
+ onOrderCreated,
+ onBack,
+}: Props & { contract: TemplateContractPaivana }): VNode {
+ const { i18n } = useTranslationContext();
+ const { state, lib } = useSessionContext();
+ const [count, setCount] = useState(1);
+ const [error, setError] = useState<string | undefined>();
+ function newQr() {
+ setError(undefined);
+ setCount((c) => c + 1);
+ }
+ const [form, setForm] = useState<Partial<Entity>>({});
+
+ const errors = undefinedIfEmpty<FormErrors<Entity>>({
+ website: !form.website
+ ? i18n.str`Required`
+ : !new RegExp(contract.website_regex!).test(form.website)
+ ? i18n.str`Doesn't match the regular expression.`
+ : undefined,
+ });
+
+ const merchantBaseUrl = state.backendUrl.href as HostPortPath;
+ const printThis = useRef<HTMLElement>(null);
+
+ const { paivana, uri } = useMemo(() => {
+ if (!form.website) return { paivana: undefined, uri: undefined };
+ const paivana = Paivana.generateId(
+ Duration.fromSpec({ hours: 1 }),
+ form.website!,
+ );
+ const uri: TalerUri = Paivana.getTemplateUri(
+ merchantBaseUrl,
+ templateId,
+ form.website,
+ paivana.id,
+ );
+ return { paivana, uri };
+ }, [count]);
+
+ useEffect(() => {
+ if (!form.website || !paivana || !onOrderCreated) return;
+
+ const ct = CancellationToken.create();
+ const url = Paivana.getPollUrl(merchantBaseUrl, {
+ website: form.website,
+ paivanaId: paivana.id,
+ long_poll_timeout: LONG_POLL_DELAY,
+ });
+ async function retryWhenNotFound(): Promise<HttpResponse> {
+ const r = await httpLib.fetch(url.href, {
+ cancellationToken: ct.token,
+ });
+ return r.status === 404 ? retryWhenNotFound() : r;
+ }
+ retryWhenNotFound()
+ .then((d) => d.json())
+ .then((info) => {
+ onOrderCreated(info.order_id);
+ })
+ .catch((e) => {
+ if (e === "stop-request") return;
+ console.error("Failed to get order state:", e);
+ setError(`Failed to get order state: ${e}`);
+ });
+ return () => {
+ ct.cancel("stop-request");
+ };
+ }, [paivana?.id]);
+
+ const stringUri = !uri ? undefined : TalerUris.stringify(uri);
+
+ return (
+ <Fragment>
+ {!uri || !stringUri ? undefined : (
+ <section id="printThis" ref={printThis}>
+ <div
+ style={{ width: "100%", display: "flex", justifyContent: "center" }}
+ >
+ <QR_Taler uri={uri} />
+ </div>
+ <pre style={{ textAlign: "center", whiteSpace: "break-spaces" }}>
+ <a target="_blank" rel="noreferrer" href={stringUri}>
+ {stringUri}
+ </a>
+ </pre>
+ </section>
+ )}
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider object={form} valueHandler={setForm} errors={errors}>
+ <Input<Entity>
+ name="website"
+ label={i18n.str`Full website URL`}
+ tooltip={i18n.str`The website were you will be redirected.`}
+ />
+ </FormProvider>
+ {error === undefined ? undefined : (
+ <p
+ style={{
+ width: "100%",
+ display: "flex",
+ justifyContent: "center",
+ }}
+ >
+ {error}
+ </p>
+ )}
+ <div class="buttons is-right mt-5">
+ <button
+ type="button"
+ class="button is-success"
+ onClick={newQr}
+ >
+ <i18n.Translate>New QR</i18n.Translate>
+ </button>
+ </div>
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button type="button" class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <button
+ type="button"
+ class="button is-info"
+ onClick={() => saveAsPDF(templateId, printThis.current!)}
+ >
+ <i18n.Translate>Print</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </Fragment>
+ );
+}
+function UseInventoryQrPage({ id: templateId, onBack }: Props): VNode {
+ return <div>not supported inventory</div>;
+}
-export function QrPage({ id: templateId, onBack }: Props): VNode {
+function UseFixedQrPage({ id: templateId, onBack }: Props): VNode {
const { i18n } = useTranslationContext();
const { state } = useSessionContext();
diff --git a/packages/taler-merchant-webui/src/paths/instance/templates/qr/index.tsx b/packages/taler-merchant-webui/src/paths/instance/templates/qr/index.tsx
@@ -36,10 +36,11 @@ import { LoginPage } from "../../../login/index.js";
export type Entity = TalerMerchantApi.TransferInformation;
interface Props {
onBack?: () => void;
+ onNewOrder?: (id:string) => void;
tid: string;
}
-export default function TemplateQrPage({ tid, onBack }: Props): VNode {
+export default function TemplateQrPage({ tid, onBack, onNewOrder }: Props): VNode {
const result = useTemplateDetails(tid);
if (!result) return <Loading />;
if (result instanceof TalerError) {
@@ -60,6 +61,6 @@ export default function TemplateQrPage({ tid, onBack }: Props): VNode {
}
return (
- <QrPage contract={result.body.template_contract} id={tid} onBack={onBack} />
+ <QrPage contract={result.body.template_contract} id={tid} onBack={onBack} onOrderCreated={onNewOrder} />
);
}
diff --git a/packages/taler-merchant-webui/src/paths/instance/templates/use/UsePage.tsx b/packages/taler-merchant-webui/src/paths/instance/templates/use/UsePage.tsx
@@ -59,31 +59,13 @@ interface Props {
export function UsePage(props: Props): VNode {
switch (props.template.template_contract.template_type) {
case TalerMerchantApi.TemplateType.FIXED_ORDER:
- return UseFixedOrderPage(props);
case TalerMerchantApi.TemplateType.INVENTORY_CART:
- return UseInventoryPage(props);
case TalerMerchantApi.TemplateType.PAIVANA:
- return UsePaivanaPage(props);
+ return UseFixedOrderPage(props);
default:
assertUnreachable(props.template.template_contract);
}
}
-function UsePaivanaPage({
- id,
- template,
- onBack,
- onOrderCreated,
-}: Props): VNode {
- return <div>unsupported paivana template</div>;
-}
-function UseInventoryPage({
- id,
- template,
- onBack,
- onOrderCreated,
-}: Props): VNode {
- return <div>unsupported inventory template</div>;
-}
function UseFixedOrderPage({
id,
@@ -95,17 +77,16 @@ function UseFixedOrderPage({
const { lib } = useSessionContext();
const { actionHandler, showError } = useNotificationContext();
- if (template.template_contract.template_type !== TemplateType.FIXED_ORDER) {
- return <Fragment />;
- }
-
const [state, setState] = useState<Partial<Entity>>({
currency:
template.editable_defaults?.currency ??
template.template_contract.currency,
// FIXME: Add additional check here, editable default might be a plain string!
- amount: (template.editable_defaults?.amount ??
- template.template_contract.amount) as AmountString,
+ amount:
+ template.template_contract.template_type !== TemplateType.FIXED_ORDER
+ ? undefined
+ : ((template.editable_defaults?.amount ??
+ template.template_contract.amount) as AmountString),
summary:
template.editable_defaults?.summary ?? template.template_contract.summary,
});
@@ -122,7 +103,11 @@ function UseFixedOrderPage({
*/
const details: UsingTemplateDetailsRequest = {
template_type: TemplateType.FIXED_ORDER,
- amount: template.template_contract.amount ? undefined : state.amount,
+ amount:
+ template.template_contract.template_type !== TemplateType.FIXED_ORDER ||
+ template.template_contract.amount
+ ? undefined
+ : state.amount,
summary: template.template_contract.summary ? undefined : state.summary,
};
const useTemplate = actionHandler(
@@ -177,19 +162,22 @@ function UseFixedOrderPage({
valueHandler={setState}
errors={errors}
>
- <InputWithAddon<Entity>
- name="amount"
- label={i18n.str`Amount`}
- addonBefore={state.currency}
- inputType="decimal"
- toStr={(v?: AmountString) => v?.split(":")[1] || ""}
- fromStr={(v: string) =>
- !v ? undefined : `${state.currency}:${v}`
- }
- inputExtra={{ min: 0, step: 0.001 }}
- tooltip={i18n.str`Amount of the order`}
- readonly={!!template.template_contract.amount}
- />
+ {template.template_contract.template_type !==
+ TemplateType.FIXED_ORDER ? undefined : (
+ <InputWithAddon<Entity>
+ name="amount"
+ label={i18n.str`Amount`}
+ addonBefore={state.currency}
+ inputType="decimal"
+ toStr={(v?: AmountString) => v?.split(":")[1] || ""}
+ fromStr={(v: string) =>
+ !v ? undefined : `${state.currency}:${v}`
+ }
+ inputExtra={{ min: 0, step: 0.001 }}
+ tooltip={i18n.str`Amount of the order`}
+ readonly={!!template.template_contract.amount}
+ />
+ )}
<Input<Entity>
name="summary"
diff --git a/packages/web-util/src/paivana.ts b/packages/web-util/src/paivana.ts
@@ -0,0 +1,142 @@
+/*
+ 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 Affero 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 Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ base64FromArrayBuffer,
+ Duration,
+ encodeCrock,
+ HostPortPath,
+ randomBytes,
+ sha256,
+ TalerPayTemplateUri,
+ TalerUriAction,
+} from "@gnu-taler/taler-util";
+
+/**
+ *
+ * @param sec
+ * @returns
+ */
+function timestampRoundedToBuffer(sec: number) {
+ const b = new ArrayBuffer(8);
+ const v = new DataView(b);
+ const numVal = BigInt(sec) * 1000n * 1000n;
+ // The buffer we sign over represents the timestamp in microseconds.
+ v.setBigUint64(0, numVal);
+ return new Uint8Array(b);
+}
+
+/**
+ *
+ * @param data
+ * @returns
+ */
+function sha256b64(data: Uint8Array) {
+ const buf = sha256(data);
+ return (
+ new Uint8Array(buf)
+ // @ts-ignore
+ .toBase64({ alphabet: "base64url" })
+ .replace(/=+$/, "")
+ );
+}
+
+/**
+ *
+ * @param curTime
+ * @param nonceBuf
+ * @param website
+ * @returns
+ */
+function makePaivanaId(curTime: number, nonceBuf: Uint8Array, website: string) {
+ const websiteBuf = new TextEncoder().encode(`${website}\0`);
+ const curTimeBuf = timestampRoundedToBuffer(curTime);
+
+ const length = nonceBuf.length + websiteBuf.length + curTimeBuf.length;
+ const buf = new Uint8Array(length);
+ buf.set(nonceBuf, 0);
+ buf.set(websiteBuf, nonceBuf.length);
+ buf.set(curTimeBuf, nonceBuf.length + websiteBuf.length);
+ const hash = sha256b64(buf);
+ return `${curTime}-${hash}`;
+}
+
+export namespace Paivana {
+ /**
+ * Generates a new random PAIVANA id
+ *
+ * @param duration
+ * @param website
+ * @returns
+ */
+ export function generateId(duration: Duration, website: string) {
+ if (duration.d_ms === "forever")
+ throw Error("can't create paivana with duration forever");
+ const nonceBuf = randomBytes(16);
+ const nonce = encodeCrock(nonceBuf);
+ const expTime = AbsoluteTime.addDuration(AbsoluteTime.now(), duration)
+ .t_ms as number;
+ const id = makePaivanaId(expTime, nonceBuf, website);
+ return { id, nonce };
+ }
+
+ /**
+ * Get the long polling URL to listen if a PAIVANA session is already paid.
+ *
+ * @param merchantBaseUrl
+ * @param params
+ * @returns
+ */
+ export function getPollUrl(
+ merchantBaseUrl: string,
+ params: { website: string; paivanaId: string; long_poll_timeout?: number },
+ ): URL {
+ const url = new URL(
+ `/sessions/${encodeURIComponent(params.paivanaId)}`,
+ merchantBaseUrl,
+ );
+ url.searchParams.set("fulfillment_url", params.website);
+ if (params.long_poll_timeout !== undefined) {
+ url.searchParams.set("timeout_ms", String(params.long_poll_timeout));
+ }
+ return url;
+ }
+
+ /**
+ * The the template URI to pay the PAIVANA session.
+ * @param merchantBaseUrl
+ * @param templateId
+ * @param website
+ * @param paivanaId
+ * @returns
+ */
+ export function getTemplateUri(
+ merchantBaseUrl: HostPortPath,
+ templateId: string,
+ website: string,
+ paivanaId: string,
+ ) {
+ const uri: TalerPayTemplateUri = {
+ type: TalerUriAction.PayTemplate,
+ merchantBaseUrl,
+ templateId,
+ fulfillmentUrl: website,
+ sessionId: paivanaId,
+ };
+ return uri;
+ }
+}