taler-typescript-core

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

commit 1f74e51fdcb6d61395b079d2738bea2029cc1bd8
parent 4f9cbd0785983bfe5fda1dfdf33ade840016b2ab
Author: Florian Dold <dold@taler.net>
Date:   Tue,  1 Sep 2026 22:53:29 +0200

wallet web UI: let users reverse payment handoff

Diffstat:
Mpackages/wallet-webui/src/routes/App.tsx | 209++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/wallet-webui/src/screens/PaymentScreen.tsx | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/wallet-webui/src/screens/TransactionDetailScreen.tsx | 17+++++++++++++++--
3 files changed, 269 insertions(+), 22 deletions(-)

diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -3809,6 +3809,7 @@ function PaymentRoute() { const [actionError, setActionError] = useState<ErrorPresentation>(); const [resuming, setResuming] = useState(false); const [cancelling, setCancelling] = useState(false); + const [reclaiming, setReclaiming] = useState(false); useEffect(() => { let active = true; setFulfillmentTarget(undefined); @@ -3831,6 +3832,35 @@ function PaymentRoute() { ); const payment = query.data?.type === TransactionType.Payment ? query.data : undefined; + const reclaim = async () => { + if (reclaiming) return; + setReclaiming(true); + setActionError(undefined); + try { + const result = await callMutation( + WalletApiOperation.ReclaimPayment, + { transactionId: transactionId as TransactionIdStr }, + ["transactions"], + ); + if (Result.isError(result)) { + setActionError( + walletCoreError( + result.detail, + i18n.str`The payment could not continue with this wallet.`, + ), + ); + } + } catch (cause) { + setActionError( + errorFromException( + cause, + i18n.str`Continuing the payment with this wallet failed.`, + ), + ); + } finally { + setReclaiming(false); + } + }; const repurchaseTransactionId = paymentRepurchaseTarget(payment); const repurchaseQuery = useWalletQuery( connection, @@ -3994,11 +4024,14 @@ function PaymentRoute() { state="handoff" merchantName={terms.merchant.name} talerPayUri={transaction.unclaimedPayUri} + error={actionError} + reclaiming={reclaiming} onSelectChoice={() => {}} onConfirm={() => {}} onWithdraw={() => navigate("/withdraw")} onCancel={completeAndReturn} onOpenFulfillment={() => {}} + onReclaim={() => void reclaim()} /> ); if (transaction.txState.major === TransactionMajorState.Dialog) @@ -4076,6 +4109,9 @@ function PaymentDialog(props: { const { language } = useLanguage(); const [, navigate] = useLocation(); const callMutation = useWalletMutation(connection); + const { controller: unclaimRequest } = useWalletProgress(connection, { + visible: false, + }); const durability = useDurability(); const choicesQuery = useWalletQuery( connection, @@ -4107,14 +4143,24 @@ function PaymentDialog(props: { const [selectedChoice, setSelectedChoice] = useState<number>(); const [useDonau, setUseDonau] = useState(false); const [state, setState] = useState< - "ready" | "paying" | "handoff" | "pending" | "paused" | "done" | "error" + | "loading" + | "ready" + | "paying" + | "handoff" + | "pending" + | "paused" + | "done" + | "error" >("ready"); const [error, setError] = useState<ErrorPresentation>(); const [cancelling, setCancelling] = useState(false); const [resuming, setResuming] = useState(false); const [unclaiming, setUnclaiming] = useState(false); + const [reclaiming, setReclaiming] = useState(false); const [talerPayUri, setTalerPayUri] = useState<string>(); const automaticStarted = useRef(false); + const handoffTarget = useRef<"other" | "current">("other"); + const reclaimingRef = useRef(false); useEffect(() => { setSelectedChoice(undefined); setUseDonau(false); @@ -4123,8 +4169,11 @@ function PaymentDialog(props: { setCancelling(false); setResuming(false); setUnclaiming(false); + setReclaiming(false); setTalerPayUri(undefined); automaticStarted.current = false; + handoffTarget.current = "other"; + reclaimingRef.current = false; }, [props.actionId, props.transaction.transactionId]); const activeChoice = activePaymentChoiceIndex(review, selectedChoice); const confirm = useCallback( @@ -4262,34 +4311,95 @@ function PaymentDialog(props: { setResuming(false); } }; + const reclaim = async () => { + if (reclaimingRef.current) return; + reclaimingRef.current = true; + setReclaiming(true); + setError(undefined); + const returnState = talerPayUri ? "handoff" : "ready"; + try { + const result = await callMutation( + WalletApiOperation.ReclaimPayment, + { transactionId: props.transaction.transactionId }, + ["transactions"], + ); + if (Result.isError(result)) { + setError( + walletCoreError( + result.detail, + i18n.str`The payment could not continue with this wallet.`, + ), + ); + setState(returnState); + return; + } + setTalerPayUri(undefined); + setState("loading"); + } catch (cause) { + setError( + errorFromException( + cause, + i18n.str`Continuing the payment with this wallet failed.`, + ), + ); + setState(returnState); + } finally { + reclaimingRef.current = false; + setReclaiming(false); + } + }; + const keepPaymentHere = () => { + if (!unclaiming || handoffTarget.current === "current") return; + handoffTarget.current = "current"; + setReclaiming(true); + void unclaimRequest.cancel().catch((cause) => { + setError( + errorFromException( + cause, + i18n.str`Cancelling the payment handoff failed.`, + ), + ); + }); + }; + const wantsCurrentWallet = () => handoffTarget.current === "current"; const unclaim = async () => { if (unclaiming) return; + handoffTarget.current = "other"; setUnclaiming(true); setError(undefined); + let reclaimAfterward = false; try { - const result = await callMutation( + const result = await unclaimRequest.callAndInvalidate( WalletApiOperation.UnclaimPayment, { transactionId: props.transaction.transactionId }, ["transactions"], ); - if (Result.isError(result)) { + if (wantsCurrentWallet()) { + reclaimAfterward = true; + } else if (Result.isError(result)) { setError( walletCoreError( result.detail, i18n.str`The payment could not be handed off to another wallet.`, ), ); - setState("error"); + setState("ready"); return; + } else { + setTalerPayUri(result.value.talerPayUri); + setState("handoff"); } - setTalerPayUri(result.value.talerPayUri); - setState("handoff"); } catch (cause) { - setError(errorFromException(cause, i18n.str`Payment handoff failed`)); - setState("error"); + if (wantsCurrentWallet()) { + reclaimAfterward = true; + } else if (!isProgressRequestCancelled(cause)) { + setError(errorFromException(cause, i18n.str`Payment handoff failed`)); + setState("ready"); + } } finally { setUnclaiming(false); } + if (reclaimAfterward) await reclaim(); }; if (choicesQuery.isLoading || !review) return ( @@ -4334,6 +4444,7 @@ function PaymentDialog(props: { cancelling={cancelling} resuming={resuming} unclaiming={unclaiming} + reclaiming={reclaiming} onSelectChoice={setSelectedChoice} onToggleDonau={setUseDonau} onConfigureDonau={(baseUrl) => @@ -4344,6 +4455,8 @@ function PaymentDialog(props: { onConfirm={(collect) => void confirm(activeChoice, collect)} onResume={() => void resume()} onUnclaim={() => void unclaim()} + onKeepHere={keepPaymentHere} + onReclaim={() => void reclaim()} onWithdraw={() => navigate("/withdraw")} onCancel={ state === "handoff" @@ -4408,6 +4521,9 @@ function TransactionDetailRoute() { const [, params] = useRoute("/transaction/:transactionId"); const [, navigate] = useLocation(); const callMutation = useWalletMutation(connection); + const { controller: unclaimRequest } = useWalletProgress(connection, { + visible: false, + }); const durability = useDurability(); const transactionId = (params?.transactionId ?? "") as TransactionIdStr; const query = useWalletQuery( @@ -4441,7 +4557,10 @@ function TransactionDetailRoute() { const [useDonau, setUseDonau] = useState(false); const [confirmingPayment, setConfirmingPayment] = useState(false); const [unclaimingPayment, setUnclaimingPayment] = useState(false); + const [reclaimingPayment, setReclaimingPayment] = useState(false); const [localHandoffUri, setLocalHandoffUri] = useState<string>(); + const handoffTarget = useRef<"other" | "current">("other"); + const reclaimingPaymentRef = useRef(false); useEffect(() => { setWorkingAction(undefined); setError(undefined); @@ -4451,7 +4570,10 @@ function TransactionDetailRoute() { setUseDonau(false); setConfirmingPayment(false); setUnclaimingPayment(false); + setReclaimingPayment(false); setLocalHandoffUri(undefined); + handoffTarget.current = "other"; + reclaimingPaymentRef.current = false; }, [transactionId]); const transaction = useMemo( () => @@ -4523,18 +4645,71 @@ function TransactionDetailRoute() { setConfirmingPayment(false); } }; + const reclaimPayment = async () => { + if (reclaimingPaymentRef.current) return; + reclaimingPaymentRef.current = true; + setReclaimingPayment(true); + setError(undefined); + setMessage(undefined); + try { + const result = await callMutation( + WalletApiOperation.ReclaimPayment, + { transactionId }, + ["transactions"], + ); + if (Result.isError(result)) { + setError( + walletCoreError( + result.detail, + i18n.str`The payment could not continue with this wallet.`, + ), + ); + return; + } + setLocalHandoffUri(undefined); + setMessage(i18n.str`The payment is continuing with this wallet.`); + } catch (cause) { + setError( + errorFromException( + cause, + i18n.str`Continuing the payment with this wallet failed.`, + ), + ); + } finally { + reclaimingPaymentRef.current = false; + setReclaimingPayment(false); + } + }; + const keepPaymentHere = () => { + if (!unclaimingPayment || handoffTarget.current === "current") return; + handoffTarget.current = "current"; + setReclaimingPayment(true); + void unclaimRequest.cancel().catch((cause) => { + setError( + errorFromException( + cause, + i18n.str`Cancelling the payment handoff failed.`, + ), + ); + }); + }; + const wantsCurrentWallet = () => handoffTarget.current === "current"; const unclaimPayment = async () => { if (!paymentDialog || unclaimingPayment) return; + handoffTarget.current = "other"; setUnclaimingPayment(true); setError(undefined); setMessage(undefined); + let reclaimAfterward = false; try { - const result = await callMutation( + const result = await unclaimRequest.callAndInvalidate( WalletApiOperation.UnclaimPayment, { transactionId }, ["transactions"], ); - if (Result.isError(result)) { + if (wantsCurrentWallet()) { + reclaimAfterward = true; + } else if (Result.isError(result)) { setError( walletCoreError( result.detail, @@ -4542,13 +4717,19 @@ function TransactionDetailRoute() { ), ); return; + } else { + setLocalHandoffUri(result.value.talerPayUri); } - setLocalHandoffUri(result.value.talerPayUri); } catch (cause) { - setError(errorFromException(cause, i18n.str`Payment handoff failed`)); + if (wantsCurrentWallet()) { + reclaimAfterward = true; + } else if (!isProgressRequestCancelled(cause)) { + setError(errorFromException(cause, i18n.str`Payment handoff failed`)); + } } finally { setUnclaimingPayment(false); } + if (reclaimAfterward) await reclaimPayment(); }; const runAction = async (action: TransactionUiAction) => { if ( @@ -4677,6 +4858,7 @@ function TransactionDetailRoute() { ? { state: "handoff", talerPayUri: query.data.unclaimedPayUri ?? localHandoffUri!, + reclaiming: reclaimingPayment, } : paymentDialog ? paymentChoicesQuery.error @@ -4702,12 +4884,15 @@ function TransactionDetailRoute() { selectedChoice: activePaymentChoice, useDonau, unclaiming: unclaimingPayment, + reclaiming: reclaimingPayment, } : undefined } onSelectPaymentChoice={setSelectedPaymentChoice} onConfirmPayment={(collect) => void confirmPayment(collect)} onUnclaimPayment={paymentDialog ? () => void unclaimPayment() : undefined} + onKeepPaymentHere={keepPaymentHere} + onReclaimPayment={() => void reclaimPayment()} onToggleDonau={setUseDonau} onConfigureDonau={(baseUrl) => navigate( diff --git a/packages/wallet-webui/src/screens/PaymentScreen.tsx b/packages/wallet-webui/src/screens/PaymentScreen.tsx @@ -168,6 +168,7 @@ export function PaymentOptions(props: { useDonau?: boolean; cancelling?: boolean; unclaiming?: boolean; + reclaiming?: boolean; onSelectChoice: (index: number) => void; onConfirm: (useDonau?: boolean) => void; onToggleDonau?: (enabled: boolean) => void; @@ -175,6 +176,7 @@ export function PaymentOptions(props: { onWithdraw: () => void; onCancel?: () => void; onUnclaim?: () => void; + onKeepHere?: () => void; }) { const selected = props.choices?.find( (choice) => choice.index === props.selectedChoice, @@ -306,18 +308,34 @@ export function PaymentOptions(props: { </Card> )} + {(props.unclaiming || props.reclaiming) && ( + <p role="status" class="text-sm text-secondary"> + {props.reclaiming + ? i18n.str`Continuing with this wallet…` + : i18n.str`Preparing the payment for another wallet…`} + </p> + )} <div class="flex flex-wrap justify-end gap-3 border-t border-outlineVariant pt-5"> {props.onUnclaim && ( <Button tone="secondary" - onClick={props.onUnclaim} + onClick={ + props.unclaiming && props.onKeepHere + ? props.onKeepHere + : props.onUnclaim + } disabled={ - props.state === "paying" || props.cancelling || props.unclaiming + props.state === "paying" || + props.cancelling || + props.reclaiming || + (props.unclaiming && !props.onKeepHere) } > - {props.unclaiming - ? i18n.str`Preparing handoff…` - : i18n.str`Continue with another wallet`} + {props.reclaiming + ? i18n.str`Continuing with this wallet…` + : props.unclaiming + ? i18n.str`Continue with this wallet` + : i18n.str`Continue with another wallet`} </Button> )} {props.onCancel && ( @@ -341,7 +359,8 @@ export function PaymentOptions(props: { !selected?.payable || props.state === "paying" || props.cancelling || - props.unclaiming + props.unclaiming || + props.reclaiming } > {props.state === "paying" @@ -359,6 +378,8 @@ export function PaymentOptions(props: { export function PaymentHandoff(props: { talerPayUri: string; onDone?: () => void; + onReclaim?: () => void; + reclaiming?: boolean; }) { return ( <Card class="border-primary bg-primaryContainer text-onPrimaryContainer"> @@ -377,9 +398,23 @@ export function PaymentHandoff(props: { <Button tone="secondary" onClick={() => void navigator.clipboard.writeText(props.talerPayUri)} + disabled={props.reclaiming} >{i18n.str`Copy payment link`}</Button> + {props.onReclaim && ( + <Button + tone="secondary" + disabled={props.reclaiming} + onClick={props.onReclaim} + > + {props.reclaiming + ? i18n.str`Continuing with this wallet…` + : i18n.str`Continue with this wallet`} + </Button> + )} {props.onDone && ( - <Button onClick={props.onDone}>{i18n.str`Back to wallet`}</Button> + <Button disabled={props.reclaiming} onClick={props.onDone}> + {i18n.str`Back to wallet`} + </Button> )} </div> </Card> @@ -405,6 +440,7 @@ export function PaymentScreen(props: { cancelling?: boolean; resuming?: boolean; unclaiming?: boolean; + reclaiming?: boolean; talerPayUri?: string; onSelectChoice: (index: number) => void; onConfirm: (useDonau?: boolean) => void; @@ -414,6 +450,8 @@ export function PaymentScreen(props: { onCancel: () => void; onResume?: () => void; onUnclaim?: () => void; + onKeepHere?: () => void; + onReclaim?: () => void; onOpenFulfillment: () => void; onCopyPosConfirmation?: (code: string) => void; }) { @@ -541,12 +579,14 @@ export function PaymentScreen(props: { useDonau={props.useDonau} cancelling={props.cancelling} unclaiming={props.unclaiming} + reclaiming={props.reclaiming} onSelectChoice={props.onSelectChoice} onConfirm={props.onConfirm} onToggleDonau={props.onToggleDonau} onConfigureDonau={props.onConfigureDonau} onWithdraw={props.onWithdraw} onCancel={props.onCancel} + onKeepHere={props.onKeepHere} onUnclaim={ props.onUnclaim ? () => setConfirmUnclaim(true) : undefined } @@ -558,6 +598,15 @@ export function PaymentScreen(props: { <PaymentHandoff talerPayUri={props.talerPayUri} onDone={props.onCancel} + onReclaim={props.onReclaim} + reclaiming={props.reclaiming} + /> + )} + + {props.error && props.state !== "error" && props.state !== "paused" && ( + <ErrorCard + title={i18n.str`Payment could not continue`} + error={props.error} /> )} @@ -671,7 +720,7 @@ export function PaymentScreen(props: { {confirmUnclaim && props.onUnclaim && ( <ConfirmationDialog title={i18n.str`Continue with another wallet?`} - description={i18n.str`This wallet will release the payment so another wallet can claim it. You will not be able to pay this order here afterward.`} + description={i18n.str`This wallet will release the payment so another wallet can claim it. You can continue with this wallet again until another wallet claims the order.`} cancelLabel={i18n.str`Keep payment here`} confirmLabel={i18n.str`Continue with another wallet`} working={props.unclaiming} diff --git a/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx b/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx @@ -21,13 +21,18 @@ import { export type TransactionPaymentReview = | { state: "loading" } | { state: "error"; error: ErrorPresentation } - | { state: "handoff"; talerPayUri: string } + | { + state: "handoff"; + talerPayUri: string; + reclaiming?: boolean; + } | { state: "ready" | "paying"; choices: PaymentChoiceView[]; selectedChoice?: number; useDonau?: boolean; unclaiming?: boolean; + reclaiming?: boolean; }; function amountLabel(transaction: TransactionDetailView): string { @@ -50,6 +55,8 @@ export function TransactionDetailScreen(props: { onSelectPaymentChoice?: (index: number) => void; onConfirmPayment?: (useDonau?: boolean) => void; onUnclaimPayment?: () => void; + onKeepPaymentHere?: () => void; + onReclaimPayment?: () => void; onToggleDonau?: (enabled: boolean) => void; onConfigureDonau?: (donauBaseUrl: string) => void; onWithdraw?: () => void; @@ -242,7 +249,11 @@ export function TransactionDetailScreen(props: { /> )} {props.paymentReview?.state === "handoff" && ( - <PaymentHandoff talerPayUri={props.paymentReview.talerPayUri} /> + <PaymentHandoff + talerPayUri={props.paymentReview.talerPayUri} + reclaiming={props.paymentReview.reclaiming} + onReclaim={props.onReclaimPayment} + /> )} {props.paymentReview && (props.paymentReview.state === "ready" || @@ -253,11 +264,13 @@ export function TransactionDetailScreen(props: { selectedChoice={props.paymentReview.selectedChoice} useDonau={props.paymentReview.useDonau} unclaiming={props.paymentReview.unclaiming} + reclaiming={props.paymentReview.reclaiming} onSelectChoice={(index) => props.onSelectPaymentChoice?.(index)} onConfirm={(collect) => props.onConfirmPayment?.(collect)} onToggleDonau={props.onToggleDonau} onConfigureDonau={props.onConfigureDonau} onWithdraw={() => props.onWithdraw?.()} + onKeepHere={props.onKeepPaymentHere} onUnclaim={ props.onUnclaimPayment ? () => setConfirmUnclaim(true) : undefined }