commit 85a355cfdd7fcab7cac2d16a142633575d584173
parent 705a110213c4b3c5a6cdf52aefc217cc3b27e741
Author: Florian Dold <florian@dold.me>
Date: Wed, 3 Jun 2026 18:22:10 +0200
wallet-core: implement new simplified preparePayFor(Uri|Template)V2 request
Diffstat:
6 files changed, 312 insertions(+), 94 deletions(-)
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts
@@ -661,8 +661,11 @@ export interface TransactionPayment extends TransactionCommon {
/**
* Additional information about the payment.
+ *
+ * Only present if the information about the
+ * order is already available.
*/
- info: OrderShortInfo;
+ info: OrderShortInfo | undefined;
/**
* Full contract terms.
@@ -722,6 +725,12 @@ export interface TransactionPayment extends TransactionCommon {
* and did the URI contain a nfc=1 flag?
*/
posConfirmationViaNfc?: boolean;
+
+ /**
+ * In case this payment transaction was detected as a repurchase,
+ * this is the transaction ID of the original payment.
+ */
+ repurchaseTransactionId?: TransactionIdStr;
}
export interface OrderShortInfo {
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -1095,6 +1095,10 @@ export const codecForPreparePayResult = (): Codec<PreparePayResult> =>
)
.build("PreparePayResult");
+export interface PreparePayV2Result {
+ transactionId: TransactionIdStr;
+}
+
/**
* Result of a prepare pay operation.
*/
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -49,6 +49,7 @@ import {
TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
+ TransactionType,
WalletNotification,
} from "@gnu-taler/taler-util";
import { clk } from "@gnu-taler/taler-util/clk";
@@ -109,53 +110,61 @@ interface PayOptions {
verbose?: number;
}
-async function doPayTemplate(
+async function doHandlePayTransaction(
wallet: WalletCoreApiClient,
- payUrl: string,
- options: PayOptions = {},
+ transactionId: TransactionIdStr,
+ options: PayOptions,
): Promise<void> {
- const result = await wallet.call(WalletApiOperation.PreparePayForTemplate, {
- talerPayTemplateUri: payUrl,
+ await wallet.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId,
+ txState: [
+ {
+ major: TransactionMajorState.Done,
+ minor: "*",
+ },
+ {
+ major: TransactionMajorState.Dialog,
+ minor: "*",
+ },
+ {
+ major: TransactionMajorState.Failed,
+ minor: "*",
+ },
+ ],
});
- console.log(`Instantiated payment template as ${result.transactionId}`);
- if (result.status === PreparePayResultType.InsufficientBalance) {
- console.log("contract", result.contractTerms);
- console.error("insufficient balance");
- processExit(1);
- return;
- }
- return doPay(wallet, result.talerUri, options);
-}
-
-async function doPay(
- wallet: WalletCoreApiClient,
- payUrl: string,
- options: PayOptions = {},
-): Promise<void> {
- const result = await wallet.call(WalletApiOperation.PreparePayForUri, {
- talerPayUri: payUrl,
+ const paySt = await wallet.call(WalletApiOperation.GetTransactionById, {
+ transactionId,
});
- if (result.status === PreparePayResultType.InsufficientBalance) {
- console.log("contract", result.contractTerms);
- console.error("insufficient balance");
- processExit(1);
+ if (paySt.type !== TransactionType.Payment) {
+ throw Error("unexpected transaction type");
+ }
+ if (paySt.txState.major === TransactionMajorState.Done) {
+ console.log(`Payment succeeded (already done).`);
return;
}
- let choiceIndex: number | undefined;
- if (result.status === PreparePayResultType.AlreadyConfirmed) {
- if (result.paid) {
- console.log(`Already paid (transactionId ${result.transactionId})`);
- } else {
- console.log(
- `Payment already in progress (transactionId ${result.transactionId})`,
- );
+ if (
+ paySt.txState.major === TransactionMajorState.Failed &&
+ paySt.txState.minor === TransactionMinorState.Repurchase
+ ) {
+ console.log(`Repurchase detected (${[paySt.repurchaseTransactionId]})`);
+ if (paySt.repurchaseTransactionId != null) {
+ console.log(`Waiting for old transaction to be final.`);
+ await wallet.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: paySt.repurchaseTransactionId,
+ txState: {
+ major: TransactionMajorState.Done,
+ minor: "*",
+ },
+ });
+ console.log(`Transaction done.`);
}
- processExit(0);
return;
- } else if (result.status === PreparePayResultType.ChoiceSelection) {
+ }
+ if (paySt.txState.major === TransactionMajorState.Dialog) {
const choices = await wallet.call(WalletApiOperation.GetChoicesForPayment, {
- transactionId: result.transactionId,
+ transactionId,
});
+ let choiceIndex: number | undefined;
if (options.choiceIndex != null) {
choiceIndex = options.choiceIndex;
} else if (choices.choices.length > 1) {
@@ -169,51 +178,69 @@ async function doPay(
}
} else {
choiceIndex = 0;
- console.log("contract:", result.contractTerms);
+ console.log("contract:", choices.contractTerms);
}
const myChoice = choices.choices[choiceIndex];
if (myChoice.status !== ChoiceSelectionDetailType.PaymentPossible) {
console.log("insufficient balance for choice");
processExit(1);
}
- } else if (result.status === "payment-possible") {
- console.log("contract:", result.contractTerms);
- } else {
- throw Error("not reached");
- }
- let doPay: boolean;
- if (options.alwaysYes) {
- doPay = true;
- } else if (options.nonInteractive) {
- console.log(`Please confirm payment by passing '--yes' to handle-uri`);
- processExit(EXIT_INPUT_REQUIRED);
- } else {
- doPay = await askYesNo();
- }
+ let doPay: boolean;
+ if (options.alwaysYes) {
+ doPay = true;
+ } else if (options.nonInteractive) {
+ console.log(`Please confirm payment by passing '--yes' to handle-uri`);
+ processExit(EXIT_INPUT_REQUIRED);
+ } else {
+ doPay = await askYesNo();
+ }
- if (doPay) {
- await wallet.call(WalletApiOperation.ConfirmPay, {
- transactionId: result.transactionId,
- choiceIndex: choiceIndex,
- useDonau: true,
+ if (doPay) {
+ await wallet.call(WalletApiOperation.ConfirmPay, {
+ transactionId,
+ choiceIndex: choiceIndex,
+ useDonau: true,
+ });
+ } else {
+ console.log("not paying");
+ }
+ if (options.noWait) {
+ return;
+ }
+ console.log(`Waiting for transaction '${transactionId}' to finish`);
+ await wallet.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
});
- } else {
- console.log("not paying");
- }
- if (options.noWait) {
- return;
+ const tx = await wallet.call(WalletApiOperation.GetTransactionById, {
+ transactionId,
+ });
+ console.log(`Finished with status '${tx.txState.major}'.`);
}
- console.log(`Waiting for transaction '${result.transactionId}' to finish`);
- await wallet.call(WalletApiOperation.TestingWaitTransactionState, {
- transactionId: result.transactionId,
- txState: {
- major: TransactionMajorState.Done,
- },
+}
+
+async function doPayTemplate(
+ wallet: WalletCoreApiClient,
+ payUrl: string,
+ options: PayOptions = {},
+): Promise<void> {
+ const r = await wallet.call(WalletApiOperation.PreparePayForTemplateV2, {
+ talerPayTemplateUri: payUrl,
});
- const tx = await wallet.call(WalletApiOperation.GetTransactionById, {
- transactionId: result.transactionId,
+ await doHandlePayTransaction(wallet, r.transactionId, options);
+}
+
+async function doPay(
+ wallet: WalletCoreApiClient,
+ payUrl: string,
+ options: PayOptions = {},
+): Promise<void> {
+ const r = await wallet.call(WalletApiOperation.PreparePayForUriV2, {
+ talerPayUri: payUrl,
});
- console.log(`Finished with status '${tx.txState.major}'.`);
+ await doHandlePayTransaction(wallet, r.transactionId, options);
}
async function askChoice(n: number): Promise<number> {
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -86,10 +86,12 @@ import {
PreparePayResult,
PreparePayResultType,
PreparePayTemplateRequest,
+ PreparePayV2Result,
randomBytes,
RefreshReason,
RefundInfoShort,
RefundPaymentInfo,
+ safeStringifyException,
ScopeInfo,
ScopeType,
SelectedProspectiveCoin,
@@ -192,6 +194,7 @@ import {
import {
EXCHANGE_COINS_LOCK,
getDenomInfo,
+ LegacyWalletTxHandle,
WalletExecutionContext,
walletMerchantClient,
} from "./wallet.js";
@@ -256,15 +259,50 @@ export class PayMerchantTransactionContext implements TransactionContext {
): Promise<Transaction | undefined> {
const proposalId = this.proposalId;
const purchaseRec = await tx.purchases.get(proposalId);
- if (!purchaseRec) throw Error("not found");
+ if (!purchaseRec) {
+ throw Error("not found");
+ }
+
+ const txState = computePayMerchantTransactionState(purchaseRec);
+ const payOpId = TaskIdentifiers.forPay(purchaseRec);
+ const payRetryRec = await tx.operationRetries.get(payOpId);
+ const unk = "UNKNOWN:0";
+ if (!purchaseRec.download) {
+ return {
+ type: TransactionType.Payment,
+ txState,
+ stId: purchaseRec.purchaseStatus,
+ scopes: [
+ {
+ type: ScopeType.Global,
+ currency: "UNKNOWN",
+ },
+ ],
+ txActions: computePayMerchantTransactionActions(purchaseRec),
+ amountRaw: unk,
+ amountEffective: unk,
+ totalRefundRaw: unk,
+ totalRefundEffective: unk,
+ refundPending: unk,
+ refunds: [],
+ posConfirmation: purchaseRec.posConfirmation,
+ timestamp: timestampPreciseFromDb(purchaseRec.timestamp),
+ transactionId: this.transactionId,
+ abortReason: purchaseRec.abortReason,
+ failReason: purchaseRec.failReason,
+ error: undefined,
+ info: undefined,
+ contractTerms: undefined,
+ refundQueryActive: false,
+ };
+ }
+
const download = await expectProposalDownloadInTx(
this.wex,
tx,
purchaseRec,
);
const contractData = download.contractTerms;
- const payOpId = TaskIdentifiers.forPay(purchaseRec);
- const payRetryRec = await tx.operationRetries.get(payOpId);
const refundsInfo = await tx.refundGroups.indexes.byProposalId.getAll(
purchaseRec.proposalId,
@@ -317,8 +355,6 @@ export class PayMerchantTransactionContext implements TransactionContext {
}),
}));
- const txState = computePayMerchantTransactionState(purchaseRec);
-
const scopes = await computePayMerchantTransactionScopesInTx(
tx,
purchaseRec,
@@ -370,6 +406,13 @@ export class PayMerchantTransactionContext implements TransactionContext {
error: payRetryRec?.lastError ? payRetryRec?.lastError : undefined,
info,
contractTerms,
+ repurchaseTransactionId:
+ purchaseRec.repurchaseProposalId == null
+ ? undefined
+ : constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: purchaseRec.repurchaseProposalId,
+ }),
refundQueryActive:
purchaseRec.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
...(payRetryRec?.lastError ? { error: payRetryRec.lastError } : {}),
@@ -1185,6 +1228,7 @@ async function processDownloadProposal(
logger.warn("repurchase detected");
p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
p.repurchaseProposalId = repurchase.proposalId;
+ await startPayReplay(wex, tx, repurchase.proposalId, p.downloadSessionId);
} else {
p.purchaseStatus = p.shared
? PurchaseStatus.DialogShared
@@ -1196,6 +1240,27 @@ async function processDownloadProposal(
return TaskRunResult.progress();
}
+async function startPayReplay(
+ wex: WalletExecutionContext,
+ tx: LegacyWalletTxHandle,
+ proposalId: string,
+ sessionId: string | undefined,
+): Promise<void> {
+ logger.info(`starting pay replay of ${proposalId} under ${sessionId}`);
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ const [rec, h] = await ctx.getRecordHandle(tx);
+ if (rec && rec.purchaseStatus === PurchaseStatus.Done) {
+ rec.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ rec.lastSessionId = sessionId;
+ }
+ await h.update(rec);
+ tx._util.scheduleOnCommit(() => {
+ wex.taskScheduler.resetTask(ctx.taskId).catch((e) => {
+ logger.error(safeStringifyException(e));
+ });
+ });
+}
+
async function generateSlate(
wex: WalletExecutionContext,
purchase: PurchaseRecord,
@@ -1329,6 +1394,31 @@ async function createOrReusePurchase(
wex,
oldProposal.proposalId,
);
+ if (
+ (oldProposal.lastSessionId !== sessionId &&
+ oldProposal.timestampFirstSuccessfulPay != null) ||
+ oldProposal.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected
+ ) {
+ logger.info(`replaying payment with old proposal`);
+ switch (oldProposal.purchaseStatus) {
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPayingReplay:
+ case PurchaseStatus.SuspendedPayingReplay:
+ await wex.runLegacyWalletDbTx(async (tx) => {
+ await startPayReplay(wex, tx, oldProposal.proposalId, sessionId);
+ });
+ break;
+ case PurchaseStatus.DoneRepurchaseDetected:
+ // Trigger replay on *old* payment
+ const repId = oldProposal.repurchaseProposalId;
+ if (repId != null) {
+ await wex.runLegacyWalletDbTx(async (tx) => {
+ await startPayReplay(wex, tx, repId, sessionId);
+ });
+ }
+ break;
+ }
+ }
if (oldProposal.shared || oldProposal.createdFromShared) {
const download = await expectProposalDownload(wex, oldProposal);
const paid = await checkIfOrderIsAlreadyPaid(wex, download, false);
@@ -1740,6 +1830,9 @@ async function handleInsufficientFunds(
return TaskRunResult.progress();
}
+/**
+ * @deprecated only used for legacy compat
+ */
async function lookupProposalOrRepurchase(
wex: WalletExecutionContext,
proposalId: string,
@@ -1784,14 +1877,17 @@ async function lookupProposalOrRepurchase(
});
}
-// FIXME: Should take a transaction ID instead of a proposal ID
-// FIXME: Does way more than checking the payment
-// FIXME: Should return immediately.
+/**
+ * @deprecated only used for legacy compat
+ */
async function checkPaymentByProposalId(
wex: WalletExecutionContext,
proposalId: string,
sessionId?: string,
): Promise<PreparePayResult> {
+ // FIXME: Should take a transaction ID instead of a proposal ID
+ // FIXME: Does way more than checking the payment
+ // FIXME: Should return immediately.
const lookupRes = await lookupProposalOrRepurchase(wex, proposalId);
if (!lookupRes) {
throw Error(`could not get proposal ${proposalId}`);
@@ -2106,6 +2202,36 @@ export async function preparePayForUri(
);
}
+export async function preparePayForUriV2(
+ wex: WalletExecutionContext,
+ talerPayUri: string,
+): Promise<PreparePayV2Result> {
+ const uriResult = parsePayUri(talerPayUri);
+
+ if (!uriResult) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
+ {
+ talerPayUri,
+ },
+ `invalid taler://pay URI (${talerPayUri})`,
+ );
+ }
+
+ const proposalRes = await createOrReusePurchase(
+ wex,
+ uriResult.merchantBaseUrl,
+ uriResult.orderId,
+ uriResult.sessionId,
+ uriResult.claimToken,
+ uriResult.noncePriv,
+ );
+
+ return {
+ transactionId: proposalRes.transactionId,
+ };
+}
+
/**
* Wait until a proposal is at least downloaded.
*/
@@ -2212,10 +2338,15 @@ export async function checkPayForTemplate(
};
}
-export async function preparePayForTemplate(
+/**
+ * Instantiate a pay template.
+ *
+ * @returns A taler://pay/ URI pointing to the order
+ */
+export async function instantiateTemplate(
wex: WalletExecutionContext,
req: PreparePayTemplateRequest,
-): Promise<PreparePayResult> {
+): Promise<string> {
const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
if (!parsedUri) {
@@ -2290,16 +2421,30 @@ export async function preparePayForTemplate(
codecForPostOrderResponse(),
);
- const payUri = stringifyPayUri({
+ return stringifyPayUri({
merchantBaseUrl: parsedUri.merchantBaseUrl,
orderId: resp.order_id,
sessionId: parsedUri.sessionId ?? "",
claimToken: resp.token,
});
+}
+export async function preparePayForTemplate(
+ wex: WalletExecutionContext,
+ req: PreparePayTemplateRequest,
+): Promise<PreparePayResult> {
+ const payUri = await instantiateTemplate(wex, req);
return await preparePayForUri(wex, payUri);
}
+export async function preparePayForTemplateV2(
+ wex: WalletExecutionContext,
+ req: PreparePayTemplateRequest,
+): Promise<PreparePayV2Result> {
+ const payUri = await instantiateTemplate(wex, req);
+ return await preparePayForUriV2(wex, payUri);
+}
+
/**
* Generate deposit permissions for a purchase.
*
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -152,6 +152,7 @@ import {
PreparePayRequest,
PreparePayResult,
PreparePayTemplateRequest,
+ PreparePayV2Result,
PreparePeerPullDebitRequest,
PreparePeerPullDebitResponse,
PreparePeerPushCreditRequest,
@@ -279,9 +280,11 @@ export enum WalletApiOperation {
GetChoicesForPayment = "getChoicesForPayment",
PreparePayForUri = "preparePayForUri",
+ PreparePayForTemplate = "preparePayForTemplate",
+ PreparePayForUriV2 = "preparePayForUriV2",
+ PreparePayForTemplateV2 = "preparePayForTemplateV2",
SharePayment = "sharePayment",
CheckPayForTemplate = "checkPayForTemplate",
- PreparePayForTemplate = "preparePayForTemplate",
StartRefundQueryForUri = "startRefundQueryForUri",
StartRefundQuery = "startRefundQuery",
ConfirmPay = "confirmPay",
@@ -792,6 +795,33 @@ export type PreparePayForUriOp = {
};
/**
+ * Prepare to make a payment based on a taler://pay-template/ URI.
+ */
+export type PreparePayForTemplateOp = {
+ op: WalletApiOperation.PreparePayForTemplate;
+ request: PreparePayTemplateRequest;
+ response: PreparePayResult;
+};
+
+/**
+ * Prepare to make a payment based on a taler://pay/ URI.
+ */
+export type PreparePayForUriV2Op = {
+ op: WalletApiOperation.PreparePayForUriV2;
+ request: PreparePayRequest;
+ response: PreparePayV2Result;
+};
+
+/**
+ * Prepare to make a payment based on a taler://pay-template/ URI.
+ */
+export type PreparePayForTemplateV2Op = {
+ op: WalletApiOperation.PreparePayForTemplateV2;
+ request: PreparePayTemplateRequest;
+ response: PreparePayV2Result;
+};
+
+/**
* Get a list of contract v1 choices for a given payment tx
* in dialog(confirm) state, as well as additional information
* on whether they can be used to pay the order or not, depending
@@ -822,15 +852,6 @@ export type CheckPayForTemplateOp = {
};
/**
- * Prepare to make a payment based on a taler://pay-template/ URI.
- */
-export type PreparePayForTemplateOp = {
- op: WalletApiOperation.PreparePayForTemplate;
- request: PreparePayTemplateRequest;
- response: PreparePayResult;
-};
-
-/**
* Confirm a payment that was previously prepared with
* {@link PreparePayForUriOp}
*/
@@ -1610,9 +1631,11 @@ export type WalletOperations = {
[WalletApiOperation.SetWalletRunConfig]: SetWalletRunConfigOp;
[WalletApiOperation.GetVersion]: GetVersionOp;
[WalletApiOperation.PreparePayForUri]: PreparePayForUriOp;
+ [WalletApiOperation.PreparePayForTemplate]: PreparePayForTemplateOp;
+ [WalletApiOperation.PreparePayForUriV2]: PreparePayForUriV2Op;
+ [WalletApiOperation.PreparePayForTemplateV2]: PreparePayForTemplateV2Op;
[WalletApiOperation.SharePayment]: SharePaymentOp;
[WalletApiOperation.CheckPayForTemplate]: CheckPayForTemplateOp;
- [WalletApiOperation.PreparePayForTemplate]: PreparePayForTemplateOp;
[WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp;
[WalletApiOperation.GetChoicesForPayment]: GetChoicesForPaymentOp;
[WalletApiOperation.ConfirmPay]: ConfirmPayOp;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -374,7 +374,9 @@ import {
confirmPay,
getChoicesForPayment,
preparePayForTemplate,
+ preparePayForTemplateV2,
preparePayForUri,
+ preparePayForUriV2,
sharePayment,
startQueryRefund,
startRefundQueryForUri,
@@ -2402,6 +2404,14 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForPreparePayTemplateRequest(),
handler: preparePayForTemplate,
},
+ [WalletApiOperation.PreparePayForUriV2]: {
+ codec: codecForPreparePayRequest(),
+ handler: (wex, req) => preparePayForUriV2(wex, req.talerPayUri),
+ },
+ [WalletApiOperation.PreparePayForTemplateV2]: {
+ codec: codecForPreparePayTemplateRequest(),
+ handler: preparePayForTemplateV2,
+ },
[WalletApiOperation.GetQrCodesForPayto]: {
codec: codecForGetQrCodesForPaytoRequest(),
handler: handleGetQrCodesForPayto,