taler-typescript-core

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

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:
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 11++++++++++-
Mpackages/taler-util/src/types-taler-wallet.ts | 4++++
Mpackages/taler-wallet-cli/src/index.ts | 169++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 45++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/wallet.ts | 10++++++++++
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,