taler-typescript-core

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

commit 73b45c5d43967c8dbd744e019d120ebd3f7e4d1f
parent 6165dd0727fcfd3ed9625b0708568ebfe3da59aa
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Fri, 17 Jul 2026 14:52:41 -0300

fix #11642

Diffstat:
Mpackages/taler-merchant-webui/src/Routing.tsx | 3+++
Mpackages/taler-merchant-webui/src/paths/instance/templates/list/Table.tsx | 14+++++++-------
Mpackages/taler-merchant-webui/src/paths/instance/templates/list/index.tsx | 6+++---
Mpackages/taler-merchant-webui/src/paths/instance/templates/qr/QrPage.tsx | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-merchant-webui/src/paths/instance/templates/qr/index.tsx | 5+++--
Mpackages/taler-merchant-webui/src/paths/instance/templates/use/UsePage.tsx | 66+++++++++++++++++++++++++++---------------------------------------
Apackages/web-util/src/paivana.ts | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; + } +}