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