taler-typescript-core

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

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:
Apackages/taler-harness/src/integrationtests/test-wallet-tokens-discount.ts | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/types-taler-wallet.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/deleteDiscount.ts.bak | 44++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/tokenFamilies.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/tokenSelection.ts | 37+++++++++++++++++++++++++++++++------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 18++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 18++++++++++++++++++
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,