commit 82800325f6566a56ad602870b9e6d24ebe8dd175 parent aa850ec165897d204d652d985c33e321113bc868 Author: Florian Dold <dold@taler.net> Date: Fri, 4 Sep 2026 00:21:37 +0200 bank web UI: add cash acceptor simulator Diffstat:
20 files changed, 752 insertions(+), 113 deletions(-)
diff --git a/packages/libeufin-bank-webui/src/Routing.tsx b/packages/libeufin-bank-webui/src/Routing.tsx @@ -59,6 +59,7 @@ import { Transactions } from "./components/Transactions/index.js"; import { AccountList } from "./pages/admin/AccountList.js"; import { ConversionClassList } from "./pages/admin/ConversionClassList.js"; import { WalletWithdrawal } from "./pages/WalletWithdrawal.js"; +import { CashAcceptor } from "./pages/CashAcceptor.js"; const TALER_SCREEN_ID = 100; @@ -154,6 +155,10 @@ const privatePages = { /\/account\/charge-wallet/, () => "#/account/charge-wallet", ), + homeCashAcceptor: urlPattern( + /^\/account\/cash-acceptor$/, + () => "#/account/cash-acceptor", + ), homeWireTransfer: urlPattern<{ account?: string; subject?: string; @@ -490,6 +495,7 @@ function PrivatePageRouting({ routeAccountDetails={privatePages.myAccountDetails} routeOperationDetails={privatePages.startOperation} routeChargeWallet={privatePages.homeChargeWallet} + routeCashAcceptor={privatePages.homeCashAcceptor} routeWireTransfer={privatePages.homeWireTransfer} routeCashout={privatePages.cashoutCreate} routeTransactions={privatePages.transactions} @@ -575,6 +581,14 @@ function PrivatePageRouting({ /> ); } + case "homeCashAcceptor": { + return ( + <CashAcceptor + routeCancel={privatePages.home} + routeOperation={privatePages.startOperation} + /> + ); + } case "conversionConfig": { return ( <ConversionConfig diff --git a/packages/libeufin-bank-webui/src/hooks/bank-state.test.ts b/packages/libeufin-bank-webui/src/hooks/bank-state.test.ts @@ -41,7 +41,7 @@ describe("persisted bank withdrawal state", () => { }; assert.deepEqual(codecForBankState().decode({ activeWithdrawal }), { currentWithdrawalOperationId: undefined, - activeWithdrawal, + activeWithdrawal: { ...activeWithdrawal, kind: "standard" }, currentChallenge: undefined, }); }); @@ -57,4 +57,19 @@ describe("persisted bank withdrawal state", () => { }), ); }); + + it("preserves the cash acceptor operation kind", () => { + const activeWithdrawal = { + operationId: "withdrawal-2", + username: "alice", + backendBaseUrl: "https://bank.example/", + confirmationDeferred: false, + kind: "cash-acceptor" as const, + }; + assert.deepEqual(codecForBankState().decode({ activeWithdrawal }), { + currentWithdrawalOperationId: undefined, + activeWithdrawal, + currentChallenge: undefined, + }); + }); }); diff --git a/packages/libeufin-bank-webui/src/hooks/bank-state.ts b/packages/libeufin-bank-webui/src/hooks/bank-state.ts @@ -25,8 +25,10 @@ import { codecForAny, codecForBoolean, codecForConstString, + codecForEither, codecForString, codecOptional, + codecOptionalDefault, } from "@gnu-taler/taler-util"; import { AppLocation, @@ -177,14 +179,25 @@ export interface ActiveWithdrawal { username: string; backendBaseUrl: string; confirmationDeferred: boolean; + kind: "standard" | "cash-acceptor"; } +const codecForWithdrawalKind = (): Codec<ActiveWithdrawal["kind"]> => + codecForEither( + codecForConstString("standard"), + codecForConstString("cash-acceptor"), + ); + const codecForActiveWithdrawal = (): Codec<ActiveWithdrawal> => buildCodecForObject<ActiveWithdrawal>() .property("operationId", codecForString()) .property("username", codecForString()) .property("backendBaseUrl", codecForString()) .property("confirmationDeferred", codecForBoolean()) + .property( + "kind", + codecOptionalDefault(codecForWithdrawalKind(), "standard"), + ) .build("ActiveWithdrawal"); interface BankState { @@ -221,7 +234,7 @@ const BANK_STATE_KEY = buildStorageKey("bank-app-state", codecForBankState()); export function useBankState(): [ Readonly<BankState>, <T extends keyof BankState>(key: T, value: BankState[T]) => void, - () => void, + (preserveActiveWithdrawal?: boolean) => void, ] { const { value, update } = useLocalStorage(BANK_STATE_KEY, defaultBankState); @@ -232,8 +245,13 @@ export function useBankState(): [ } update(newValue); } - function reset() { - update(defaultBankState); + function reset(preserveActiveWithdrawal = false) { + update({ + ...defaultBankState, + activeWithdrawal: preserveActiveWithdrawal + ? value.activeWithdrawal + : undefined, + }); } return [value, updateField, reset]; } diff --git a/packages/libeufin-bank-webui/src/hooks/preferences.test.ts b/packages/libeufin-bank-webui/src/hooks/preferences.test.ts @@ -25,6 +25,7 @@ describe("bank interface preferences", () => { const currentPreferences = { showInstallWallet: true, fastWithdrawalForm: false, + cashAcceptorSimulator: false, }; it("does not require the removed withdrawal confirmation preference", () => { @@ -45,6 +46,17 @@ describe("bank interface preferences", () => { assert.deepEqual(getAllBooleanPreferences(), [ "showInstallWallet", "fastWithdrawalForm", + "cashAcceptorSimulator", ]); }); + + it("defaults the cash acceptor preference for older storage", () => { + assert.deepEqual( + codecForPreferences().decode({ + showInstallWallet: true, + fastWithdrawalForm: false, + }), + currentPreferences, + ); + }); }); diff --git a/packages/libeufin-bank-webui/src/hooks/preferences.ts b/packages/libeufin-bank-webui/src/hooks/preferences.ts @@ -19,6 +19,7 @@ import { TranslatedString, buildCodecForObject, codecForBoolean, + codecOptionalDefault, } from "@gnu-taler/taler-util"; import { buildStorageKey, @@ -29,6 +30,7 @@ import { interface Preferences { showInstallWallet: boolean; fastWithdrawalForm: boolean; + cashAcceptorSimulator: boolean; } export const codecForPreferences = (): Codec<Preferences> => @@ -36,11 +38,16 @@ export const codecForPreferences = (): Codec<Preferences> => .allowExtra() .property("showInstallWallet", codecForBoolean()) .property("fastWithdrawalForm", codecForBoolean()) + .property( + "cashAcceptorSimulator", + codecOptionalDefault(codecForBoolean(), false), + ) .build("Preferences"); const defaultPreferences: Preferences = { showInstallWallet: true, fastWithdrawalForm: false, + cashAcceptorSimulator: false, }; const BANK_PREFERENCES_KEY = buildStorageKey( @@ -69,7 +76,7 @@ export function usePreferences(): [ } export function getAllBooleanPreferences(): Array<keyof Preferences> { - return ["showInstallWallet", "fastWithdrawalForm"]; + return ["showInstallWallet", "fastWithdrawalForm", "cashAcceptorSimulator"]; } export function getLabelForPreferences( @@ -81,6 +88,8 @@ export function getLabelForPreferences( return i18n.str`Withdraw without setting amount`; case "showInstallWallet": return i18n.str`Show install wallet first`; + case "cashAcceptorSimulator": + return i18n.str`Show cash acceptor simulator`; // case "showDebugInfo": // return i18n.str`Show debug info`; } diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/index.ts b/packages/libeufin-bank-webui/src/pages/AccountPage/index.ts @@ -32,6 +32,7 @@ export interface Props { routeCashout: RouteDefinition; routeChargeWallet: RouteDefinition; + routeCashAcceptor: RouteDefinition; routeWireTransfer: RouteDefinition; routeTransactions: RouteDefinition; routeAccountDetails: RouteDefinition; @@ -73,6 +74,7 @@ export namespace State { balanceIsDebit: boolean; routeCashout: RouteDefinition; routeChargeWallet: RouteDefinition; + routeCashAcceptor: RouteDefinition; routeWireTransfer: RouteDefinition; routeTransactions: RouteDefinition; routeAccountDetails: RouteDefinition; diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/state.ts b/packages/libeufin-bank-webui/src/pages/AccountPage/state.ts @@ -31,6 +31,7 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; export function useComponentState({ account, routeChargeWallet, + routeCashAcceptor, routeOperationDetails, routeWireTransfer, routeCashout, @@ -87,6 +88,7 @@ export function useComponentState({ routeCashout, routeOperationDetails, routeChargeWallet, + routeCashAcceptor, routeWireTransfer, routeTransactions, routeAccountDetails, diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/stories.tsx b/packages/libeufin-bank-webui/src/pages/AccountPage/stories.tsx @@ -39,6 +39,7 @@ export const Ready = tests.createExample(ReadyView, { balanceIsDebit: false, routeCashout: route, routeChargeWallet: route, + routeCashAcceptor: route, routeWireTransfer: route, routeTransactions: route, routeAccountDetails: route, diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/views.tsx b/packages/libeufin-bank-webui/src/pages/AccountPage/views.tsx @@ -128,6 +128,7 @@ export function ReadyView({ accountLoginName, accountBankHost, routeChargeWallet, + routeCashAcceptor, routeWireTransfer, balance, balanceIsDebit, @@ -196,6 +197,7 @@ export function ReadyView({ routeOperationDetails={routeOperationDetails} routeCashout={routeCashout} routeChargeWallet={routeChargeWallet} + routeCashAcceptor={routeCashAcceptor} routeWireTransfer={routeWireTransfer} /> {showRecentActivity ? ( diff --git a/packages/libeufin-bank-webui/src/pages/ActiveWithdrawal.tsx b/packages/libeufin-bank-webui/src/pages/ActiveWithdrawal.tsx @@ -85,6 +85,7 @@ export function ActiveWithdrawal({ username, backendBaseUrl: backendUrl.href, confirmationDeferred: false, + kind: "standard", }); }, [ active, @@ -112,6 +113,7 @@ export function ActiveWithdrawal({ username, backendBaseUrl: backendUrl.href, confirmationDeferred: false, + kind: "standard", }); }, [ active, diff --git a/packages/libeufin-bank-webui/src/pages/BankFrame.tsx b/packages/libeufin-bank-webui/src/pages/BankFrame.tsx @@ -16,8 +16,11 @@ import { AbsoluteTime, + HttpStatusCode, ObservabilityEventType, + TalerBankIntegrationHttpClient, TalerError, + TranslatedString, assertUnreachable, } from "@gnu-taler/taler-util"; import { @@ -86,7 +89,7 @@ export function BankFrame({ const settings = useSettingsContext(); const showDemoBanner = settings.enableDemoHeader ?? false; const showPublicAccounts = settings.showPublicAccounts ?? false; - const [, , resetBankState] = useBankState(); + const [bankState, , resetBankState] = useBankState(); const path = useOptionalNavigationContext()?.path; const { clear: clearNotifications, clearErrors } = useNotificationContext(); const previousPath = useRef(path); @@ -127,7 +130,7 @@ export function BankFrame({ } finally { clearNotifications(); session.logOut(); - resetBankState(); + resetBankState(bankState.activeWithdrawal?.kind === "cash-acceptor"); setRevocationWarning(!revocationConfirmed); if (typeof window !== "undefined") { if (revocationConfirmed) { @@ -305,6 +308,78 @@ function BankSettingsDialog({ const { i18n } = useTranslationContext(); const [{ showDebugInfo }, updateCommonPreference] = useCommonPreferences(); const [preferences, updatePreference] = usePreferences(); + const [bankState, updateBankState] = useBankState(); + const { displayInfo } = useNotificationContext(); + const [confirmDisableSimulator, setConfirmDisableSimulator] = useState(false); + const [disableSimulatorRunning, setDisableSimulatorRunning] = useState(false); + const [disableSimulatorError, setDisableSimulatorError] = useState<string>(); + + function finishDisablingSimulator(message?: TranslatedString): void { + updateBankState("activeWithdrawal", undefined); + updatePreference("cashAcceptorSimulator", false); + setConfirmDisableSimulator(false); + setDisableSimulatorError(undefined); + if (message) displayInfo(message); + } + + async function disableSimulator(): Promise<void> { + const active = bankState.activeWithdrawal; + if (!active || active.kind !== "cash-acceptor") { + updatePreference("cashAcceptorSimulator", false); + setConfirmDisableSimulator(false); + return; + } + setDisableSimulatorRunning(true); + setDisableSimulatorError(undefined); + try { + const integration = new TalerBankIntegrationHttpClient( + new URL("taler-integration/", active.backendBaseUrl).href, + ); + const aborted = await integration.abortWithdrawalOperationById( + active.operationId, + ); + if (aborted.type === "ok") { + finishDisablingSimulator(i18n.str`Cash acceptor simulation aborted.`); + return; + } + if (aborted.case === HttpStatusCode.NotFound) { + finishDisablingSimulator( + i18n.str`The cash acceptor operation no longer exists. The simulator was disabled.`, + ); + return; + } + if (aborted.case === HttpStatusCode.Conflict) { + const status = await integration.getWithdrawalOperationById( + active.operationId, + ); + if (status.type === "fail" && status.case === HttpStatusCode.NotFound) { + finishDisablingSimulator( + i18n.str`The cash acceptor operation no longer exists. The simulator was disabled.`, + ); + return; + } + if ( + status.type === "ok" && + (status.body.status === "confirmed" || + status.body.status === "aborted") + ) { + finishDisablingSimulator( + i18n.str`The cash acceptor operation had already finished. The simulator was disabled.`, + ); + return; + } + } + setDisableSimulatorError( + i18n.str`The operation is still active and could not be aborted. Please try again.`, + ); + } catch { + setDisableSimulatorError( + i18n.str`The bank could not be reached. The simulator remains enabled; please try again.`, + ); + } finally { + setDisableSimulatorRunning(false); + } + } useEffect(() => { if (!open || typeof window === "undefined") return; @@ -350,60 +425,124 @@ function BankSettingsDialog({ </button> </div> - <ul class="mt-6 divide-y divide-gray-200"> - {getAllBooleanPreferences().map((preference) => { - const isOn = !!preferences[preference]; - const labelId = `settings-preference-${preference}`; - return ( - <li - key={preference} - class="flex items-center justify-between gap-4 py-4" + {confirmDisableSimulator ? ( + <div class="mt-6"> + <h3 class="font-semibold"> + <i18n.Translate> + Turn off the simulator and abort the active operation? + </i18n.Translate> + </h3> + <p class="mt-2 text-sm text-gray-600"> + <i18n.Translate> + The active cash acceptor simulation cannot be resumed after it + is aborted. The simulator will only be turned off after the + bank confirms that the operation has ended. + </i18n.Translate> + </p> + {disableSimulatorError ? ( + <p + class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800" + role="alert" > - <span id={labelId} class="text-sm font-medium"> - {getLabelForPreferences(preference, i18n)} + {disableSimulatorError} + </p> + ) : undefined} + <div class="mt-6 flex justify-end gap-3"> + <button + type="button" + disabled={disableSimulatorRunning} + class="rounded-md px-3 py-2 text-sm font-semibold text-onBackground hover:bg-gray-100 disabled:opacity-50" + onClick={() => { + setConfirmDisableSimulator(false); + setDisableSimulatorError(undefined); + }} + > + <i18n.Translate>Keep simulator enabled</i18n.Translate> + </button> + <button + type="button" + name="confirm disable cash acceptor simulator" + disabled={disableSimulatorRunning} + class="rounded-md bg-red-700 px-3 py-2 text-sm font-semibold text-white hover:bg-red-800 disabled:opacity-50" + onClick={() => void disableSimulator()} + > + {disableSimulatorRunning ? ( + <i18n.Translate>Aborting…</i18n.Translate> + ) : ( + <i18n.Translate>Abort and turn off</i18n.Translate> + )} + </button> + </div> + </div> + ) : ( + <Fragment> + <ul class="mt-6 divide-y divide-gray-200"> + {getAllBooleanPreferences().map((preference) => { + const isOn = !!preferences[preference]; + const labelId = `settings-preference-${preference}`; + return ( + <li + key={preference} + class="flex items-center justify-between gap-4 py-4" + > + <span id={labelId} class="text-sm font-medium"> + {getLabelForPreferences(preference, i18n)} + </span> + <PreferenceSwitch + name={`${preference} switch`} + labelId={labelId} + enabled={isOn} + onChange={() => { + if ( + preference === "cashAcceptorSimulator" && + isOn && + bankState.activeWithdrawal?.kind === "cash-acceptor" + ) { + setDisableSimulatorError(undefined); + setConfirmDisableSimulator(true); + return; + } + updatePreference(preference, !isOn); + }} + /> + </li> + ); + })} + <li class="flex items-center justify-between gap-4 py-4"> + <span id="settings-debug-label" class="text-sm font-medium"> + <i18n.Translate>Show debug information</i18n.Translate> </span> <PreferenceSwitch - name={`${preference} switch`} - labelId={labelId} - enabled={isOn} - onChange={() => updatePreference(preference, !isOn)} + name="debug switch" + labelId="settings-debug-label" + enabled={showDebugInfo} + onChange={() => + updateCommonPreference("showDebugInfo", !showDebugInfo) + } /> </li> - ); - })} - <li class="flex items-center justify-between gap-4 py-4"> - <span id="settings-debug-label" class="text-sm font-medium"> - <i18n.Translate>Show debug information</i18n.Translate> - </span> - <PreferenceSwitch - name="debug switch" - labelId="settings-debug-label" - enabled={showDebugInfo} - onChange={() => - updateCommonPreference("showDebugInfo", !showDebugInfo) - } - /> - </li> - </ul> + </ul> - <div class="mt-6 flex justify-end gap-3"> - {onSignOut ? ( - <button - type="button" - class="mr-auto rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-onBackground shadow-sm hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600" - onClick={() => void onSignOut()} - > - <i18n.Translate>Sign out</i18n.Translate> - </button> - ) : undefined} - <button - type="button" - class="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" - onClick={onClose} - > - <i18n.Translate>Done</i18n.Translate> - </button> - </div> + <div class="mt-6 flex justify-end gap-3"> + {onSignOut ? ( + <button + type="button" + class="mr-auto rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-onBackground shadow-sm hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600" + onClick={() => void onSignOut()} + > + <i18n.Translate>Sign out</i18n.Translate> + </button> + ) : undefined} + <button + type="button" + class="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary shadow-sm hover:bg-primaryHover hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + onClick={onClose} + > + <i18n.Translate>Done</i18n.Translate> + </button> + </div> + </Fragment> + )} </section> </div> </dialog> diff --git a/packages/libeufin-bank-webui/src/pages/CashAcceptor.tsx b/packages/libeufin-bank-webui/src/pages/CashAcceptor.tsx @@ -0,0 +1,81 @@ +/* + 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. +*/ + +import { + Loading, + RouteDefinition, + useNavigationContext, + useBankCoreApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useEffect } from "preact/hooks"; +import { usePreferences } from "../hooks/preferences.js"; +import { useBankState } from "../hooks/bank-state.js"; +import { useSessionState } from "../hooks/session.js"; +import { OperationState } from "./OperationState/index.js"; + +export function CashAcceptor({ + routeCancel, + routeOperation, +}: { + routeCancel: RouteDefinition; + routeOperation: RouteDefinition<{ wopid: string }>; +}): VNode { + const { i18n } = useTranslationContext(); + const { navigateTo } = useNavigationContext(); + const [preferences] = usePreferences(); + const [bankState] = useBankState(); + const { state: session } = useSessionState(); + const { url: backendUrl } = useBankCoreApiContext(); + const active = + session.status === "loggedIn" && + bankState.activeWithdrawal?.username === session.username && + bankState.activeWithdrawal.backendBaseUrl === backendUrl.href + ? bankState.activeWithdrawal + : undefined; + + useEffect(() => { + if (!preferences.cashAcceptorSimulator) { + navigateTo(routeCancel.url({})); + } else if (active) { + navigateTo(routeOperation.url({ wopid: active.operationId })); + } + }, [ + active, + navigateTo, + preferences.cashAcceptorSimulator, + routeCancel, + routeOperation, + ]); + + if (!preferences.cashAcceptorSimulator || active) return <Loading />; + + return ( + <section class="mt-2"> + <a + href={routeCancel.url({})} + class="inline-flex items-center text-sm font-semibold text-primaryDark hover:underline" + > + <span class="mr-1" aria-hidden="true"> + ← + </span> + <i18n.Translate>Back to overview</i18n.Translate> + </a> + <OperationState + creationMode="cash-acceptor" + routeClose={routeCancel} + onAbort={() => navigateTo(routeCancel.url({}))} + onOperationCreated={(wopid) => + navigateTo(routeOperation.url({ wopid })) + } + /> + </section> + ); +} diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/OperationState.test.ts b/packages/libeufin-bank-webui/src/pages/OperationState/OperationState.test.ts @@ -30,6 +30,7 @@ import { maximumWithdrawalAmount, validateWithdrawalAmount, } from "../withdrawal-amount.js"; +import { accountWithdrawalMaximum } from "./views.js"; describe("withdrawal limits", () => { it("reserves the bank fee from the account limit", () => { @@ -80,6 +81,23 @@ describe("withdrawal limits", () => { { currency: "EUR", value: 0, fraction: 0 }, ); }); + + it("includes the live account balance and debit threshold", () => { + assert.deepEqual( + accountWithdrawalMaximum( + { + balance: { + amount: "EUR:10", + credit_debit_indicator: "credit", + }, + debit_threshold: "EUR:5", + } as any, + { currency: "EUR", value: 0, fraction: 10_000_000 }, + { currency: "EUR", value: 20, fraction: 0 }, + ), + { currency: "EUR", value: 14, fraction: 90_000_000 }, + ); + }); }); describe("withdrawal operation lifecycle", () => { @@ -155,6 +173,24 @@ describe("withdrawal operation lifecycle", () => { assert.equal(suggested.progressMode, "wallet-first"); }); + it("marks cash acceptor handoff for external confirmation", () => { + const state = buildWithdrawalOperationState({ + ...common, + result: success({ + status: "pending", + username: "alice", + no_amount_to_wallet: true, + }), + }); + + assert.equal(state.status, "ready"); + if (state.status !== "ready") return; + assert.equal(state.cashAcceptor, true); + assert.equal(state.uri.externalConfirmation, true); + assert.equal(state.amount, undefined); + assert.equal(state.progressMode, "wallet-first"); + }); + it("preserves aborted and confirmed terminal states", () => { const aborted = success({ status: "aborted", username: "alice" }); const confirmed = success({ status: "confirmed", username: "alice" }); @@ -261,6 +297,7 @@ describe("withdrawal operation lifecycle", () => { fraction: 50_000_000, }, }); + assert.equal(selected.cashAcceptor, true); assert.deepEqual(missing.details.amount, { mode: "missing" }); }); }); diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/index.ts b/packages/libeufin-bank-webui/src/pages/OperationState/index.ts @@ -45,6 +45,7 @@ export interface Props { onContinueLater?: () => void; onOperationCreated?: (operationId: string) => void; focus?: boolean; + creationMode?: "suggested" | "cash-acceptor"; } export interface WithdrawalOperationProps extends Props { @@ -97,6 +98,7 @@ export namespace State { onAbort: () => void; operationId: string; routeClose: RouteDefinition; + cashAcceptor: boolean; } export interface InvalidPayto { @@ -133,6 +135,7 @@ export namespace State { }; operationId: string; progressMode: WithdrawalProgressMode; + cashAcceptor: boolean; } export interface Aborted { status: "aborted"; @@ -143,6 +146,7 @@ export namespace State { status: "confirmed"; error: undefined; routeClose: RouteDefinition; + cashAcceptor: boolean; } } diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/state.ts b/packages/libeufin-bank-webui/src/pages/OperationState/state.ts @@ -37,7 +37,6 @@ import { useWithdrawalDetails, } from "../../hooks/account.js"; import { useBankState } from "../../hooks/bank-state.js"; -import { usePreferences } from "../../hooks/preferences.js"; import { useSessionState } from "../../hooks/session.js"; import { Props, State, WithdrawalOperationProps } from "./index.js"; @@ -101,7 +100,12 @@ export function buildWithdrawalOperationState({ return { status: "aborted", error: undefined, routeClose }; } if (data.status === "confirmed") { - return { status: "confirmed", error: undefined, routeClose }; + return { + status: "confirmed", + error: undefined, + routeClose, + cashAcceptor: data.no_amount_to_wallet === true, + }; } const amount = !data.amount ? undefined : Amounts.parse(data.amount); @@ -117,6 +121,7 @@ export function buildWithdrawalOperationState({ type: TalerUriAction.Withdraw, bankIntegrationApiBaseUrl, withdrawalOperationId: operationId, + ...(data.no_amount_to_wallet ? { externalConfirmation: true } : {}), }, amount: amount ? { mode: "fixed", value: amount } @@ -128,6 +133,7 @@ export function buildWithdrawalOperationState({ focus, operationId, onAbort, + cashAcceptor: data.no_amount_to_wallet === true, }; } if (!data.selected_reserve_pub) { @@ -173,6 +179,7 @@ export function buildWithdrawalOperationState({ data.no_amount_to_wallet || suggestedAmount ? "wallet-first" : "amount-first", + cashAcceptor: data.no_amount_to_wallet === true, onAbort, onContinueLater, }; @@ -184,8 +191,8 @@ export function useComponentState({ onContinueLater, onOperationCreated, focus, + creationMode = "suggested", }: Props): utils.RecursiveState<State> { - const [preference] = usePreferences(); const settings = useSettingsContext(); const [bankState, updateBankState] = useBankState(); const { state: credentials } = useSessionState(); @@ -204,16 +211,16 @@ export function useComponentState({ const doSilentStart = useCallback( async (generation: number) => { - // FIXME: if amount is not enough use balance - const parsedAmount = Amounts.parseOrThrow(`${config.currency}:${amount}`); if (!creds) return; const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest = - preference.fastWithdrawalForm + creationMode === "cash-acceptor" ? { - suggested_amount: Amounts.stringify(parsedAmount), + no_amount_to_wallet: true, } : { - amount: Amounts.stringify(parsedAmount), + suggested_amount: Amounts.stringify( + Amounts.parseOrThrow(`${config.currency}:${amount}`), + ), }; const resp = await bank.createWithdrawal(creds, params); @@ -227,6 +234,7 @@ export function useComponentState({ username: creds.username, backendBaseUrl: backendUrl.href, confirmationDeferred: false, + kind: creationMode === "cash-acceptor" ? "cash-acceptor" : "standard", }); onOperationCreated?.(resp.body.withdrawal_id); }, @@ -235,9 +243,9 @@ export function useComponentState({ bank, backendUrl.href, config.currency, + creationMode, creds, onOperationCreated, - preference.fastWithdrawalForm, updateBankState, ], ); diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx b/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx @@ -20,6 +20,8 @@ import { Amounts, HttpStatusCode, PaytoType, + TalerCorebankApi, + TalerError, TalerErrorCode, TalerUriAction, TalerUris, @@ -38,7 +40,11 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; -import { revalidateWithdrawalDetails } from "../../hooks/account.js"; +import { + revalidateAccountDetails, + revalidateWithdrawalDetails, + useAccountDetails, +} from "../../hooks/account.js"; import { LoggedIn, useSessionState } from "../../hooks/session.js"; import { useBankChallengeHandlerContext } from "../../context/challenge.js"; @@ -51,6 +57,33 @@ import { } from "../../components/AbortWithdrawalDialog.js"; import { WithdrawalProgress } from "../WithdrawalProgress.js"; import { validateWithdrawalAmount } from "../withdrawal-amount.js"; +import { maximumWithdrawalAmount } from "../withdrawal-amount.js"; +import { IntAmounts } from "../regional/CreateCashout.js"; + +export function accountWithdrawalMaximum( + account: TalerCorebankApi.AccountData, + fee: AmountJson, + configuredMaximum: AmountJson | undefined, +): AmountJson | undefined { + const balance = Amounts.parse(account.balance.amount); + const debtLimit = Amounts.parse(account.debit_threshold); + const currency = fee.currency.toUpperCase(); + if ( + !balance || + !debtLimit || + balance.currency.toUpperCase() !== currency || + debtLimit.currency.toUpperCase() !== currency || + (configuredMaximum && configuredMaximum.currency.toUpperCase() !== currency) + ) { + return undefined; + } + const signed = IntAmounts.toIntAmount( + balance, + account.balance.credit_debit_indicator === "debit", + ); + const limit = signed.increment(debtLimit).result; + return maximumWithdrawalAmount(limit, fee, configuredMaximum); +} export function InvalidPaytoView({ payto }: State.InvalidPayto) { const { i18n } = useTranslationContext(); @@ -84,6 +117,7 @@ export function NeedConfirmationView({ details, operationId, progressMode, + cashAcceptor, }: State.NeedConfirmation) { const { i18n } = useTranslationContext(); const mfa = useBankChallengeHandlerContext(); @@ -113,6 +147,7 @@ export function NeedConfirmationView({ const [amountInput, setAmountInput] = useState<string | undefined>( initialAmount, ); + const accountResult = useAccountDetails(account); useEffect(() => { setAmountInput(initialAmount); @@ -178,6 +213,21 @@ export function NeedConfirmationView({ (amount) => amount.currency.toUpperCase() === config.currency.toUpperCase(), ); + const accountMaximum = + cashAcceptor && + wireFee && + accountResult && + !(accountResult instanceof TalerError) && + accountResult.type === "ok" + ? accountWithdrawalMaximum(accountResult.body, wireFee, maximum) + : undefined; + const accountCapacityUnavailable = + cashAcceptor && + !!accountResult && + (accountResult instanceof TalerError || + accountResult.type !== "ok" || + !accountMaximum); + const displayedMaximum = cashAcceptor ? accountMaximum : maximum; const trimmedAmount = amountInput?.trim(); const editableAmount = !trimmedAmount @@ -193,6 +243,11 @@ export function NeedConfirmationView({ !editableAmount || !configAmountsValid ? undefined : validateWithdrawalAmount(editableAmount, minimum, maximum); + const exceedsAccountCapacity = + cashAcceptor && + editableAmount && + accountMaximum && + Amounts.cmp(editableAmount, accountMaximum) > 0; const amountError = details.amount.mode !== "bank-selected" || !amountTouched ? undefined @@ -206,7 +261,9 @@ export function NeedConfirmationView({ ? i18n.str`The amount is below the bank's minimum.` : amountValidation === "above-maximum" ? i18n.str`The amount is above the bank's maximum.` - : undefined; + : exceedsAccountCapacity + ? i18n.str`The amount plus the bank fee exceeds the account's available balance.` + : undefined; const expectsTalerBank = config.wire_type === "X_TALER_BANK" || config.wire_type === "x-taler-bank"; @@ -231,6 +288,8 @@ export function NeedConfirmationView({ !invalidConfiguration && !!selectedAmount && !amountValidation && + !exceedsAccountCapacity && + !accountCapacityUnavailable && !!totalDebit; function fail(title: TranslatedString, description: TranslatedString): void { @@ -381,6 +440,38 @@ export function NeedConfirmationView({ if (!canConfirm || !creds) return; setAmountTouched(true); setDialogError(undefined); + if (cashAcceptor && selectedAmount && wireFee) { + try { + const freshAccount = await bank.getAccount(creds); + if (freshAccount.type !== "ok") { + fail( + i18n.str`Failed to refresh the account balance.`, + i18n.str`The cash acceptor amount was not confirmed. Please try again.`, + ); + return; + } + const freshMaximum = accountWithdrawalMaximum( + freshAccount.body, + wireFee, + maximum, + ); + await revalidateAccountDetails(); + if (!freshMaximum || Amounts.cmp(selectedAmount, freshMaximum) > 0) { + setAmountTouched(true); + fail( + i18n.str`Insufficient available balance.`, + i18n.str`The amount plus the bank fee no longer fits within this account's available balance.`, + ); + return; + } + } catch { + fail( + i18n.str`Failed to refresh the account balance.`, + i18n.str`The bank could not be reached. Please try again.`, + ); + return; + } + } await confirm.run(creds, confirmationAmount); } @@ -446,17 +537,37 @@ export function NeedConfirmationView({ id="withdrawal-confirmation-title" class="mt-5 text-lg font-semibold outline-none" > - <i18n.Translate>Confirm wallet withdrawal</i18n.Translate> + {cashAcceptor ? ( + <i18n.Translate>Enter accepted cash amount</i18n.Translate> + ) : ( + <i18n.Translate>Confirm wallet withdrawal</i18n.Translate> + )} </h2> <p id="withdrawal-confirmation-description" class="mt-2 text-sm text-gray-600" > - <i18n.Translate> - Review how much will leave your bank account before confirming. - </i18n.Translate> + {cashAcceptor ? ( + <i18n.Translate> + Enter what the wallet should receive, then review the fee and + total account debit. + </i18n.Translate> + ) : ( + <i18n.Translate> + Review how much will leave your bank account before confirming. + </i18n.Translate> + )} </p> + {cashAcceptor ? ( + <div class="mt-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"> + <i18n.Translate> + Simulation: no physical cash is accepted. The signed-in bank + account provides the wallet amount and pays the bank fee. + </i18n.Translate> + </div> + ) : undefined} + {dialogError ? ( <div class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800" @@ -515,6 +626,22 @@ export function NeedConfirmationView({ </p> </div> ) : undefined} + {accountCapacityUnavailable ? ( + <div + class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800" + role="alert" + > + <div class="font-semibold"> + <i18n.Translate>Account balance unavailable</i18n.Translate> + </div> + <p class="mt-1"> + <i18n.Translate> + The available balance must be loaded before confirming the cash + acceptor amount. + </i18n.Translate> + </p> + </div> + ) : undefined} <div class="mt-5 rounded-xl border border-onBackground/10 bg-background px-4 py-5 text-center"> <div class="text-sm font-medium text-gray-600"> @@ -526,7 +653,11 @@ export function NeedConfirmationView({ id="withdrawal-amount-label" class="block text-sm font-medium text-onBackground" > - <i18n.Translate>Amount to your Taler Wallet</i18n.Translate> + {cashAcceptor ? ( + <i18n.Translate>Accepted cash amount</i18n.Translate> + ) : ( + <i18n.Translate>Amount to your Taler Wallet</i18n.Translate> + )} </div> <InputAmount currency={config.currency} @@ -545,9 +676,9 @@ export function NeedConfirmationView({ {amountError} </p> ) : undefined} - {configAmountsValid && (minimum || maximum) ? ( + {configAmountsValid && (minimum || displayedMaximum) ? ( <p class="mt-2 text-xs text-gray-600"> - {minimum && maximum ? ( + {minimum && displayedMaximum ? ( <Fragment> <i18n.Translate>Allowed amount</i18n.Translate>:{" "} <RenderAmount @@ -556,7 +687,7 @@ export function NeedConfirmationView({ />{" "} –{" "} <RenderAmount - value={maximum} + value={displayedMaximum} spec={config.currency_specification} /> </Fragment> @@ -568,11 +699,11 @@ export function NeedConfirmationView({ spec={config.currency_specification} /> </Fragment> - ) : maximum ? ( + ) : displayedMaximum ? ( <Fragment> <i18n.Translate>Maximum amount</i18n.Translate>:{" "} <RenderAmount - value={maximum} + value={displayedMaximum} spec={config.currency_specification} /> </Fragment> @@ -860,7 +991,7 @@ export function AbortedView() { ); } -export function ConfirmedView({ routeClose }: State.Confirmed) { +export function ConfirmedView({ routeClose, cashAcceptor }: State.Confirmed) { const { i18n } = useTranslationContext(); return ( <section @@ -889,13 +1020,26 @@ export function ConfirmedView({ routeClose }: State.Confirmed) { class="text-xl font-semibold leading-7" id="withdrawal-confirmed-title" > - <i18n.Translate>Withdrawal confirmed</i18n.Translate> + {cashAcceptor ? ( + <i18n.Translate> + Cash acceptor simulation completed + </i18n.Translate> + ) : ( + <i18n.Translate>Withdrawal confirmed</i18n.Translate> + )} </h1> <p class="mx-auto mt-3 max-w-sm text-sm leading-6 text-gray-600"> - <i18n.Translate> - The bank transfer to the Taler exchange has been initiated. Your - Taler Wallet will receive the withdrawn amount shortly. - </i18n.Translate> + {cashAcceptor ? ( + <i18n.Translate> + The simulated cash amount has been funded from this bank + account. The Taler Wallet will receive it shortly. + </i18n.Translate> + ) : ( + <i18n.Translate> + The bank transfer to the Taler exchange has been initiated. Your + Taler Wallet will receive the withdrawn amount shortly. + </i18n.Translate> + )} </p> </div> </div> @@ -919,6 +1063,7 @@ export function ReadyView({ onAbort, operationId, routeClose, + cashAcceptor, }: State.Ready): VNode { const { i18n } = useTranslationContext(); const { publishTalerAction } = useTalerWalletIntegrationAPI(); @@ -933,20 +1078,23 @@ export function ReadyView({ lib: { bank }, } = useBankCoreApiContext(); - const talerWithdrawUri = TalerUris.stringify({ - type: TalerUriAction.Withdraw, - bankIntegrationApiBaseUrl: uri.bankIntegrationApiBaseUrl, - withdrawalOperationId: uri.withdrawalOperationId, - }); + const talerWithdrawUri = TalerUris.stringify(uri); const integrationBaseUrl = uri.bankIntegrationApiBaseUrl; const withdrawalOperationId = uri.withdrawalOperationId; + const externalConfirmation = uri.externalConfirmation; useEffect(() => { publishTalerAction({ type: TalerUriAction.Withdraw, bankIntegrationApiBaseUrl: integrationBaseUrl, withdrawalOperationId, + ...(externalConfirmation ? { externalConfirmation: true } : {}), }); - }, [integrationBaseUrl, publishTalerAction, withdrawalOperationId]); + }, [ + externalConfirmation, + integrationBaseUrl, + publishTalerAction, + withdrawalOperationId, + ]); function fail(description: string): void { setDialogError({ @@ -1003,17 +1151,38 @@ export function ReadyView({ id="wallet-withdrawal-title" class="mt-6 text-xl font-semibold text-brand sm:text-2xl" > - <i18n.Translate> - Complete withdrawal with your Taler Wallet - </i18n.Translate> + {cashAcceptor ? ( + <i18n.Translate>Simulate cash acceptor</i18n.Translate> + ) : ( + <i18n.Translate> + Complete withdrawal with your Taler Wallet + </i18n.Translate> + )} </h1> <p class="mx-auto mt-3 max-w-xl text-sm leading-6 text-gray-600"> - <i18n.Translate> - Scan this QR code with your Taler Wallet. Your wallet will select - an exchange and prepare the withdrawal. - </i18n.Translate> + {cashAcceptor ? ( + <i18n.Translate> + Scan this QR code first. The wallet will select an exchange and + then this simulator will ask for the accepted cash amount. + </i18n.Translate> + ) : ( + <i18n.Translate> + Scan this QR code with your Taler Wallet. Your wallet will + select an exchange and prepare the withdrawal. + </i18n.Translate> + )} </p> + {cashAcceptor ? ( + <div class="mx-auto mt-5 max-w-xl rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-left text-sm text-amber-900"> + <i18n.Translate> + Simulation: no physical cash is accepted. Do not enter an amount + until the wallet has scanned this code and selected its + exchange. + </i18n.Translate> + </div> + ) : undefined} + {amount ? ( <div class="mx-auto mt-6 max-w-sm rounded-lg border border-primary/20 bg-primary/5 px-4 py-3"> <div class="text-sm font-medium text-secondary"> diff --git a/packages/libeufin-bank-webui/src/pages/PaymentOptions.stories.tsx b/packages/libeufin-bank-webui/src/pages/PaymentOptions.stories.tsx @@ -32,6 +32,7 @@ const route = urlPattern<any>(/.*/, () => "#"); export const USD = tests.createExample(PaymentOptions, { routeCashout: route, routeChargeWallet: route, + routeCashAcceptor: route, routeWireTransfer: route, routeOperationDetails: route, }); diff --git a/packages/libeufin-bank-webui/src/pages/PaymentOptions.tsx b/packages/libeufin-bank-webui/src/pages/PaymentOptions.tsx @@ -14,24 +14,28 @@ import { } from "@gnu-taler/web-util/browser"; import { ComponentChildren, VNode, h } from "preact"; import { useBankState } from "../hooks/bank-state.js"; +import { usePreferences } from "../hooks/preferences.js"; import { useSessionState } from "../hooks/session.js"; export interface PaymentOptionProps { routeOperationDetails: RouteDefinition<{ wopid: string }>; routeCashout: RouteDefinition; routeChargeWallet: RouteDefinition; + routeCashAcceptor: RouteDefinition; routeWireTransfer: RouteDefinition; } export function PaymentOptions({ routeCashout, routeChargeWallet, + routeCashAcceptor, routeWireTransfer, routeOperationDetails, }: PaymentOptionProps): VNode { const { i18n } = useTranslationContext(); const { config, url: backendUrl } = useBankCoreApiContext(); const [bankState] = useBankState(); + const [preferences] = usePreferences(); const { state: session } = useSessionState(); const activeWithdrawal = session.status === "loggedIn" && @@ -44,6 +48,9 @@ export function PaymentOptions({ const walletHref = activeOperationId ? routeOperationDetails.url({ wopid: activeOperationId }) : routeChargeWallet.url({}); + const cashAcceptorHref = activeOperationId + ? routeOperationDetails.url({ wopid: activeOperationId }) + : routeCashAcceptor.url({}); return ( <section class="mt-8" aria-labelledby="account-actions-heading"> @@ -61,6 +68,15 @@ export function PaymentOptions({ title={i18n.str`Withdraw to Taler Wallet`} description={i18n.str`Move digital cash to your Taler Wallet.`} /> + {preferences.cashAcceptorSimulator ? ( + <ActionCard + name="cash acceptor simulator" + href={cashAcceptorHref} + icon={<CashAcceptorIcon />} + title={i18n.str`Simulate cash acceptor`} + description={i18n.str`Simulate the wallet-first flow used by a physical cash acceptor. This account provides the funds.`} + /> + ) : undefined} <ActionCard name="wire transfer" href={routeWireTransfer.url({})} @@ -155,6 +171,25 @@ function TransferIcon(): VNode { ); } +function CashAcceptorIcon(): VNode { + return ( + <svg + aria-hidden="true" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="1.8" + class="h-6 w-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M5 3h14v18H5V3Zm3 4h8m-8 4h8m-6 5h4" + /> + </svg> + ); +} + function CashoutIcon(): VNode { return ( <svg diff --git a/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx b/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx @@ -181,6 +181,7 @@ export function WithdrawalAmountForm({ username: creds!.username, backendBaseUrl: backendUrl.href, confirmationDeferred: false, + kind: "standard", }); onOperationCreated(uri.value.withdrawalOperationId); }, @@ -485,6 +486,7 @@ export function WalletWithdrawForm({ return preference.fastWithdrawalForm ? ( <OperationState + creationMode="suggested" focus={focus} routeClose={routeCancel} onAbort={onOperationAborted} diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts b/packages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts @@ -949,7 +949,9 @@ async function acceptAndConfirmWebuiWithdrawal(args: { }, }); const confirmationDialog = args.page.getByRole("dialog", { - name: "Confirm wallet withdrawal", + name: args.bankAmount + ? "Enter accepted cash amount" + : "Confirm wallet withdrawal", exact: true, }); await confirmationDialog.waitFor({ state: "visible" }); @@ -958,7 +960,7 @@ async function acceptAndConfirmWebuiWithdrawal(args: { .waitFor(); if (args.bankAmount) { await confirmationDialog - .getByLabel("Amount to your Taler Wallet", { exact: true }) + .getByLabel("Accepted cash amount", { exact: true }) .fill(Amounts.stringifyValue(Amounts.parseOrThrow(args.bankAmount))); } const fee = Amounts.parseOrThrow(args.expectedFee); @@ -1229,34 +1231,118 @@ export async function runLibeufinBankWebuiMoneyFlowsTest(t: GlobalTestState) { expectedFee: "TESTKUDOS:0.1", }); - // A cash-acceptor-style operation leaves amount selection to the bank. - const accessToken = succeedOrThrow( - await api.createAccessToken( - user, - { type: "basic", username: user, password }, - { scope: "readwrite" }, - ), - ).access_token; - const bankSelectedWithdrawal = succeedOrThrow( - await api.createWithdrawal( - { username: user, token: accessToken }, - { no_amount_to_wallet: true }, - ), + // The opt-in cash acceptor simulator leaves amount selection to the bank. + // Its direct route is inert while the preference is disabled. + await page.goto(`${webui.url}#/account/cash-acceptor`); + await page.waitForURL(/#\/account$/); + await page + .getByRole("button", { name: "Open interface preferences" }) + .click(); + const cashAcceptorSwitch = page.getByRole("switch", { + name: "Show cash acceptor simulator", + exact: true, + }); + t.assertDeepEqual( + await cashAcceptorSwitch.getAttribute("aria-checked"), + "false", ); - await page.goto( - `${webui.url}#/start-operation/${bankSelectedWithdrawal.withdrawal_id}`, + await cashAcceptorSwitch.click({ force: true }); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await page + .getByRole("link", { name: "Simulate cash acceptor", exact: true }) + .click(); + await page.waitForURL(/#\/start-operation\/[a-zA-Z0-9-]+$/); + await page + .getByRole("heading", { name: "Simulate cash acceptor", exact: true }) + .waitFor(); + const cashAcceptorUri = await page + .getByRole("link", { + name: "Open Taler Wallet manually", + exact: true, + }) + .getAttribute("href"); + const parsedCashAcceptorUri = cashAcceptorUri + ? TalerUris.parse(cashAcceptorUri) + : undefined; + t.assertTrue( + parsedCashAcceptorUri?.tag === "ok" && + parsedCashAcceptorUri.value.type === TalerUriAction.Withdraw && + parsedCashAcceptorUri.value.externalConfirmation === true, + "cash acceptor URI did not require external confirmation", ); await acceptAndConfirmWebuiWithdrawal({ t, page, walletClient, exchangeBaseUrl: exchange.baseUrl, - withdrawalUri: `${bankSelectedWithdrawal.taler_withdraw_uri}?external-confirmation=1`, bankAmount: "TESTKUDOS:10", expectedAmount: "TESTKUDOS:10", expectedFee: "TESTKUDOS:0.1", }); + // Disabling the simulator aborts its active operation through the + // unauthenticated WOPID capability before hiding the entry point. + await page.waitForURL(/#\/account$/); + await page + .getByRole("link", { name: "Simulate cash acceptor", exact: true }) + .click(); + const abortableCashLink = page.getByRole("link", { + name: "Open Taler Wallet manually", + exact: true, + }); + await abortableCashLink.waitFor({ state: "visible" }); + const abortableCashUri = await abortableCashLink.getAttribute("href"); + const parsedAbortableCashUri = abortableCashUri + ? TalerUris.parse(abortableCashUri) + : undefined; + if ( + parsedAbortableCashUri?.tag !== "ok" || + parsedAbortableCashUri.value.type !== TalerUriAction.Withdraw + ) { + throw Error("Bank WebUI did not expose an abortable cash acceptor URI"); + } + const abortedCashOperationId = + parsedAbortableCashUri.value.withdrawalOperationId; + await logout(page); + await page + .getByRole("button", { name: "Open interface preferences" }) + .click(); + await page + .getByRole("switch", { + name: "Show cash acceptor simulator", + exact: true, + }) + .click({ force: true }); + await page + .getByRole("heading", { + name: "Turn off the simulator and abort the active operation?", + exact: true, + }) + .waitFor(); + await page + .getByRole("button", { name: "Abort and turn off", exact: true }) + .click(); + await page + .getByRole("switch", { + name: "Show cash acceptor simulator", + exact: true, + }) + .waitFor(); + await page.getByRole("button", { name: "Done", exact: true }).click(); + const abortedCash = succeedOrThrow( + await api.getWithdrawalById(abortedCashOperationId), + ); + t.assertDeepEqual(abortedCash.status, "aborted"); + await login(page, webui.url, user, password); + await waitForLoggedIn(page); + await page.waitForURL(/#\/account$/); + t.assertDeepEqual( + await page + .getByRole("link", { name: "Simulate cash acceptor", exact: true }) + .count(), + 0, + ); + // A pending operation can be left, resumed from the global notice, and // explicitly aborted without leaving an orphaned active-operation record. await page.goto(`${webui.url}#/account/charge-wallet`);