From 04ab9f37801f6a42b85581cc79667239d3fc79e5 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sat, 11 Feb 2023 14:24:29 +0100 Subject: wallet-core,harness: implement pay templating --- packages/taler-util/src/index.ts | 1 + packages/taler-util/src/merchant-api-types.ts | 368 ++++++++++++++++++++++++++ packages/taler-util/src/taler-types.ts | 15 +- packages/taler-util/src/taleruri.test.ts | 36 +++ packages/taler-util/src/taleruri.ts | 65 ++++- packages/taler-util/src/wallet-types.ts | 11 + 6 files changed, 487 insertions(+), 9 deletions(-) create mode 100644 packages/taler-util/src/merchant-api-types.ts (limited to 'packages/taler-util/src') diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts index 2f674d097..661b0332f 100644 --- a/packages/taler-util/src/index.ts +++ b/packages/taler-util/src/index.ts @@ -35,3 +35,4 @@ export { RequestThrottler } from "./RequestThrottler.js"; export * from "./CancellationToken.js"; export * from "./contract-terms.js"; export * from "./base64.js"; +export * from "./merchant-api-types.js"; diff --git a/packages/taler-util/src/merchant-api-types.ts b/packages/taler-util/src/merchant-api-types.ts new file mode 100644 index 000000000..61002191a --- /dev/null +++ b/packages/taler-util/src/merchant-api-types.ts @@ -0,0 +1,368 @@ +/* + 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 + */ + +/** + * Test harness for various GNU Taler components. + * Also provides a fault-injection proxy. + * + * @author Florian Dold + */ + +/** + * Imports. + */ +import { + MerchantContractTerms, + Codec, + buildCodecForObject, + codecForString, + codecOptional, + codecForConstString, + codecForBoolean, + codecForNumber, + codecForMerchantContractTerms, + codecForAny, + buildCodecForUnion, + AmountString, + AbsoluteTime, + CoinPublicKeyString, + EddsaPublicKeyString, + codecForAmountString, + TalerProtocolDuration, + codecForTimestamp, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; + +export interface MerchantPostOrderRequest { + // The order must at least contain the minimal + // order detail, but can override all + order: Partial; + + // if set, the backend will then set the refund deadline to the current + // time plus the specified delay. + refund_delay?: TalerProtocolDuration; + + // specifies the payment target preferred by the client. Can be used + // to select among the various (active) wire methods supported by the instance. + payment_target?: string; + + // FIXME: some fields are missing + + // Should a token for claiming the order be generated? + // False can make sense if the ORDER_ID is sufficiently + // high entropy to prevent adversarial claims (like it is + // if the backend auto-generates one). Default is 'true'. + create_token?: boolean; +} + +export type ClaimToken = string; + +export interface MerchantPostOrderResponse { + order_id: string; + token?: ClaimToken; +} + +export const codecForMerchantPostOrderResponse = (): Codec => + buildCodecForObject() + .property("order_id", codecForString()) + .property("token", codecOptional(codecForString())) + .build("PostOrderResponse"); + +export const codecForMerchantRefundDetails = (): Codec => + buildCodecForObject() + .property("reason", codecForString()) + .property("pending", codecForBoolean()) + .property("amount", codecForString()) + .property("timestamp", codecForTimestamp) + .build("PostOrderResponse"); + +export const codecForMerchantCheckPaymentPaidResponse = + (): Codec => + buildCodecForObject() + .property("order_status_url", codecForString()) + .property("order_status", codecForConstString("paid")) + .property("refunded", codecForBoolean()) + .property("wired", codecForBoolean()) + .property("deposit_total", codecForAmountString()) + .property("exchange_ec", codecForNumber()) + .property("exchange_hc", codecForNumber()) + .property("refund_amount", codecForAmountString()) + .property("contract_terms", codecForMerchantContractTerms()) + // FIXME: specify + .property("wire_details", codecForAny()) + .property("wire_reports", codecForAny()) + .property("refund_details", codecForAny()) + .build("CheckPaymentPaidResponse"); + +export const codecForCheckPaymentUnpaidResponse = + (): Codec => + buildCodecForObject() + .property("order_status", codecForConstString("unpaid")) + .property("taler_pay_uri", codecForString()) + .property("order_status_url", codecForString()) + .property("already_paid_order_id", codecOptional(codecForString())) + .build("CheckPaymentPaidResponse"); + +export const codecForCheckPaymentClaimedResponse = + (): Codec => + buildCodecForObject() + .property("order_status", codecForConstString("claimed")) + .property("contract_terms", codecForMerchantContractTerms()) + .build("CheckPaymentClaimedResponse"); + +export const codecForMerchantOrderPrivateStatusResponse = + (): Codec => + buildCodecForUnion() + .discriminateOn("order_status") + .alternative("paid", codecForMerchantCheckPaymentPaidResponse()) + .alternative("unpaid", codecForCheckPaymentUnpaidResponse()) + .alternative("claimed", codecForCheckPaymentClaimedResponse()) + .build("MerchantOrderPrivateStatusResponse"); + +export type MerchantOrderPrivateStatusResponse = + | MerchantCheckPaymentPaidResponse + | CheckPaymentUnpaidResponse + | CheckPaymentClaimedResponse; + +export interface CheckPaymentClaimedResponse { + // Wallet claimed the order, but didn't pay yet. + order_status: "claimed"; + + contract_terms: MerchantContractTerms; +} + +export interface MerchantCheckPaymentPaidResponse { + // did the customer pay for this contract + order_status: "paid"; + + // Was the payment refunded (even partially) + refunded: boolean; + + // Did the exchange wire us the funds + wired: boolean; + + // Total amount the exchange deposited into our bank account + // for this contract, excluding fees. + deposit_total: AmountString; + + // Numeric error code indicating errors the exchange + // encountered tracking the wire transfer for this purchase (before + // we even got to specific coin issues). + // 0 if there were no issues. + exchange_ec: number; + + // HTTP status code returned by the exchange when we asked for + // information to track the wire transfer for this purchase. + // 0 if there were no issues. + exchange_hc: number; + + // Total amount that was refunded, 0 if refunded is false. + refund_amount: AmountString; + + // Contract terms + contract_terms: MerchantContractTerms; + + // Ihe wire transfer status from the exchange for this order if available, otherwise empty array + wire_details: TransactionWireTransfer[]; + + // Reports about trouble obtaining wire transfer details, empty array if no trouble were encountered. + wire_reports: TransactionWireReport[]; + + // The refund details for this order. One entry per + // refunded coin; empty array if there are no refunds. + refund_details: RefundDetails[]; + + order_status_url: string; +} + +export interface CheckPaymentUnpaidResponse { + order_status: "unpaid"; + + // URI that the wallet must process to complete the payment. + taler_pay_uri: string; + + order_status_url: string; + + // Alternative order ID which was paid for already in the same session. + // Only given if the same product was purchased before in the same session. + already_paid_order_id?: string; + + // We do we NOT return the contract terms here because they may not + // exist in case the wallet did not yet claim them. +} + +export interface RefundDetails { + // Reason given for the refund + reason: string; + + // when was the refund approved + timestamp: TalerProtocolTimestamp; + + // has not been taken yet + pending: boolean; + + // Total amount that was refunded (minus a refund fee). + amount: AmountString; +} + +export interface TransactionWireTransfer { + // Responsible exchange + exchange_url: string; + + // 32-byte wire transfer identifier + wtid: string; + + // execution time of the wire transfer + execution_time: AbsoluteTime; + + // Total amount that has been wire transferred + // to the merchant + amount: AmountString; + + // Was this transfer confirmed by the merchant via the + // POST /transfers API, or is it merely claimed by the exchange? + confirmed: boolean; +} + +export interface TransactionWireReport { + // Numerical error code + code: number; + + // Human-readable error description + hint: string; + + // Numerical error code from the exchange. + exchange_ec: number; + + // HTTP status code received from the exchange. + exchange_hc: number; + + // Public key of the coin for which we got the exchange error. + coin_pub: CoinPublicKeyString; +} + +export interface TippingReserveStatus { + // Array of all known reserves (possibly empty!) + reserves: ReserveStatusEntry[]; +} + +export interface ReserveStatusEntry { + // Public key of the reserve + reserve_pub: string; + + // Timestamp when it was established + creation_time: AbsoluteTime; + + // Timestamp when it expires + expiration_time: AbsoluteTime; + + // Initial amount as per reserve creation call + merchant_initial_amount: AmountString; + + // Initial amount as per exchange, 0 if exchange did + // not confirm reserve creation yet. + exchange_initial_amount: AmountString; + + // Amount picked up so far. + pickup_amount: AmountString; + + // Amount approved for tips that exceeds the pickup_amount. + committed_amount: AmountString; + + // Is this reserve active (false if it was deleted but not purged) + active: boolean; +} + +export interface TipCreateConfirmation { + // Unique tip identifier for the tip that was created. + tip_id: string; + + // taler://tip URI for the tip + taler_tip_uri: string; + + // URL that will directly trigger processing + // the tip when the browser is redirected to it + tip_status_url: string; + + // when does the tip expire + tip_expiration: AbsoluteTime; +} + +export interface TipCreateRequest { + // Amount that the customer should be tipped + amount: AmountString; + + // Justification for giving the tip + justification: string; + + // URL that the user should be directed to after tipping, + // will be included in the tip_token. + next_url: string; +} + +export interface MerchantInstancesResponse { + // List of instances that are present in the backend (see Instance) + instances: MerchantInstanceDetail[]; +} + +export interface MerchantInstanceDetail { + // Merchant name corresponding to this instance. + name: string; + + // Merchant instance this response is about ($INSTANCE) + id: string; + + // Public key of the merchant/instance, in Crockford Base32 encoding. + merchant_pub: EddsaPublicKeyString; + + // List of the payment targets supported by this instance. Clients can + // specify the desired payment target in /order requests. Note that + // front-ends do not have to support wallets selecting payment targets. + payment_targets: string[]; +} + +export interface MerchantTemplateContractDetails { + // Human-readable summary for the template. + summary?: string; + + // The price is imposed by the merchant and cannot be changed by the customer. + // This parameter is optional. + amount?: AmountString; + + // Minimum age buyer must have (in years). Default is 0. + minimum_age: number; + + // The time the customer need to pay before his order will be deleted. + // It is deleted if the customer did not pay and if the duration is over. + pay_duration: TalerProtocolDuration; +} + +export interface MerchantTemplateAddDetails { + + // Template ID to use. + template_id: string; + + // Human-readable description for the template. + template_description: string; + + // A base64-encoded image selected by the merchant. + // This parameter is optional. + // We are not sure about it. + image?: string; + + // Additional information in a separate template. + template_contract: MerchantTemplateContractDetails; +} \ No newline at end of file diff --git a/packages/taler-util/src/taler-types.ts b/packages/taler-util/src/taler-types.ts index bb15f0494..6e7df2c04 100644 --- a/packages/taler-util/src/taler-types.ts +++ b/packages/taler-util/src/taler-types.ts @@ -1481,10 +1481,11 @@ export const codecForWithdrawResponse = (): Codec => .property("ev_sig", codecForBlindedDenominationSignature()) .build("WithdrawResponse"); -export const codecForWithdrawBatchResponse = (): Codec => - buildCodecForObject() - .property("ev_sigs", codecForList(codecForWithdrawResponse())) - .build("WithdrawBatchResponse"); +export const codecForWithdrawBatchResponse = + (): Codec => + buildCodecForObject() + .property("ev_sigs", codecForList(codecForWithdrawResponse())) + .build("WithdrawBatchResponse"); export const codecForMerchantPayResponse = (): Codec => buildCodecForObject() @@ -1757,7 +1758,6 @@ export interface ExchangeBatchWithdrawRequest { planchets: ExchangeWithdrawRequest[]; } - export interface ExchangeRefreshRevealRequest { new_denoms_h: HashCodeString[]; coin_evs: CoinEnvelope[]; @@ -2113,3 +2113,8 @@ export const codecForWalletKycUuid = (): Codec => .property("requirement_row", codecForNumber()) .property("h_payto", codecForString()) .build("WalletKycUuid"); + +export interface MerchantUsingTemplateDetails { + summary?: string; + amount?: AmountString; +} diff --git a/packages/taler-util/src/taleruri.test.ts b/packages/taler-util/src/taleruri.test.ts index 3ee243fb3..a6c4d89fc 100644 --- a/packages/taler-util/src/taleruri.test.ts +++ b/packages/taler-util/src/taleruri.test.ts @@ -22,6 +22,8 @@ import { parseTipUri, parsePayPushUri, constructPayPushUri, + parsePayTemplateUri, + constructPayUri, } from "./taleruri.js"; test("taler pay url parsing: wrong scheme", (t) => { @@ -225,3 +227,37 @@ test("taler peer to peer push URI (construction)", (t) => { }); t.deepEqual(url, "taler://pay-push/foo.example.com/bla/123"); }); + +test("taler pay URI (construction)", (t) => { + const url1 = constructPayUri("http://localhost:123/", "foo", ""); + t.deepEqual(url1, "taler+http://pay/localhost:123/foo/"); + + const url2 = constructPayUri("http://localhost:123/", "foo", "bla"); + t.deepEqual(url2, "taler+http://pay/localhost:123/foo/bla"); +}); + +test("taler pay template URI (parsing)", (t) => { + const url1 = + "taler://pay-template/merchant.example.com/FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY?amount=KUDOS:5"; + const r1 = parsePayTemplateUri(url1); + if (!r1) { + t.fail(); + return; + } + t.deepEqual(r1.merchantBaseUrl, "https://merchant.example.com/"); + t.deepEqual(r1.templateId, "FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY"); + t.deepEqual(r1.templateParams.amount, "KUDOS:5"); +}); + +test("taler pay template URI (parsing, http with port)", (t) => { + const url1 = + "taler+http://pay-template/merchant.example.com:1234/FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY?amount=KUDOS:5"; + const r1 = parsePayTemplateUri(url1); + if (!r1) { + t.fail(); + return; + } + t.deepEqual(r1.merchantBaseUrl, "http://merchant.example.com:1234/"); + t.deepEqual(r1.templateId, "FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY"); + t.deepEqual(r1.templateParams.amount, "KUDOS:5"); +}); diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts index 4e47acbce..2aa9cb030 100644 --- a/packages/taler-util/src/taleruri.ts +++ b/packages/taler-util/src/taleruri.ts @@ -16,7 +16,6 @@ import { BackupRecovery } from "./backup-types.js"; import { canonicalizeBaseUrl } from "./helpers.js"; -import { initNodePrng } from "./prng-node.js"; import { URLSearchParams, URL } from "./url.js"; export interface PayUriResult { @@ -27,6 +26,12 @@ export interface PayUriResult { noncePriv: string | undefined; } +export interface PayTemplateUriResult { + merchantBaseUrl: string; + templateId: string; + templateParams: Record; +} + export interface WithdrawUriResult { bankIntegrationApiBaseUrl: string; withdrawalOperationId: string; @@ -91,6 +96,7 @@ export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { export enum TalerUriType { TalerPay = "taler-pay", + TalerTemplate = "taler-template", TalerWithdraw = "taler-withdraw", TalerTip = "taler-tip", TalerRefund = "taler-refund", @@ -103,6 +109,7 @@ export enum TalerUriType { const talerActionPayPull = "pay-pull"; const talerActionPayPush = "pay-push"; +const talerActionPayTemplate = "pay-template"; /** * Classify a taler:// URI. @@ -121,6 +128,12 @@ export function classifyTalerUri(s: string): TalerUriType { if (sl.startsWith("taler+http://pay/")) { return TalerUriType.TalerPay; } + if (sl.startsWith("taler://pay-template/")) { + return TalerUriType.TalerPay; + } + if (sl.startsWith("taler+http://pay-template/")) { + return TalerUriType.TalerPay; + } if (sl.startsWith("taler://tip/")) { return TalerUriType.TalerTip; } @@ -216,6 +229,38 @@ export function parsePayUri(s: string): PayUriResult | undefined { }; } +export function parsePayTemplateUri( + s: string, +): PayTemplateUriResult | undefined { + const pi = parseProtoInfo(s, talerActionPayTemplate); + if (!pi) { + return undefined; + } + const c = pi?.rest.split("?"); + const q = new URLSearchParams(c[1] ?? ""); + const parts = c[0].split("/"); + if (parts.length < 2) { + return undefined; + } + const host = parts[0].toLowerCase(); + const templateId = parts[parts.length - 1]; + const pathSegments = parts.slice(1, parts.length - 1); + const p = [host, ...pathSegments].join("/"); + const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`); + + const params: Record = {}; + + q.forEach((v, k) => { + params[k] = v; + }); + + return { + merchantBaseUrl, + templateId, + templateParams: params, + }; +} + export function constructPayUri( merchantBaseUrl: string, orderId: string, @@ -227,9 +272,21 @@ export function constructPayUri( const url = new URL(base); const isHttp = base.startsWith("http://"); let result = isHttp ? `taler+http://pay/` : `taler://pay/`; - result += `${url.hostname}${url.pathname}${orderId}/${sessionId}?`; - if (claimToken) result += `c=${claimToken}`; - if (noncePriv) result += `n=${noncePriv}`; + result += url.hostname; + if (url.port != "") { + result += `:${url.port}`; + } + result += `${url.pathname}${orderId}/${sessionId}`; + let queryPart = ""; + if (claimToken) { + queryPart += `c=${claimToken}`; + } + if (noncePriv) { + queryPart += `n=${noncePriv}`; + } + if (queryPart) { + result += "?" + queryPart; + } return result; } diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index d57a221f3..0f29b964b 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -1418,6 +1418,17 @@ export const codecForPreparePayRequest = (): Codec => .property("talerPayUri", codecForString()) .build("PreparePay"); +export interface PreparePayTemplateRequest { + talerPayTemplateUri: string; + templateParams: Record; +} + +export const codecForPreparePayTemplateRequest = (): Codec => + buildCodecForObject() + .property("talerPayTemplateUri", codecForString()) + .property("templateParams", codecForAny()) + .build("PreparePayTemplate"); + export interface ConfirmPayRequest { proposalId: string; sessionId?: string; -- cgit v1.2.3