taler-typescript-core

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

commit b42525e997feeb90e9f80e21020edf00acaa0ea3
parent c77b2b130866d395b05bd428e09a6316f7d6cf79
Author: Florian Dold <dold@taler.net>
Date:   Mon,  7 Sep 2026 13:48:58 +0200

wallet-core: stop reusing expired digital purchases

Honor max_pickup_time when selecting prior purchases and reopening
repurchase links. Cap wallet-created Paivana sessions at the remaining
paid access period, and require prior purchases to cover the expiration
bound into externally created sessions.

Issue: https://bugs.taler.net/n/11443

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-paivana-repurchase.ts | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/taler-harness/src/integrationtests/test-payment-template.ts | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-repurchase.ts | 50++++++++++++++++++++++++++++++++++++++++++++++----
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 6+++++-
Mpackages/taler-util/src/types-taler-merchant.ts | 12++++++++++--
Mpackages/taler-wallet-core/src/pay-merchant.ts | 71++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/taler-wallet-core/src/pay-paivana-common.ts | 23++++++++++++++++++-----
Mpackages/taler-wallet-core/src/pay-paivana.ts | 20++++++++++++++++++--
Apackages/taler-wallet-core/src/pay-repurchase.test.ts | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/pay-repurchase.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 685 insertions(+), 60 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-paivana-repurchase.ts b/packages/taler-harness/src/integrationtests/test-paivana-repurchase.ts @@ -19,6 +19,9 @@ */ import { Logger, + succeedOrThrow, + TalerMerchantInstanceHttpClient, + TemplateType, TransactionMajorState, TransactionMinorState, TransactionType, @@ -43,10 +46,16 @@ export const logger = new Logger("test-paivana.ts"); export async function runPaivanaRepurchaseTest(t: GlobalTestState) { // Set up test environment - const { walletClient, bankClient, exchange, paivana } = - await createSimpleTestkudosEnvironmentV3(t, undefined, { - paivanaWebsite: ".*.html", // block all html pages - }); + const { + walletClient, + bankClient, + exchange, + paivana, + merchant, + merchantAdminAccessToken, + } = await createSimpleTestkudosEnvironmentV3(t, undefined, { + paivanaWebsite: ".*.html", // block all html pages + }); const withdrawalRes = await withdrawViaBankV3(t, { walletClient, @@ -57,10 +66,27 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { await withdrawalRes.withdrawalFinishedCond; + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + succeedOrThrow( + await merchantClient.updateTemplate(merchantAdminAccessToken, "paivana", { + template_description: "finite article access", + template_contract: { + template_type: TemplateType.PAIVANA, + choices: [{ amount: "TESTKUDOS:1" }], + website_regex: ".*.html", + summary: "finite article access", + max_pickup_duration: { d_us: 30_000_000 }, + }, + }), + ); + let accessDeadline = 0; const website = `${paivana.baseUrl}index.html`; let originalPaymentId: string | undefined; - for (let iteration = 0; iteration < 3; iteration++) { + for (let iteration = 0; iteration < 4; iteration++) { + const reuse = iteration % 2 === 1; logger.info("1) access denied, preparing Paivana payment"); { @@ -70,16 +96,15 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { ); await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: templateStatus.transactionId, - txState: - iteration === 0 - ? { - major: TransactionMajorState.Dialog, - minor: TransactionMinorState.Proposed, - } - : { - major: TransactionMajorState.Failed, - minor: TransactionMinorState.Repurchase, - }, + txState: !reuse + ? { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + } + : { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.Repurchase, + }, }); const txDet = await walletClient.call( @@ -91,12 +116,16 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { ); t.assertDeepEqual(txDet.type, TransactionType.Payment); - if (iteration > 0) { + if (reuse) { t.assertDeepEqual(txDet.txState.major, TransactionMajorState.Failed); const repurchaseTxId = txDet.repurchaseTransactionId; t.assertTrue(repurchaseTxId != null); t.assertDeepEqual(repurchaseTxId, originalPaymentId); + t.assertDeepEqual( + templateStatus.paivana.expiration.t_s, + accessDeadline, + ); await walletClient.call( WalletApiOperation.TestingWaitTransactionState, @@ -108,7 +137,14 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { }, ); } else { + t.assertTrue(txDet.transactionId !== originalPaymentId); originalPaymentId = txDet.transactionId; + t.assertTrue(txDet.contractTerms != null); + const deadline = txDet.contractTerms.max_pickup_time?.t_s; + t.assertTrue(typeof deadline === "number"); + accessDeadline = deadline as number; + t.assertTrue(accessDeadline > Date.now() / 1000); + t.assertTrue(accessDeadline <= Date.now() / 1000 + 31); await walletClient.call(WalletApiOperation.ConfirmPay, { transactionId: txDet.transactionId, choiceIndex: 0, @@ -141,8 +177,16 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { t.assertTrue((await protectedResponse.bytes()).byteLength > 0); } - await waitMs(600); + // Cross a protocol timestamp second before revisiting, then cross the + // paid access deadline before buying again. Paivana uses real wall time. + await waitMs( + iteration === 1 + ? Math.max(0, accessDeadline * 1000 - Date.now()) + 1000 + : 1100, + ); } } runPaivanaRepurchaseTest.suites = ["wallet"]; + +runPaivanaRepurchaseTest.timeoutMs = 120_000; diff --git a/packages/taler-harness/src/integrationtests/test-payment-template.ts b/packages/taler-harness/src/integrationtests/test-payment-template.ts @@ -23,6 +23,8 @@ import { Duration, TalerMerchantInstanceHttpClient, TemplateType, + TalerMerchantApi, + TalerProtocolTimestamp, TransactionMajorState, TransactionMinorState, j2s, @@ -197,3 +199,89 @@ export async function runPaymentTemplateTest(t: GlobalTestState) { } runPaymentTemplateTest.suites = ["wallet"]; + +/** Check that every template kind puts its access deadline in the contract. */ +export async function runTemplatePickupTimeTest(t: GlobalTestState) { + const { merchant, merchantAdminAccessToken } = + await createSimpleTestkudosEnvironmentV3(t); + const api = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + succeedOrThrow( + await api.addProduct(merchantAdminAccessToken, { + product_id: "article", + description: "Article", + price: "TESTKUDOS:1", + unit: "piece", + total_stock: -1, + }), + ); + for (const kind of [ + TemplateType.FIXED_ORDER, + TemplateType.PAIVANA, + TemplateType.INVENTORY_CART, + ]) { + for (const duration of [undefined, "forever", 0, 60_000_000] as const) { + const id = `pickup-${kind}-${duration ?? "omitted"}`; + const common = { + summary: "Article", + ...(duration === undefined + ? {} + : { max_pickup_duration: { d_us: duration } }), + }; + const template: TalerMerchantApi.TemplateContractDetails = + kind === TemplateType.FIXED_ORDER + ? { ...common, template_type: kind, amount: "TESTKUDOS:1" } + : kind === TemplateType.PAIVANA + ? { + ...common, + template_type: kind, + choices: [{ amount: "TESTKUDOS:1" }], + } + : { ...common, template_type: kind, selected_all: true }; + succeedOrThrow( + await api.addTemplate(merchantAdminAccessToken, { + template_id: id, + template_description: id, + template_contract: template, + }), + ); + const before = Date.now() / 1000; + const order = succeedOrThrow( + await api.useTemplateCreateOrder( + id, + kind === TemplateType.PAIVANA + ? { + template_type: kind, + website: "https://example.com/article", + paivana_id: `${Math.floor(before) + 60}-${"A".repeat(43)}`, + } + : kind === TemplateType.INVENTORY_CART + ? { + template_type: kind, + inventory_selection: [ + { product_id: "article", quantity: "1" }, + ], + } + : { template_type: kind }, + ), + ); + const after = Date.now() / 1000; + const details = succeedOrThrow( + await api.getOrderDetails(merchantAdminAccessToken, order.order_id), + ); + t.assertTrue(details.order_status === "unpaid"); + const deadline: TalerProtocolTimestamp | undefined = + details.proto_contract_terms?.max_pickup_time; + if (typeof duration === "number") { + t.assertTrue(typeof deadline?.t_s === "number"); + const seconds = deadline!.t_s as number; + t.assertTrue(seconds >= Math.floor(before) + duration / 1_000_000); + t.assertTrue(seconds <= Math.ceil(after) + duration / 1_000_000); + } else { + t.assertTrue(deadline === undefined); + } + } + } +} +runTemplatePickupTimeTest.suites = ["merchant"]; diff --git a/packages/taler-harness/src/integrationtests/test-repurchase.ts b/packages/taler-harness/src/integrationtests/test-repurchase.ts @@ -32,7 +32,7 @@ import { createSimpleTestkudosEnvironmentV3, withdrawViaBankV3, } from "../harness/environments.js"; -import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; +import { GlobalTestState, harnessHttpLib, waitMs } from "../harness/harness.js"; /** * Check repurchase detection for legacy and version 1 contracts. @@ -50,7 +50,7 @@ export async function runRepurchaseTest(t: GlobalTestState) { walletClient, bankClient, exchange, - amount: "TESTKUDOS:20", + amount: "TESTKUDOS:30", }); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); @@ -92,9 +92,13 @@ export async function runRepurchaseTest(t: GlobalTestState) { const sessionOne = `repurchase-v${version}-session-1`; const sessionTwo = `repurchase-v${version}-session-2`; + const pickupDeadline = Math.floor(Date.now() / 1000) + 15; const orderOneResp = succeedOrThrow( await merchantClient.createOrder(merchantAdminAccessToken, { - order: makeOrder(version, fulfillmentUrl), + order: { + ...makeOrder(version, fulfillmentUrl), + max_pickup_time: { t_s: pickupDeadline }, + }, }), ); const orderOneStatus = succeedOrThrow( @@ -212,6 +216,41 @@ export async function runRepurchaseTest(t: GlobalTestState) { 3, ); + // An existing repurchase link must not keep replaying the expired + // original. Its still-valid unpaid order can now be paid explicitly. + await waitMs(Math.max(0, pickupDeadline * 1000 - Date.now()) + 1000); + const reopened = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { + talerPayUri: orderThreeStatus.taler_pay_uri, + }, + ); + t.assertDeepEqual( + reopened.transactionId, + preparePayThreeResult.transactionId, + ); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: reopened.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: version === 0 ? TransactionMinorState.Proposed : "*", + }, + }); + const reopenedDetails = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: reopened.transactionId }, + ); + t.assertTrue(reopenedDetails.type === TransactionType.Payment); + t.assertTrue(reopenedDetails.repurchaseTransactionId === undefined); + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: reopened.transactionId, + choiceIndex: 0, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: reopened.transactionId, + txState: { major: TransactionMajorState.Done }, + }); + await walletClient.call(WalletApiOperation.DeleteTransaction, { transactionId: preparePayOneResult.transactionId, }); @@ -223,8 +262,11 @@ export async function runRepurchaseTest(t: GlobalTestState) { txnsAfterDeletion.transactions.filter( (x) => x.type === TransactionType.Payment, ).length, - 0, + 1, ); + await walletClient.call(WalletApiOperation.DeleteTransaction, { + transactionId: reopened.transactionId, + }); }); } diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -157,7 +157,10 @@ import { runPaymentForgettableTest } from "./test-payment-forgettable.js"; import { runPaymentMultipleTest } from "./test-payment-multiple.js"; import { runPaymentOrderGoneTest } from "./test-payment-order-gone.js"; import { runPaymentUnclaimTest } from "./test-payment-unclaim.js"; -import { runPaymentTemplateTest } from "./test-payment-template.js"; +import { + runPaymentTemplateTest, + runTemplatePickupTimeTest, +} from "./test-payment-template.js"; import { runPaymentTransientTest } from "./test-payment-transient.js"; import { runPaymentTest } from "./test-payment.js"; import { runPaywallFlowTest } from "./test-paywall-flow.js"; @@ -354,6 +357,7 @@ const allTests: TestMainFunction[] = [ runPaymentTest, runPaymentUnclaimTest, runPaymentTemplateTest, + runTemplatePickupTimeTest, runPaymentAbortTest, runPaymentTransientTest, runPayPaidTest, diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -562,8 +562,9 @@ interface MerchantContractTermsCommon { // If a non-unique fulfillment URL is used, a customer can only // buy the order once and will be redirected to a previous purchase // when trying to buy an order with the same fulfillment URL a second - // time. This is useful for digital goods that a customer only needs - // to buy once but should be able to repeatedly download. + // time, while the previous purchase's max_pickup_time permits access. + // This is useful for digital goods that a customer should be able to + // repeatedly download during the purchased access period. // // For orders where the customer is expected to be able to make // repeated purchases (for equivalent goods), the fulfillment URL @@ -577,6 +578,9 @@ interface MerchantContractTermsCommon { // Front-ends may use other means to generate a unique fulfillment URL. fulfillment_url?: string; + // Latest time the purchased resource may be accessed. Absent means unlimited. + max_pickup_time?: TalerProtocolTimestamp; + // URL where the same contract could be ordered again (if // available). Returned also at the public order endpoint // for people other than the actual buyer (hence public, @@ -869,6 +873,7 @@ const codecForMerchantContractTermsCommon = codecOptional(codecForInternationalizedString()), ) .property("nonce", codecForString()) + .property("max_pickup_time", codecOptional(codecForTimestamp)) .property("pay_deadline", codecForTimestamp) .property("refund_deadline", codecForTimestamp) .property("wire_transfer_deadline", codecForTimestamp) @@ -4071,6 +4076,9 @@ export interface OrderCommon { // Either fulfillment_url or fulfillment_message must be specified. fulfillment_url?: string; + // Latest time the purchased resource may be accessed. Absent means unlimited. + max_pickup_time?: TalerProtocolTimestamp; + // Message shown to the customer after paying for the order. // Either fulfillment_url or fulfillment_message must be specified. fulfillment_message?: string; diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -170,6 +170,8 @@ import { requireExchangeCoinUseConfirmedOrThrow, } from "./exchanges.js"; import { requireValidExchangeRefundConfirmation } from "./exchange-signatures.js"; +import { findRepurchase } from "./pay-repurchase.js"; +import { getPaivanaSessionExpiration } from "./pay-paivana-common.js"; import { instantiateTemplateRaw } from "./pay-template.js"; import { runWithMaybeProgressContext } from "./progress.js"; import { @@ -1759,29 +1761,16 @@ async function processDownloadProposal( h: contractTermsHash, contractTermsRaw: proposalResp.contract_terms, }); - const isResourceFulfillmentUrl = - fulfillmentUrl && - (fulfillmentUrl.startsWith("http://") || - fulfillmentUrl.startsWith("https://")); - let repurchase: WalletPurchase | undefined = undefined; - // Only consumed under isResourceFulfillmentUrl, which implies - // fulfillmentUrl is set. Previously this passed undefined straight to - // the index, which scans the whole store for a result that is then unused. - const otherPurchases = fulfillmentUrl - ? await tx.getPurchasesByFulfillmentUrl(fulfillmentUrl) - : []; - if (isResourceFulfillmentUrl) { - for (const otherPurchase of otherPurchases) { - if ( - otherPurchase.purchaseStatus == PurchaseStatus.Done || - otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying || - otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay - ) { - repurchase = otherPurchase; - break; - } - } - } + const repurchase = ( + await findRepurchase(tx, { + fulfillmentUrl, + sessionId: p.downloadSessionId, + merchantBaseUrl: getPaivanaSessionExpiration(p.downloadSessionId) + ? p.merchantBaseUrl + : undefined, + excludeProposalId: p.proposalId, + }) + )?.purchase; // FIXME: Adjust this to account for refunds, don't count as repurchase // if original order is refunded. @@ -2081,13 +2070,37 @@ export async function createOrReusePurchase( }); break; case PurchaseStatus.DoneRepurchaseDetected: { - // Trigger replay on *old* payment - const repurchaseId = oldProposal.repurchaseProposalId; - if (repurchaseId != null) { - await wex.runWalletDbTx(async (tx) => { - await startPayReplay(wex, tx, repurchaseId, sessionId); + await wex.runWalletDbTx(async (tx) => { + const [p, h] = await oldCtx.getRecordHandle(tx); + if ( + !p || + p.purchaseStatus !== PurchaseStatus.DoneRepurchaseDetected + ) + return; + const match = await findRepurchase(tx, { + fulfillmentUrl: p.download?.fulfillmentUrl, + sessionId, + merchantBaseUrl: getPaivanaSessionExpiration(sessionId) + ? p.merchantBaseUrl + : undefined, + excludeProposalId: p.proposalId, }); - } + p.downloadSessionId = sessionId; + p.repurchaseProposalId = match?.purchase.proposalId; + if (match) { + await startPayReplay( + wex, + tx, + match.purchase.proposalId, + sessionId, + ); + } else { + p.purchaseStatus = isSharedPurchase(p) + ? PurchaseStatus.DialogShared + : PurchaseStatus.DialogProposed; + } + await h.update(p, "recheck-repurchase", BalanceEffect.None); + }); break; } } diff --git a/packages/taler-wallet-core/src/pay-paivana-common.ts b/packages/taler-wallet-core/src/pay-paivana-common.ts @@ -54,10 +54,23 @@ export function isValidPaivanaSessionId( sessionId: string, now: TalerProtocolTimestamp = TalerProtocolTimestamp.now(), ): boolean { - const match = /^([0-9]+)-[A-Za-z0-9_-]{43}$/.exec(sessionId); - if (!match || now.t_s === "never") { - return false; - } + const expiration = getPaivanaSessionExpiration(sessionId); + return ( + expiration !== undefined && + now.t_s !== "never" && + expiration.t_s !== "never" && + expiration.t_s >= now.t_s + ); +} + +/** Read the public expiration prefix without changing the session's binding. */ +export function getPaivanaSessionExpiration( + sessionId: string | undefined, +): TalerProtocolTimestamp | undefined { + const match = /^([0-9]+)-[A-Za-z0-9_-]{43}$/.exec(sessionId ?? ""); + if (!match) return undefined; const expiration = Number(match[1]); - return Number.isSafeInteger(expiration) && expiration >= now.t_s; + return Number.isSafeInteger(expiration) + ? TalerProtocolTimestamp.fromSeconds(expiration) + : undefined; } diff --git a/packages/taler-wallet-core/src/pay-paivana.ts b/packages/taler-wallet-core/src/pay-paivana.ts @@ -39,6 +39,7 @@ import { computePayMerchantTransactionState, createOrReusePurchase, } from "./pay-merchant.js"; +import { findRepurchase } from "./pay-repurchase.js"; import { instantiateTemplateRaw } from "./pay-template.js"; import { runWithMaybeProgressContext, @@ -203,8 +204,23 @@ export async function preparePaivanaTemplate( ); requirePaivanaTemplate(templateInfo); - const expiration = getPaivanaExpiration( - templateInfo.template_contract.max_pickup_duration, + const now = TalerProtocolTimestamp.now(); + const match = await wex.runWalletDbTx((tx) => + findRepurchase( + tx, + { + fulfillmentUrl: url, + merchantBaseUrl: parsed.merchantBaseUrl, + }, + now, + ), + ); + const expiration = TalerProtocolTimestamp.min( + getPaivanaExpiration( + templateInfo.template_contract.max_pickup_duration, + now, + ), + match?.maxPickupTime ?? TalerProtocolTimestamp.never(), ); const nonceBytes = getRandomBytes(16); const redemption: PaivanaRedemption = { diff --git a/packages/taler-wallet-core/src/pay-repurchase.test.ts b/packages/taler-wallet-core/src/pay-repurchase.test.ts @@ -0,0 +1,295 @@ +/* + 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. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + CancellationToken, + codecForMerchantContractTerms, + decodeCrock, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import { PurchaseStatus, WalletPurchase } from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; +import { runnerFactories } from "./db/testing/runners.js"; +import { createOrReusePurchase } from "./pay-merchant.js"; +import { makePaivanaSessionId, preparePaivanaTemplate } from "./pay-paivana.js"; +import { findRepurchase } from "./pay-repurchase.js"; +import { WalletExecutionContext } from "./wallet.js"; + +const url = "https://example.com/article"; +const merchant = "https://merchant.example/"; +const session = (expiration: number) => `${expiration}-${"A".repeat(43)}`; + +function contract(deadline?: number | "never", version: 0 | 1 = 0) { + return { + version, + ...(version === 0 + ? { amount: "TESTKUDOS:1", max_fee: "TESTKUDOS:0" } + : { + token_families: {}, + choices: [ + { + amount: "TESTKUDOS:1", + max_fee: "TESTKUDOS:0", + inputs: [], + outputs: [], + }, + ], + }), + nonce: "nonce", + h_wire: "wire", + exchanges: [], + fulfillment_url: url, + merchant_pub: "pub", + merchant: { name: "Shop" }, + order_id: "order", + pay_deadline: { t_s: 9999999999 }, + wire_transfer_deadline: { t_s: 9999999999 }, + merchant_base_url: merchant, + refund_deadline: { t_s: 1 }, + summary: "Article", + timestamp: { t_s: 1 }, + wire_method: "iban", + ...(deadline === undefined ? {} : { max_pickup_time: { t_s: deadline } }), + }; +} + +function purchase(id: string, status = PurchaseStatus.Done): WalletPurchase { + return { + proposalId: id, + orderId: id, + merchantBaseUrl: merchant, + purchaseStatus: status, + noncePriv: "private", + noncePub: "public", + claimToken: undefined, + timestamp: 1, + downloadSessionId: "session", + shared: false, + createdFromShared: false, + download: { + contractTermsHash: id, + contractTermsMerchantSig: "sig", + currency: "TESTKUDOS", + fulfillmentUrl: url, + }, + } as WalletPurchase; +} + +function selectionTx( + entries: [WalletPurchase, ReturnType<typeof contract>][], +): WalletDbTransaction { + return { + getPurchasesByFulfillmentUrl: async () => entries.map(([p]) => p), + getContractTerms: async (id: string) => { + const entry = entries.find(([p]) => p.proposalId === id); + return entry && { h: id, contractTermsRaw: entry[1] }; + }, + } as unknown as WalletDbTransaction; +} + +for (const version of [0, 1] as const) { + test(`v${version} contracts decode optional pickup deadlines`, () => { + for (const deadline of [undefined, 100, "never"] as const) { + const decoded = codecForMerchantContractTerms().decode( + contract(deadline, version), + ); + assert.deepEqual( + decoded.max_pickup_time, + deadline === undefined ? undefined : { t_s: deadline }, + ); + } + assert.throws(() => + codecForMerchantContractTerms().decode({ + ...contract(100, version), + max_pickup_time: "invalid", + }), + ); + }); + test(`v${version} repurchase expires exactly at the pickup deadline`, async () => { + const tx = selectionTx([[purchase("old"), contract(100, version)]]); + for (const now of [99, 100, 101]) { + const found = await findRepurchase( + tx, + { fulfillmentUrl: url }, + { t_s: now }, + ); + assert.equal(found?.purchase.proposalId, now < 100 ? "old" : undefined); + } + }); +} + +test("selection skips expired purchases and prefers the longest eligible access", async () => { + const tx = selectionTx([ + [purchase("expired"), contract(10)], + [purchase("short"), contract(120)], + [purchase("long", PurchaseStatus.PendingPayingReplay), contract(150)], + [purchase("unpaid", PurchaseStatus.DialogProposed), contract()], + ]); + assert.equal( + (await findRepurchase(tx, { fulfillmentUrl: url }, { t_s: 100 }))?.purchase + .proposalId, + "long", + ); + for (const deadline of [undefined, "never"] as const) { + assert.equal( + ( + await findRepurchase( + selectionTx([[purchase("unlimited"), contract(deadline)]]), + { fulfillmentUrl: url }, + { t_s: 100 }, + ) + )?.purchase.proposalId, + "unlimited", + ); + } +}); + +test("Paivana reuse requires coverage of the bound expiration and the advertised merchant", async () => { + const tx = selectionTx([[purchase("old"), contract(120)]]); + for (const expiration of [99, 100, 120, 121]) { + const found = await findRepurchase( + tx, + { + fulfillmentUrl: url, + sessionId: session(expiration), + merchantBaseUrl: merchant, + }, + { t_s: 100 }, + ); + assert.equal( + found?.purchase.proposalId, + expiration === 120 ? "old" : undefined, + ); + } + assert.equal( + await findRepurchase( + tx, + { fulfillmentUrl: url, merchantBaseUrl: "https://other.example/" }, + { t_s: 100 }, + ), + undefined, + ); + assert.equal( + await findRepurchase(tx, { fulfillmentUrl: undefined }, { t_s: 100 }), + undefined, + ); + assert.equal( + await findRepurchase( + tx, + { fulfillmentUrl: "taler://fulfillment/abc" }, + { t_s: 100 }, + ), + undefined, + ); +}); + +test("wallet-generated Paivana sessions bind the remaining access period", async () => { + const now = TalerProtocolTimestamp.now().t_s as number; + const deadline = now + 60; + const tx = selectionTx([[purchase("old"), contract(deadline)]]); + const wex = { + cancellationToken: CancellationToken.CONTINUE, + runWalletDbTx: (f: (tx: WalletDbTransaction) => Promise<unknown>) => f(tx), + http: { + fetch: async (requestUrl: string) => ({ + status: requestUrl === url ? 402 : 200, + requestUrl, + requestMethod: "GET", + headers: { get: () => "taler://pay-template/merchant.example/paivana" }, + json: async () => ({ + template_contract: { + template_type: "paivana", + choices: [{ amount: "TESTKUDOS:1" }], + max_pickup_duration: { d_us: 3600_000_000 }, + }, + }), + }), + }, + } as unknown as WalletExecutionContext; + const prepared = await preparePaivanaTemplate(wex, { url }); + assert.deepEqual(prepared.redemption.expiration, { t_s: deadline }); + const expected = makePaivanaSessionId( + prepared.redemption.expiration, + decodeCrock(prepared.redemption.nonce), + url, + ); + assert.equal( + new URL(prepared.talerPayTemplateUri).searchParams.get("session_id"), + expected, + ); +}); + +for (const factory of runnerFactories) { + test(`${factory.name}: reopening a cached repurchase after expiry permits a new confirmation`, async () => { + const runner = await factory(); + try { + const original = purchase("original"); + const cached = purchase("cached", PurchaseStatus.DoneRepurchaseDetected); + cached.repurchaseProposalId = original.proposalId; + await runner.runReadWriteTx(async (tx) => { + await tx.upsertPurchase(original); + await tx.upsertPurchase(cached); + await tx.upsertContractTerms({ + h: original.proposalId, + contractTermsRaw: contract(1), + }); + await tx.upsertContractTerms({ + h: cached.proposalId, + contractTermsRaw: contract(), + }); + }); + let replays = 0; + const wex = { + ws: {}, + runWalletDbTx: (f: (tx: WalletDbTransaction) => Promise<unknown>) => + runner.runReadWriteTx(f), + taskScheduler: { + resetTask: async () => { + replays++; + }, + }, + } as unknown as WalletExecutionContext; + const result = await createOrReusePurchase( + wex, + merchant, + cached.orderId, + "new-session", + undefined, + undefined, + undefined, + ); + assert.equal(result.proposalId, cached.proposalId); + const updated = await runner.runReadWriteTx((tx) => + tx.getPurchase(cached.proposalId), + ); + assert.equal(updated?.purchaseStatus, PurchaseStatus.DialogProposed); + assert.equal(updated?.repurchaseProposalId, undefined); + assert.equal(replays, 0); + assert.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getPurchase(original.proposalId), + ) + )?.purchaseStatus, + PurchaseStatus.Done, + ); + } finally { + await runner.close(); + } + }); +} diff --git a/packages/taler-wallet-core/src/pay-repurchase.ts b/packages/taler-wallet-core/src/pay-repurchase.ts @@ -0,0 +1,102 @@ +/* + 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. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + canonicalizeMerchantInstanceUrl, + codecForMerchantContractTerms, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import { PurchaseStatus, WalletPurchase } from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; +import { getPaivanaSessionExpiration } from "./pay-paivana-common.js"; + +/** Find a purchase that still grants access, preferring the longest access. */ +export async function findRepurchase( + tx: WalletDbTransaction, + request: { + fulfillmentUrl: string | undefined; + merchantBaseUrl?: string; + sessionId?: string; + excludeProposalId?: string; + }, + now: TalerProtocolTimestamp = TalerProtocolTimestamp.now(), +): Promise< + | { purchase: WalletPurchase; maxPickupTime: TalerProtocolTimestamp } + | undefined +> { + const url = request.fulfillmentUrl; + if ( + !url || + !(url.startsWith("http://") || url.startsWith("https://")) || + now.t_s === "never" + ) { + return undefined; + } + const requestedExpiration = getPaivanaSessionExpiration(request.sessionId); + if ( + requestedExpiration?.t_s !== undefined && + requestedExpiration.t_s !== "never" && + requestedExpiration.t_s <= now.t_s + ) { + return undefined; + } + let best: + | { purchase: WalletPurchase; maxPickupTime: TalerProtocolTimestamp } + | undefined; + for (const purchase of await tx.getPurchasesByFulfillmentUrl(url)) { + if (purchase.proposalId === request.excludeProposalId || !purchase.download) + continue; + if ( + purchase.purchaseStatus !== PurchaseStatus.Done && + purchase.purchaseStatus !== PurchaseStatus.PendingPaying && + purchase.purchaseStatus !== PurchaseStatus.PendingPayingReplay + ) + continue; + // Paivana redeems order IDs at the merchant advertised by its paywall. + if ( + request.merchantBaseUrl && + canonicalizeMerchantInstanceUrl(purchase.merchantBaseUrl) !== + canonicalizeMerchantInstanceUrl(request.merchantBaseUrl) + ) + continue; + const stored = await tx.getContractTerms( + purchase.download.contractTermsHash, + ); + if (!stored) continue; + const contract = codecForMerchantContractTerms().decode( + stored.contractTermsRaw, + ); + const maxPickupTime = + contract.max_pickup_time ?? TalerProtocolTimestamp.never(); + if ( + maxPickupTime.t_s !== "never" && + (maxPickupTime.t_s <= now.t_s || + (requestedExpiration && + (requestedExpiration.t_s === "never" || + requestedExpiration.t_s > maxPickupTime.t_s))) + ) + continue; + if ( + !best || + (best.maxPickupTime.t_s !== "never" && + (maxPickupTime.t_s === "never" || + maxPickupTime.t_s > best.maxPickupTime.t_s)) + ) { + best = { purchase, maxPickupTime }; + } + } + return best; +}