commit 10402e6c45364e94642ad3b6ae88fe64132c6f0c
parent e18d39aaec82c5af8ebe095f4c13f0f0b1fa4306
Author: Iván Ávalos <avalos@disroot.org>
Date: Tue, 2 Sep 2025 13:32:28 +0200
wallet-core: add listDiscounts request (+harness)
Diffstat:
8 files changed, 555 insertions(+), 6 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tokens-discount.ts b/packages/taler-harness/src/integrationtests/test-wallet-tokens-discount.ts
@@ -0,0 +1,267 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 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/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AccessToken,
+ DiscountListDetail,
+ Duration,
+ Order,
+ OrderInputType,
+ OrderOutputType,
+ PreparePayResultType,
+ succeedOrThrow,
+ TalerMerchantInstanceHttpClient,
+ TalerProtocolTimestamp,
+ TokenFamilyDetails,
+ TokenFamilyKind,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "harness/environments.js";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState, WalletClient } from "../harness/harness.js";
+import { logger } from "./test-tops-challenger-twice.js";
+
+export async function runWalletTokensDiscountTest(t: GlobalTestState) {
+ let {
+ bankClient,
+ exchange,
+ merchant,
+ walletClient,
+ merchantAdminAccessToken,
+ } = await createSimpleTestkudosEnvironmentV3(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ walletConfig: {
+ features: {
+ enableV1Contracts: true,
+ },
+ },
+ },
+ );
+
+ const merchantApi = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ // withdraw some test money
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:40",
+ });
+ await wres.withdrawalFinishedCond;
+
+ let tokenFamilyJson = {
+ kind: TokenFamilyKind.Discount,
+ slug: "test_discount",
+ name: "Test discount",
+ description: "This is a test discount",
+ description_i18n: {},
+ valid_after: TalerProtocolTimestamp.now(),
+ valid_before: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ years: 1 }),
+ ),
+ ),
+ duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ days: 90 }),
+ ),
+ validity_granularity: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ days: 1 }),
+ ),
+ };
+
+ // setup discount token family
+ succeedOrThrow(
+ await merchantApi.createTokenFamily(
+ merchantAdminAccessToken,
+ tokenFamilyJson,
+ ),
+ );
+
+ let orderJsonDiscount: Order = {
+ version: 1,
+ summary: "Test order",
+ timestamp: TalerProtocolTimestamp.now(),
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 1 }),
+ ),
+ ),
+ choices: [
+ {
+ amount: "TESTKUDOS:2",
+ inputs: [],
+ outputs: [
+ {
+ type: OrderOutputType.Token,
+ token_family_slug: "test_discount",
+ },
+ ],
+ },
+ {
+ amount: "TESTKUDOS:1",
+ inputs: [
+ {
+ type: OrderInputType.Token,
+ token_family_slug: "test_discount",
+ },
+ ],
+ outputs: [],
+ },
+ ],
+ };
+
+ {
+ logger.info("Payment with discount token output...");
+
+ {
+ await createAndPayOrder({
+ orderJson: orderJsonDiscount,
+ choiceIndex: 0,
+ t,
+ walletClient,
+ merchantApi,
+ merchantAdminAccessToken,
+ });
+
+ const {discounts} = await walletClient.call(
+ WalletApiOperation.ListDiscounts,
+ {}
+ );
+
+ t.assertTrue(discounts.length === 1);
+ t.assertTrue(discounts[0].tokensAvailable === 1);
+ }
+
+ {
+ await createAndPayOrder({
+ orderJson: orderJsonDiscount,
+ choiceIndex: 0,
+ t,
+ walletClient,
+ merchantApi,
+ merchantAdminAccessToken,
+ });
+
+ let {discounts} = await walletClient.call(
+ WalletApiOperation.ListDiscounts,
+ {}
+ );
+
+ t.assertTrue(discounts.length === 1);
+ t.assertTrue(discounts[0].tokensAvailable === 2);
+ }
+ }
+
+ // reduce discount token family duration to 30 days
+ tokenFamilyJson.name = "Test discount, but less duration";
+
+ succeedOrThrow<TokenFamilyDetails | void>(
+ await merchantApi.updateTokenFamily(
+ merchantAdminAccessToken,
+ tokenFamilyJson.slug,
+ tokenFamilyJson,
+ ),
+ );
+
+ let d2: DiscountListDetail | undefined;
+
+ {
+ logger.info("Payment with discount token output (different name)...");
+
+ {
+ await createAndPayOrder({
+ orderJson: orderJsonDiscount,
+ choiceIndex: 0,
+ t,
+ walletClient,
+ merchantApi,
+ merchantAdminAccessToken,
+ });
+
+ const {discounts} = await walletClient.call(
+ WalletApiOperation.ListDiscounts,
+ {}
+ );
+
+ const d1 = discounts.find(d => d.name === "Test discount");
+ t.assertTrue(d1 !== undefined);
+ t.assertTrue(d1.tokensAvailable === 2);
+
+ d2 = discounts.find(d => d.name === "Test discount, but less duration");
+ t.assertTrue(d2 !== undefined);
+ t.assertTrue(d2.tokensAvailable === 1);
+ }
+ }
+}
+
+async function createAndPayOrder(req: {
+ orderJson: Order,
+ choiceIndex: number,
+ t: GlobalTestState,
+ walletClient: WalletClient,
+ merchantApi: TalerMerchantInstanceHttpClient,
+ merchantAdminAccessToken: AccessToken,
+}) {
+ const orderResp = succeedOrThrow(
+ await req.merchantApi.createOrder(req.merchantAdminAccessToken, {
+ order: req.orderJson,
+ }),
+ );
+
+ let orderStatus = succeedOrThrow(
+ await req.merchantApi.getOrderDetails(
+ req.merchantAdminAccessToken,
+ orderResp.order_id,
+ ),
+ );
+
+ req.t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+
+ const preparePayResult = await req.walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ req.t.assertTrue(
+ preparePayResult.status === PreparePayResultType.ChoiceSelection,
+ );
+
+ await req.walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ choiceIndex: req.choiceIndex,
+ });
+
+ await req.walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletTokensDiscountTest.suites = ["merchant", "wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -183,6 +183,7 @@ import { runWalletObservabilityTest } from "./test-wallet-observability.js";
import { runWalletRefreshErrorsTest } from "./test-wallet-refresh-errors.js";
import { runWalletRefreshTest } from "./test-wallet-refresh.js";
import { runWalletTokensTest } from "./test-wallet-tokens.js";
+import { runWalletTokensDiscountTest } from "./test-wallet-tokens-discount.js";
import { runWalletTransactionsTest } from "./test-wallet-transactions.js";
import { runWalletWirefeesTest } from "./test-wallet-wirefees.js";
import { runWallettestingTest } from "./test-wallettesting.js";
@@ -298,6 +299,7 @@ const allTests: TestMainFunction[] = [
runWalletBalanceZeroTest,
runWalletInsufficientBalanceTest,
runWalletTokensTest,
+ runWalletTokensDiscountTest,
runWalletWirefeesTest,
runDenomLostTest,
runWalletDenomExpireTest,
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -57,6 +57,7 @@ import {
HashCode,
TalerMerchantApi,
TemplateParams,
+ Timestamp,
WithdrawalOperationStatusFlag,
canonicalizeBaseUrl,
codecForEddsaPrivateKey,
@@ -2522,6 +2523,70 @@ export const codecForConfirmPayRequest = (): Codec<ConfirmPayRequest> =>
.property("useDonau", codecOptional(codecForBoolean()))
.build("ConfirmPay");
+export interface ListDiscountsRequest {
+ /**
+ * Filter by hash of token issue public key.
+ */
+ tokenIssuePubHash?: string;
+
+ /**
+ * Filter by merchant base URL.
+ */
+ merchantBaseUrl?: string;
+}
+
+export interface ListDiscountsResponse {
+ discounts: DiscountListDetail[];
+}
+
+export interface DiscountListDetail {
+ /**
+ * Hash of token issue public key.
+ */
+ tokenIssuePubHash: string;
+
+ /**
+ * URL of the merchant issuing the token.
+ */
+ merchantBaseUrl: string;
+
+ /**
+ * Human-readable name for the token family.
+ */
+ name: string;
+
+ /**
+ * Human-readable description for the token family.
+ */
+ description: string;
+
+ /**
+ * Optional map from IETF BCP 47 language tags to localized descriptions.
+ */
+ descriptionI18n: any | undefined;
+
+ /**
+ * Start time of the token's validity period.
+ */
+ validityStart: Timestamp;
+
+ /**
+ * End time of the token's validity period.
+ */
+ validityEnd: Timestamp;
+
+ /**
+ * Number of tokens available to use.
+ */
+ tokensAvailable: number;
+}
+
+export const codecForListDiscountsRequest = (): Codec<ListDiscountsRequest> =>
+ buildCodecForObject<ListDiscountsRequest>()
+ .property("tokenIssuePubHash", codecOptional(codecForString()))
+ .property("merchantBaseUrl", codecOptional(codecForCanonBaseUrl()))
+ .build("ListDiscounts");
+
export interface CoreApiRequestEnvelope {
id: string;
operation: string;
diff --git a/packages/taler-wallet-core/src/deleteDiscount.ts.bak b/packages/taler-wallet-core/src/deleteDiscount.ts.bak
@@ -0,0 +1,44 @@
+
+
+export async function deleteDiscount(
+ wex: WalletExecutionContext,
+ tokenIssuePubHash: string,
+ validityEnd: TalerProtocolTimestamp,
+ validityStart?: TalerProtocolTimestamp,
+): Promise<EmptyObject> {
+ await wex.db.runReadWriteTx({
+ storeNames: ["tokens"],
+ }, async (tx) => {
+ const tokens = (await tx.tokens.indexes
+ .byTokenIssuePubHash
+ .getAll(tokenIssuePubHash))
+ .filter(t => isTokenValidBetween(t,
+ validityStart ?? TalerProtocolTimestamp.now(),
+ validityEnd,
+ ));
+
+ let inUse: boolean = false;
+ for (const token of tokens) {
+ if (isTokenInUse(token)) {
+ inUse = true;
+ return;
+ }
+ }
+
+ // FIXME: proper GANA error
+ if (inUse) {
+ throw Error("One or more tokens in this family are in use");
+ }
+
+ for (const token of tokens) {
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `deleting token in ${token.tokenIssuePubHash} token family`
+ );
+ }
+ await tx.tokens.delete(token.tokenUsePub);
+ }
+ });
+
+ return {};
+}
diff --git a/packages/taler-wallet-core/src/tokenFamilies.ts b/packages/taler-wallet-core/src/tokenFamilies.ts
@@ -0,0 +1,110 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2025 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/>
+ */
+
+/**
+ * Token and token family management requests.
+ *
+ * @author Iván Ávalos
+ */
+import {
+ canonicalJson,
+ DiscountListDetail,
+ ListDiscountsResponse,
+ Logger,
+ TokenIssuePublicKey,
+} from "@gnu-taler/taler-util";
+import { WalletExecutionContext } from "./index.js";
+import { TokenRecord } from "./db.js";
+import {
+ isTokenValid,
+} from "./tokenSelection.js";
+
+const logger = new Logger("tokenFamilies.ts");
+
+// FIXME: unit test discount grouping
+function groupDiscounts(tokens: {
+ name: string,
+ description: string,
+ descriptionI18n: any | undefined,
+ merchantBaseUrl: string,
+ tokenIssuePub: TokenIssuePublicKey,
+ tokenIssuePubHash: string,
+}[]): DiscountListDetail[] {
+ const groupedIdx: number[] = [];
+ const items: DiscountListDetail[] = [];
+
+ // compare tokens against each other,
+ // except the ones already in a group.
+ for (let a = 0; a < tokens.length; a++) {
+ if (groupedIdx.includes(a)) continue;
+ const tokenA = tokens[a];
+ const item: DiscountListDetail = {
+ ...tokenA,
+ tokensAvailable: 1,
+ validityStart: tokenA.tokenIssuePub.signature_validity_start,
+ validityEnd: tokenA.tokenIssuePub.signature_validity_end,
+ };
+
+ for (let b = 0; b < tokens.length; b++) {
+ if (b === a) continue;
+ if (groupedIdx.includes(b)) continue;
+ const tokenB = tokens[b];
+ const fields = Object.keys(tokenB) as (keyof typeof tokenB)[];
+ const equal = fields.every(f =>
+ canonicalJson(tokenA[f]) === canonicalJson(tokenB[f])
+ );
+ if (equal) {
+ item.tokensAvailable += 1;
+ groupedIdx.push(b);
+ }
+ }
+
+ groupedIdx.push(a);
+ items.push(item);
+ }
+
+ return items;
+}
+
+export async function listDiscounts(
+ wex: WalletExecutionContext,
+ tokenIssuePubHash?: string,
+ merchantBaseUrl?: string,
+): Promise<ListDiscountsResponse> {
+ const tokens: TokenRecord[] = await wex.db.runReadOnlyTx({
+ storeNames: ["tokens"],
+ }, async (tx) => {
+ return (await tx.tokens.getAll())
+ .filter(t => isTokenValid(t))
+ .filter(t => !tokenIssuePubHash || t.tokenIssuePubHash === tokenIssuePubHash)
+ .filter(t => !merchantBaseUrl || t.merchantBaseUrl === merchantBaseUrl);
+ });
+
+ if (tokens.length === 0) {
+ return { discounts: [] };
+ }
+
+ return {
+ discounts: groupDiscounts(tokens.map(t => ({
+ name: t.name,
+ description: t.description,
+ descriptionI18n: t.descriptionI18n,
+ merchantBaseUrl: t.merchantBaseUrl,
+ tokenIssuePub: t.tokenIssuePub,
+ tokenIssuePubHash: t.tokenIssuePubHash,
+ }))),
+ };
+}
diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts
@@ -25,6 +25,7 @@ import {
MerchantContractTokenDetails,
MerchantContractTokenKind,
PaymentTokenAvailabilityDetails,
+ TalerProtocolTimestamp,
TokenAvailabilityHint,
} from "@gnu-taler/taler-util";
import {
@@ -271,12 +272,10 @@ export function selectTokenCandidates(
// - filter out tokens with errors
// - sort ascending by expiration date
// - choose the first n tokens in the list
- const usable = records.filter(tok =>
- !tok.transactionId && AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validAfter)),
- AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validBefore)),
- )).filter(tok => {
+ const usable = records
+ .filter(tok => !isTokenInUse(tok))
+ .filter(tok => isTokenValid(tok))
+ .filter(tok => {
const res = verifyTokenMerchant(
merchantBaseUrl,
tok.merchantBaseUrl,
@@ -337,3 +336,29 @@ export function selectTokenCandidates(
details,
};
}
+
+export function isTokenInUse(tok: TokenRecord): boolean {
+ return tok.transactionId !== undefined;
+}
+
+export function isTokenValid(tok: TokenRecord): boolean {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validAfter)),
+ AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validBefore)),
+ );
+}
+
+export function isTokenValidBetween(
+ tok: TokenRecord,
+ start: TalerProtocolTimestamp,
+ end: TalerProtocolTimestamp,
+): boolean {
+ return AbsoluteTime.cmp(
+ AbsoluteTime.fromProtocolTimestamp(start ?? TalerProtocolTimestamp.now()),
+ AbsoluteTime.fromProtocolTimestamp(tok.tokenIssuePub.signature_validity_start),
+ ) <= 0 && AbsoluteTime.cmp(
+ AbsoluteTime.fromProtocolTimestamp(end),
+ AbsoluteTime.fromProtocolTimestamp(tok.tokenIssuePub.signature_validity_end),
+ ) >= 0
+}
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -123,6 +123,8 @@ import {
ListAssociatedRefreshesResponse,
ListBankAccountsRequest,
ListBankAccountsResponse,
+ ListDiscountsRequest,
+ ListDiscountsResponse,
ListExchangesRequest,
ListGlobalCurrencyAuditorsResponse,
ListGlobalCurrencyExchangesResponse,
@@ -288,6 +290,9 @@ export enum WalletApiOperation {
StartExchangeWalletKyc = "startExchangeWalletKyc",
GetBankingChoicesForPayto = "getBankingChoicesForPayto",
+ // Tokens and token families
+ ListDiscounts = "listDiscounts",
+
// Donau
SetDonau = "setDonau",
GetDonau = "getDonau",
@@ -701,6 +706,18 @@ export type StartRefundQueryOp = {
response: EmptyObject;
};
+// group: Token family management
+
+/**
+ * List discount tokens stored in the wallet. Listed tokens
+ * will be grouped by expiration date (@e validityEnd)
+ */
+export type ListDiscountsOp = {
+ op: WalletApiOperation.ListDiscounts;
+ request: ListDiscountsRequest;
+ response: ListDiscountsResponse;
+};
+
// group: Global Currency management
export type ListGlobalCurrencyAuditorsOp = {
@@ -1573,6 +1590,7 @@ export type WalletOperations = {
[WalletApiOperation.GetDepositWireTypesForCurrency]: GetDepositWireTypesForCurrencyOp;
[WalletApiOperation.GetQrCodesForPayto]: GetQrCodesForPaytoOp;
[WalletApiOperation.GetBankingChoicesForPayto]: GetBankingChoicesForPaytoOp;
+ [WalletApiOperation.ListDiscounts]: ListDiscountsOp;
[WalletApiOperation.StartExchangeWalletKyc]: StartExchangeWalletKycOp;
[WalletApiOperation.TestingWaitExchangeWalletKyc]: TestingWaitExchangeWalletKycOp;
[WalletApiOperation.TestingPlanMigrateExchangeBaseUrl]: TestingPlanMigrateExchangeBaseUrlOp;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -93,6 +93,8 @@ import {
IntegrationTestV2Args,
ListBankAccountsRequest,
ListBankAccountsResponse,
+ ListDiscountsRequest,
+ ListDiscountsResponse,
ListGlobalCurrencyAuditorsResponse,
ListGlobalCurrencyExchangesResponse,
Logger,
@@ -197,6 +199,7 @@ import {
codecForIntegrationTestArgs,
codecForIntegrationTestV2Args,
codecForListBankAccounts,
+ codecForListDiscountsRequest,
codecForListExchangesRequest,
codecForPrepareBankIntegratedWithdrawalRequest,
codecForPreparePayRequest,
@@ -414,6 +417,7 @@ import {
getWithdrawalDetailsForUri,
prepareBankIntegratedWithdrawal,
} from "./withdraw.js";
+import { listDiscounts } from "./tokenFamilies.js";
const logger = new Logger("wallet.ts");
@@ -1267,6 +1271,16 @@ async function handleConfirmPay(
});
}
+async function handleListDiscounts(
+ wex: WalletExecutionContext,
+ req: ListDiscountsRequest,
+): Promise<ListDiscountsResponse> {
+ return await listDiscounts(wex,
+ req.tokenIssuePubHash,
+ req.merchantBaseUrl,
+ );
+}
+
async function handleAbortTransaction(
wex: WalletExecutionContext,
req: AbortTransactionRequest,
@@ -2116,6 +2130,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForConfirmPayRequest(),
handler: handleConfirmPay,
},
+ [WalletApiOperation.ListDiscounts]: {
+ codec: codecForListDiscountsRequest(),
+ handler: handleListDiscounts,
+ },
[WalletApiOperation.SuspendTransaction]: {
codec: codecForSuspendTransaction(),
handler: handleSuspendTransaction,