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:
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
}