commit 37b71ab26f3855bb90453c9e7766be9f02af3448 parent 8e4c4a5731df290b2dcca856bac0b9a43c81bdde Author: Florian Dold <florian@dold.me> Date: Fri, 19 Jun 2026 14:22:36 +0200 more taler URI refactoring, first draft of template URI overrides Diffstat:
13 files changed, 908 insertions(+), 654 deletions(-)
diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/index.ts b/packages/libeufin-bank-webui/src/pages/OperationState/index.ts @@ -20,7 +20,7 @@ import { TalerCoreBankErrorsByMethod, TalerError, TranslatedString, - WithdrawUriResult + TalerWithdrawUri } from "@gnu-taler/taler-util"; import { ErrorLoading, @@ -84,7 +84,7 @@ export namespace State { export interface Ready { status: "ready"; error: undefined; - uri: WithdrawUriResult; + uri: TalerWithdrawUri; focus?: boolean; onAbort: () => void; operationId: string; diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/state.ts b/packages/libeufin-bank-webui/src/pages/OperationState/state.ts @@ -23,7 +23,7 @@ import { TalerCorebankApi, TalerError, TalerUriAction, - WithdrawUriResult, + TalerWithdrawUri, assertUnreachable, } from "@gnu-taler/taler-util"; import { @@ -101,7 +101,7 @@ export function useComponentState({ }; } - const parsedUri: WithdrawUriResult = { + const parsedUri: TalerWithdrawUri = { type: TalerUriAction.Withdraw, bankIntegrationApiBaseUrl: bank.getIntegrationAPI().href as HostPortPath, withdrawalOperationId: withdrawalOperationId, diff --git a/packages/libeufin-bank-webui/src/pages/QrCodeSection.tsx b/packages/libeufin-bank-webui/src/pages/QrCodeSection.tsx @@ -19,7 +19,7 @@ import { HttpStatusCode, TalerUris, UserAndToken, - WithdrawUriResult, + TalerWithdrawUri, } from "@gnu-taler/taler-util"; import { Button, @@ -39,7 +39,7 @@ export function QrCodeSection({ withdrawUri, onAborted, }: { - withdrawUri: WithdrawUriResult; + withdrawUri: TalerWithdrawUri; onAborted: () => void; }): VNode { const { i18n } = useTranslationContext(); diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -21,7 +21,7 @@ import { PaytoType, Paytos, TalerErrorCode, - WithdrawUriResult, + TalerWithdrawUri, assertUnreachable, } from "@gnu-taler/taler-util"; import { @@ -41,7 +41,7 @@ import { LoginForm } from "./LoginForm.js"; const TALER_SCREEN_ID = 114; interface Props { - withdrawUri: WithdrawUriResult; + withdrawUri: TalerWithdrawUri; details: { account: Paytos.URI; reserve: string; diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalQRCode.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalQRCode.tsx @@ -18,7 +18,7 @@ import { Amounts, HttpStatusCode, TalerError, - WithdrawUriResult, + TalerWithdrawUri, assertUnreachable, } from "@gnu-taler/taler-util"; import { @@ -38,7 +38,7 @@ import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion const TALER_SCREEN_ID = 116; interface Props { - withdrawUri: WithdrawUriResult; + withdrawUri: TalerWithdrawUri; origin: "from-bank-ui" | "from-wallet-ui"; onOperationAborted: () => void; routeClose: RouteDefinition; diff --git a/packages/taler-harness/src/integrationtests/test-paivana-repurchase.ts b/packages/taler-harness/src/integrationtests/test-paivana-repurchase.ts @@ -22,7 +22,7 @@ import { encodeCrock, getRandomBytes, Logger, - PayTemplateUriResult, + TalerPayTemplateUri, Result, stringToBytes, succeedOrThrow, @@ -125,7 +125,7 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { // thrid device so no information produced // here is available - const newTemplate: PayTemplateUriResult = { + const newTemplate: TalerPayTemplateUri = { type: TalerUriAction.PayTemplate, merchantBaseUrl: uri.merchantBaseUrl, templateId: uri.templateId, diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts @@ -634,12 +634,9 @@ export class Amounts { /** * Check if the argument is a valid amount in string form. */ - static check(a: any): boolean { - if (typeof a !== "string") { - return false; - } + static checkString(s: string): s is AmountString { try { - const parsedAmount = Amounts.parse(a); + const parsedAmount = Amounts.parse(s); return !!parsedAmount; } catch { return false; @@ -667,16 +664,14 @@ export class Amounts { return `${x} ${amount.currency}`; } - static amountHasSameCurrency(a1: AmountLike, a2: AmountLike): boolean { - const x1 = this.jsonifyAmount(a1); - const x2 = this.jsonifyAmount(a2); - return x1.currency.toUpperCase() === x2.currency.toUpperCase(); - } - static isSameCurrency(curr1: string, curr2: string): boolean { return curr1.toLowerCase() === curr2.toLowerCase(); } + /** + * Stringify only the value part of an amount, + * leaving out the currency name. + */ static stringifyValue(a: AmountLike, minFractional = 0): string { const aJ = Amounts.jsonifyAmount(a); const av = aJ.value + Math.floor(aJ.fraction / amountFractionalBase); diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts @@ -34,18 +34,18 @@ import { URL, URLSearchParams } from "./url.js"; * A parsed taler URI. */ export type TalerUri = - | PayUriResult - | PayTemplateUriResult - | DevExperimentUri - | PayPullUriResult - | PayPushUriResult - | BackupRestoreUri - | RefundUriResult - | WithdrawUriResult - | WithdrawExchangeUri - | AddExchangeUri - | WithdrawalTransferResultUri - | AddContactUri; + | TalerPayUriResult + | TalerPayTemplateUri + | TalerDevExperimentUri + | TalerPayPullUri + | TalerPayPushUri + | TalerRestoreUri + | TalerRefundUri + | TalerWithdrawUri + | TalerWithdrawExchangeUri + | TalerAddExchangeUri + | TalerWithdrawalTransferResultUri + | TalerAddContactUri; declare const __action_str: unique symbol; export type TalerUriString = string & { [__action_str]: true }; @@ -129,31 +129,58 @@ export namespace TalerUris { const result: [string, string][] = []; switch (p.type) { case TalerUriAction.Withdraw: { - if (p.externalConfirmation) result.push(["external-confirmation", "1"]); + if (p.externalConfirmation) { + result.push(["external-confirmation", "1"]); + } return result; } case TalerUriAction.Pay: { - if (p.claimToken) result.push(["c", p.claimToken]); - if (p.noncePriv) result.push(["n", p.noncePriv]); + if (p.claimToken) { + result.push(["c", p.claimToken]); + } + if (p.noncePriv) { + result.push(["n", p.noncePriv]); + } return result; } case TalerUriAction.WithdrawExchange: { - if (p.amount) result.push(["a", p.amount]); + if (p.amount) { + result.push(["a", p.amount]); + } return result; } case TalerUriAction.WithdrawalTransferResult: { result.push(["ref", p.ref]); - if (p.status) result.push(["status", p.status]); + if (p.status) { + result.push(["status", p.status]); + } return result; } case TalerUriAction.AddContact: { - if (p.sourceBaseUrl) result.push(["sourceBaseUrl", p.sourceBaseUrl]); + if (p.sourceBaseUrl) { + result.push(["sourceBaseUrl", p.sourceBaseUrl]); + } return result; } case TalerUriAction.PayTemplate: { - if (p.fulfillmentUrl) + if (p.fulfillmentUrl) { result.push(["fulfillment_url", p.fulfillmentUrl]); - if (p.sessionId) result.push(["session_id", p.sessionId]); + } + if (p.sessionId) { + result.push(["session_id", p.sessionId]); + } + if (p.amount) { + result.push(["amount", p.amount]); + } + if (p.summary) { + result.push(["summary", p.summary]); + } + if (p.editableAmount) { + result.push(["editable_amount", "1"]); + } + if (p.editableSummary) { + result.push(["editable_summary", "1"]); + } return result; } case TalerUriAction.Refund: @@ -305,13 +332,7 @@ export namespace TalerUris { pos: 0; }; - /** - * Parse a taler:// URI. - */ - export function parse( - s: string, - opts: PaytoParseOptions = {}, - ): + export type ParseResult = | ResultOk<TalerUri> | ResultError<TalerUriParseError.WRONG_PREFIX> | ResultError<TalerUriParseError.UNSUPPORTED, { uriType: string }> @@ -327,7 +348,13 @@ export namespace TalerUris { | ResultError< TalerUriParseError.INVALID_PARAMETER, { uriType: TalerUriAction; name: string } - > { + > + | ResultError<TalerUriParseError.UNEXPECTED_ACTION>; + + /** + * Parse a taler:// URI. + */ + export function parse(s: string, opts: PaytoParseOptions = {}): ParseResult { // check prefix let isHttp = false; const prefixCheck = opts.ignoreUppercase ? s.toLowerCase() : s; @@ -380,429 +407,30 @@ export namespace TalerUris { // get URI components const cs = targetPath.split("/"); switch (uriType) { - case TalerUriAction.Pay: { - // check number of segments - if (cs.length < 3) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - // get merchant host - const merchant = Paytos.parseHostPortPath2( - cs[0], - cs.slice(1, -2).join("/"), - scheme, - ); - if (!opts.ignoreComponentError && !merchant) { - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 0 as const, - uriType, - error: merchant, - }, - ); - } - - // get order - const orderId = cs[cs.length - 2]; - // get session - const sessionId = cs[cs.length - 1]; - - return Result.of<TalerUri>({ - type: TalerUriAction.Pay, - merchantBaseUrl: merchant ?? (cs[0] as HostPortPath), - orderId, - sessionId, - claimToken: params["c"], - noncePriv: params["n"], - }); - } - case TalerUriAction.Withdraw: { - // check number of segments - if (cs.length < 2) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - // get bank host - const bank = Paytos.parseHostPortPath2( - cs[0], - cs.slice(1, -1).join("/"), - scheme, - ); - if (!opts.ignoreComponentError && !bank) { - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 0 as const, - uriType, - error: bank, - }, - ); - } - - // get operation id - const operationId = cs[cs.length - 1]; - // get external confirmation - const externalConfirmation = !params["external-confirmation"] - ? undefined - : params["external-confirmation"] === "1"; - - return Result.of<TalerUri>({ - type: TalerUriAction.Withdraw, - bankIntegrationApiBaseUrl: bank ?? (cs[0] as HostPortPath), - withdrawalOperationId: operationId, - externalConfirmation, - }); - } - case TalerUriAction.Refund: { - // check number of segments - if (cs.length < 3) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - if (cs[cs.length - 1]) { - // last must be empty - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 1 as const, - uriType, - }, - ); - } - - // get merchant host - const merchant = Paytos.parseHostPortPath2( - cs[0], - cs.slice(1, -2).join("/"), - scheme, - ); - if (!opts.ignoreComponentError && !merchant) { - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 0 as const, - uriType, - error: merchant, - }, - ); - } - - // get order id - const orderId = cs[cs.length - 2]; - return Result.of({ - type: TalerUriAction.Refund, - merchantBaseUrl: merchant ?? (cs[0] as HostPortPath), - orderId, - }); - } - case TalerUriAction.PayPull: { - // check number of segments - if (cs.length < 2) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - // get exchange host - const exchange = Paytos.parseHostPortPath2( - cs[0], - cs.slice(1, -1).join("/"), - scheme, - ); - if (!opts.ignoreComponentError && !exchange) { - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 0 as const, - uriType, - error: exchange, - }, - ); - } - // get contract priv - const contractPriv = cs[cs.length - 1]; // FIXME: validate private key - - return Result.of({ - type: TalerUriAction.PayPull, - exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), - contractPriv, - }); - } - case TalerUriAction.PayPush: { - // check number of segments - if (cs.length < 2) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - // get exchange host - const exchange = Paytos.parseHostPortPath2( - cs[0], - cs.slice(1, -1).join("/"), - scheme, - ); - if (!opts.ignoreComponentError && !exchange) { - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 0 as const, - uriType, - error: exchange, - }, - ); - } - - // get contract priv - const contractPriv = cs[cs.length - 1]; // FIXME: validate private key - - return Result.of({ - type: TalerUriAction.PayPush, - exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), - contractPriv, - }); - } - case TalerUriAction.PayTemplate: { - // check number of segments - if (cs.length < 2) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - // get merchant host - const merchant = Paytos.parseHostPortPath2( - cs[0], - cs.slice(1, -1).join("/"), - scheme, - ); - if (!opts.ignoreComponentError && !merchant) { - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 0 as const, - uriType, - error: merchant, - }, - ); - } - const templateId = cs[cs.length - 1]; // FIXME: validate private key - - return Result.of({ - type: TalerUriAction.PayTemplate, - merchantBaseUrl: merchant ?? (cs[0] as HostPortPath), - templateId, - fulfillmentUrl: params["fulfillment_url"], - sessionId: params["session_id"], - }); - } - case TalerUriAction.Restore: { - // check number of segments - if (cs.length !== 2) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - const walletPriv = cs[0]; // FIXME: validate private key - const providers: Array<HostPortPath> = []; - // const providers = new Array<HostPortPath>(); - cs[1].split(",").map((name) => { - const url = decodeURIComponent(name); - - let isHttp = false; - const withoutScheme = url.startsWith("https://") - ? url.substring(8) - : (isHttp = url.startsWith("http://")) - ? url.substring(7) - : url; - - // Check resolution of this issue https://bugs.gnunet.org/view.php?id=10466 - const thisScheme = - url === withoutScheme ? scheme : isHttp ? "http" : "https"; - - const [hostname, path] = withoutScheme.split("/", 1); - const host = Paytos.parseHostPortPath2(hostname, path, thisScheme)!; - providers.push(host); - }); - - return Result.of({ - type: TalerUriAction.Restore, - providers, - walletRootPriv: walletPriv ?? (cs[0] as HostPortPath), - }); - } - case TalerUriAction.DevExperiment: { - // check number of segments - if (cs.length !== 1) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - const devExperimentId = cs[0]; - const query = new URLSearchParams(search); - - return Result.of({ - type: TalerUriAction.DevExperiment, - devExperimentId, - query, - }); - } - case TalerUriAction.WithdrawExchange: { - // check number of segments - if (cs.length < 1) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - // FIXME: https://bugs.gnunet.org/view.php?id=10466 - // if (cs[cs.length-1]) { - if (cs.length > 1 && cs[cs.length - 1]) { - // last must be empty - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 1 as const, - uriType, - }, - ); - } - - // get exchange host - const exchange = Paytos.parseHostPortPath2( - cs[0], - cs.slice(1, -1).join("/"), - scheme, - ); - if (!opts.ignoreComponentError && !exchange) { - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 0 as const, - uriType, - error: exchange, - }, - ); - } - - // get amount param - const amountRes = !params["a"] - ? undefined - : Amounts.parseWithError(params["a"]); - if ( - !opts.ignoreComponentError && - amountRes && - Result.isError(amountRes) - ) { - return Result.errorWithDetail(TalerUriParseError.INVALID_PARAMETER, { - name: "a" as const, - uriType, - error: amountRes, - }); - } - const amount = - amountRes && Result.isOk(amountRes) - ? Amounts.stringify(amountRes.value) - : undefined; - - return Result.of({ - type: TalerUriAction.WithdrawExchange, - exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), - amount, - }); - } - case TalerUriAction.AddExchange: { - // check number of segments - if (cs.length === 1) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - // get exchange host - const exchange = Paytos.parseHostPortPath2( - cs[0], - cs.slice(1).join("/"), - scheme, - ); - if (!opts.ignoreComponentError && !exchange) { - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 0 as const, - uriType, - error: exchange, - }, - ); - } - - return Result.of({ - type: TalerUriAction.AddExchange, - exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), - }); - } - case TalerUriAction.WithdrawalTransferResult: { - if (cs.length === 0) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - const ref = params["ref"]; - const status = - params["status"] !== "aborted" && params["status"] !== "success" - ? undefined - : params["status"]; - return Result.of({ - type: TalerUriAction.WithdrawalTransferResult, - ref, - status, - }); - } - case TalerUriAction.AddContact: { - // check number of segments - if (cs.length < 4) { - return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { - uriType, - }); - } - - const aliasType = cs[0]; - const alias = cs[1]; - - const mailboxBaseUri = Paytos.parseHostPortPath2( - cs[2], - cs.slice(2, cs.length - 2).join("/"), - scheme, - ); - if (!mailboxBaseUri) { - return Result.errorWithDetail( - TalerUriParseError.INVALID_TARGET_PATH, - { - pos: 0 as const, - uriType, - error: mailboxBaseUri, - }, - ); - } - const mailboxIdentity = cs[cs.length - 1]; - - return Result.of({ - type: TalerUriAction.AddContact, - aliasType, - alias, - mailboxIdentity, - mailboxBaseUri, - sourceBaseUrl: params["sourceBaseUrl"], - }); - } + case TalerUriAction.Pay: + return parsePay(scheme, uriType, cs, params, opts); + case TalerUriAction.Withdraw: + return parseWithdraw(scheme, uriType, cs, params, opts); + case TalerUriAction.Refund: + return parseRefund(scheme, uriType, cs, params, opts); + case TalerUriAction.PayPull: + return parsePayPull(scheme, uriType, cs, params, opts); + case TalerUriAction.PayPush: + return parsePayPush(scheme, uriType, cs, params, opts); + case TalerUriAction.PayTemplate: + return parsePayTemplate(scheme, uriType, cs, params, opts); + case TalerUriAction.Restore: + return parseRestore(scheme, uriType, cs, params, opts); + case TalerUriAction.DevExperiment: + return parseDevExperiment(scheme, uriType, cs, params, opts); + case TalerUriAction.WithdrawExchange: + return parseWithdrawExchange(scheme, uriType, cs, params, opts); + case TalerUriAction.AddExchange: + return parseAddExchange(scheme, uriType, cs, params, opts); + case TalerUriAction.WithdrawalTransferResult: + return parseWithdrawalTransferResult(scheme, uriType, cs, params); + case TalerUriAction.AddContact: + return parseAddContact(scheme, uriType, cs, params); default: { assertUnreachable(uriType); } @@ -832,7 +460,496 @@ export namespace TalerUris { } } -export interface PayUriResult { +function parseWithdraw( + scheme: "http" | "https", + uriType: TalerUriAction.Withdraw, + cs: string[], + params: Record<string, string>, + opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length < 2) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get bank host + const bank = Paytos.parseHostPortPath2( + cs[0], + cs.slice(1, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !bank) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + uriType, + error: bank, + }); + } + + // get operation id + const operationId = cs[cs.length - 1]; + // get external confirmation + const externalConfirmation = !params["external-confirmation"] + ? undefined + : params["external-confirmation"] === "1"; + + return Result.of<TalerUri>({ + type: TalerUriAction.Withdraw, + bankIntegrationApiBaseUrl: bank ?? (cs[0] as HostPortPath), + withdrawalOperationId: operationId, + externalConfirmation, + }); +} + +function parsePay( + scheme: "http" | "https", + uriType: TalerUriAction.Pay, + cs: string[], + params: Record<string, string>, + opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length < 3) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get merchant host + const merchant = Paytos.parseHostPortPath2( + cs[0], + cs.slice(1, -2).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !merchant) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + uriType, + error: merchant, + }); + } + + // get order + const orderId = cs[cs.length - 2]; + // get session + const sessionId = cs[cs.length - 1]; + + return Result.of<TalerUri>({ + type: TalerUriAction.Pay, + merchantBaseUrl: merchant ?? (cs[0] as HostPortPath), + orderId, + sessionId, + claimToken: params["c"], + noncePriv: params["n"], + }); +} + +function parsePayPush( + scheme: "http" | "https", + uriType: TalerUriAction.PayPush, + cs: string[], + _params: Record<string, string>, + opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length < 2) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get exchange host + const exchange = Paytos.parseHostPortPath2( + cs[0], + cs.slice(1, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !exchange) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + uriType, + error: exchange, + }); + } + + // get contract priv + const contractPriv = cs[cs.length - 1]; // FIXME: validate private key + + return Result.of({ + type: TalerUriAction.PayPush, + exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), + contractPriv, + }); +} + +function parsePayPull( + scheme: "http" | "https", + uriType: TalerUriAction.PayPull, + cs: string[], + params: Record<string, string>, + opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length < 2) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get exchange host + const exchange = Paytos.parseHostPortPath2( + cs[0], + cs.slice(1, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !exchange) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + uriType, + error: exchange, + }); + } + // get contract priv + const contractPriv = cs[cs.length - 1]; // FIXME: validate private key + + return Result.of({ + type: TalerUriAction.PayPull, + exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), + contractPriv, + }); +} + +function parseRefund( + scheme: "http" | "https", + uriType: TalerUriAction.Refund, + cs: string[], + params: Record<string, string>, + opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length < 3) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + if (cs[cs.length - 1]) { + // last must be empty + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 1 as const, + uriType, + }); + } + + // get merchant host + const merchant = Paytos.parseHostPortPath2( + cs[0], + cs.slice(1, -2).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !merchant) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + uriType, + error: merchant, + }); + } + + // get order id + const orderId = cs[cs.length - 2]; + return Result.of({ + type: TalerUriAction.Refund, + merchantBaseUrl: merchant ?? (cs[0] as HostPortPath), + orderId, + }); +} + +function parsePayTemplate( + scheme: "http" | "https", + uriType: TalerUriAction.PayTemplate, + cs: string[], + params: Record<string, string>, + opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length < 2) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get merchant host + const merchant = Paytos.parseHostPortPath2( + cs[0], + cs.slice(1, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !merchant) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + uriType, + error: merchant, + }); + } + const templateId = cs[cs.length - 1]; + + const amountParam = params["amount"]; + let amount: AmountString | undefined; + if (amountParam != undefined && Amounts.checkString(amountParam)) { + amount = amountParam; + } + + return Result.of({ + type: TalerUriAction.PayTemplate, + merchantBaseUrl: merchant ?? (cs[0] as HostPortPath), + templateId, + fulfillmentUrl: params["fulfillment_url"], + sessionId: params["session_id"], + amount, + editableAmount: truthyArg(params["editable_amount"]), + editableSummary: truthyArg(params["editable_summary"]), + summary: params["summary"], + }); +} + +function truthyArg(x: string | undefined): boolean | undefined { + if (x == null) { + return undefined; + } + if (x === "1") { + return true; + } + return false; +} + +function parseRestore( + scheme: "http" | "https", + uriType: TalerUriAction.Restore, + cs: string[], + _params: Record<string, string>, + _opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length !== 2) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + const walletPriv = cs[0]; // FIXME: validate private key + const providers: Array<HostPortPath> = []; + // const providers = new Array<HostPortPath>(); + cs[1].split(",").map((name) => { + const url = decodeURIComponent(name); + + let isHttp = false; + const withoutScheme = url.startsWith("https://") + ? url.substring(8) + : (isHttp = url.startsWith("http://")) + ? url.substring(7) + : url; + + // Check resolution of this issue https://bugs.gnunet.org/view.php?id=10466 + const thisScheme = + url === withoutScheme ? scheme : isHttp ? "http" : "https"; + + const [hostname, path] = withoutScheme.split("/", 1); + const host = Paytos.parseHostPortPath2(hostname, path, thisScheme)!; + providers.push(host); + }); + + return Result.of({ + type: TalerUriAction.Restore, + providers, + walletRootPriv: walletPriv ?? (cs[0] as HostPortPath), + }); +} + +function parseDevExperiment( + _scheme: "http" | "https", + uriType: TalerUriAction.DevExperiment, + cs: string[], + params: Record<string, string>, + _opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length !== 1) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + const devExperimentId = cs[0]; + const query = new URLSearchParams(params); + + return Result.of({ + type: TalerUriAction.DevExperiment, + devExperimentId, + query, + }); +} + +function parseWithdrawExchange( + scheme: "http" | "https", + uriType: TalerUriAction.WithdrawExchange, + cs: string[], + params: Record<string, string>, + opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length < 1) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // FIXME: https://bugs.gnunet.org/view.php?id=10466 + // if (cs[cs.length-1]) { + if (cs.length > 1 && cs[cs.length - 1]) { + // last must be empty + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 1 as const, + uriType, + }); + } + + // get exchange host + const exchange = Paytos.parseHostPortPath2( + cs[0], + cs.slice(1, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !exchange) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + uriType, + error: exchange, + }); + } + + // get amount param + const amountRes = !params["a"] + ? undefined + : Amounts.parseWithError(params["a"]); + if (!opts.ignoreComponentError && amountRes && Result.isError(amountRes)) { + return Result.errorWithDetail(TalerUriParseError.INVALID_PARAMETER, { + name: "a" as const, + uriType, + error: amountRes, + }); + } + const amount = + amountRes && Result.isOk(amountRes) + ? Amounts.stringify(amountRes.value) + : undefined; + + return Result.of({ + type: TalerUriAction.WithdrawExchange, + exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), + amount, + }); +} + +function parseWithdrawalTransferResult( + _scheme: "http" | "https", + uriType: TalerUriAction.WithdrawalTransferResult, + cs: string[], + params: Record<string, string>, + _opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + if (cs.length === 0) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + const ref = params["ref"]; + const status = + params["status"] !== "aborted" && params["status"] !== "success" + ? undefined + : params["status"]; + return Result.of({ + type: TalerUriAction.WithdrawalTransferResult, + ref, + status, + }); +} + +function parseAddExchange( + scheme: "http" | "https", + uriType: TalerUriAction.AddExchange, + cs: string[], + params: Record<string, string>, + opts: TalerUris.PaytoParseOptions = {}, +): TalerUris.ParseResult { + // check number of segments + if (cs.length === 1) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get exchange host + const exchange = Paytos.parseHostPortPath2( + cs[0], + cs.slice(1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !exchange) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + uriType, + error: exchange, + }); + } + + return Result.of({ + type: TalerUriAction.AddExchange, + exchangeBaseUrl: exchange ?? (cs[0] as HostPortPath), + }); +} + +function parseAddContact( + scheme: "http" | "https", + uriType: TalerUriAction.AddContact, + cs: string[], + params: Record<string, string>, +): TalerUris.ParseResult { + // check number of segments + if (cs.length < 4) { + return Result.errorWithDetail(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + const aliasType = cs[0]; + const alias = cs[1]; + + const mailboxBaseUri = Paytos.parseHostPortPath2( + cs[2], + cs.slice(2, cs.length - 2).join("/"), + scheme, + ); + if (!mailboxBaseUri) { + return Result.errorWithDetail(TalerUriParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + uriType, + error: mailboxBaseUri, + }); + } + const mailboxIdentity = cs[cs.length - 1]; + + return Result.of({ + type: TalerUriAction.AddContact, + aliasType, + alias, + mailboxIdentity, + mailboxBaseUri, + sourceBaseUrl: params["sourceBaseUrl"], + }); +} + +export interface TalerPayUriResult { type: TalerUriAction.Pay; merchantBaseUrl: HostPortPath; orderId: string; @@ -850,68 +967,72 @@ export type TemplateParams = { summary?: string; }; -export interface PayTemplateUriResult { +export interface TalerPayTemplateUri { type: TalerUriAction.PayTemplate; merchantBaseUrl: HostPortPath; templateId: string; sessionId?: string; fulfillmentUrl?: string; + editableSummary?: boolean; + summary?: string; + editableAmount?: boolean; + amount?: AmountString; } -export interface WithdrawUriResult { +export interface TalerWithdrawUri { type: TalerUriAction.Withdraw; bankIntegrationApiBaseUrl: HostPortPath; withdrawalOperationId: string; externalConfirmation?: boolean; } -export interface RefundUriResult { +export interface TalerRefundUri { type: TalerUriAction.Refund; merchantBaseUrl: HostPortPath; orderId: string; } -export interface PayPushUriResult { +export interface TalerPayPushUri { type: TalerUriAction.PayPush; exchangeBaseUrl: HostPortPath; contractPriv: string; } -export interface PayPullUriResult { +export interface TalerPayPullUri { type: TalerUriAction.PayPull; exchangeBaseUrl: HostPortPath; contractPriv: string; } -export interface DevExperimentUri { +export interface TalerDevExperimentUri { type: TalerUriAction.DevExperiment; devExperimentId: string; query?: URLSearchParams; // FIXME: Wrong type it should be Record<string,string> } -export interface BackupRestoreUri { +export interface TalerRestoreUri { type: TalerUriAction.Restore; walletRootPriv: string; providers: Array<HostPortPath>; } -export interface WithdrawExchangeUri { +export interface TalerWithdrawExchangeUri { type: TalerUriAction.WithdrawExchange; exchangeBaseUrl: HostPortPath; amount?: AmountString; } -export interface AddExchangeUri { +export interface TalerAddExchangeUri { type: TalerUriAction.AddExchange; exchangeBaseUrl: HostPortPath; } -export interface WithdrawalTransferResultUri { +export interface TalerWithdrawalTransferResultUri { type: TalerUriAction.WithdrawalTransferResult; ref: string; status?: "success" | "aborted"; } -export interface AddContactUri { +export interface TalerAddContactUri { type: TalerUriAction.AddContact; alias: string; aliasType: string; diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts @@ -31,7 +31,7 @@ import { Amounts, ContractTermsUtil, DenomLossEventType, - DevExperimentUri, + TalerDevExperimentUri, Duration, Logger, MerchantContractTermsV0, @@ -422,7 +422,7 @@ export async function applyDevExperiment( } } -function getValFlag(parsedUri: DevExperimentUri): boolean { +function getValFlag(parsedUri: TalerDevExperimentUri): boolean { const setVal = parsedUri.query?.get("val"); if (setVal == null) { return true; @@ -437,7 +437,7 @@ function getValFlag(parsedUri: DevExperimentUri): boolean { async function addFakeTx( wex: WalletExecutionContext, - parsedUri: DevExperimentUri, + parsedUri: TalerDevExperimentUri, ): Promise<void> { const txType = parsedUri.query?.get("txType") ?? "withdrawal"; const withdrawalGroupId = encodeCrock(getRandomBytes(32)); diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -35,8 +35,6 @@ import { BlindedDonationReceiptKeyPair, checkDbInvariant, checkLogicInvariant, - CheckPayTemplateReponse, - CheckPayTemplateRequest, ChoiceSelectionDetail, ChoiceSelectionDetailType, codecForAbortResponse, @@ -44,9 +42,7 @@ import { codecForMerchantOrderStatusPaid, codecForMerchantOrderStatusUnpaid, codecForMerchantPayResponse, - codecForPostOrderResponse, codecForWalletRefundResponse, - codecForWalletTemplateDetails, CoinDepositPermission, CoinRefreshRequest, ConfirmPayResult, @@ -99,12 +95,9 @@ import { TalerError, TalerErrorCode, TalerErrorDetail, - TalerMerchantApi, - TalerMerchantInstanceHttpClient, TalerPreciseTimestamp, TalerUriAction, TalerUris, - TemplateType, TokenUseSig, Transaction, TransactionAction, @@ -114,7 +107,6 @@ import { TransactionState, TransactionType, URL, - UsingTemplateDetailsRequest, WalletNotification, } from "@gnu-taler/taler-util"; import { @@ -176,6 +168,7 @@ import { getScopeForAllCoins, getScopeForAllExchanges, } from "./exchanges.js"; +import { instantiateTemplate } from "./pay-template.js"; import { calculateRefreshOutput, createRefreshGroup, @@ -2290,159 +2283,6 @@ async function waitProposalDownloaded( }); } -async function downloadTemplate( - wex: WalletExecutionContext, - merchantBaseUrl: string, - templateId: string, -): Promise<TalerMerchantApi.WalletTemplateDetailsResponse> { - const reqUrl = new URL(`templates/${templateId}`, merchantBaseUrl); - const httpReq = await cancelableFetch(wex, reqUrl); - const resp = await readSuccessResponseJsonOrThrow( - httpReq, - codecForWalletTemplateDetails(), - ); - return resp; -} - -export async function checkPayForTemplate( - wex: WalletExecutionContext, - req: CheckPayTemplateRequest, -): Promise<CheckPayTemplateReponse> { - const parsedUri = Result.orUndefined( - TalerUris.parseRestricted( - req.talerPayTemplateUri, - TalerUriAction.PayTemplate, - ), - ); - if (!parsedUri) { - throw Error("invalid taler-template URI"); - } - const templateDetails = await downloadTemplate( - wex, - parsedUri.merchantBaseUrl, - parsedUri.templateId, - ); - - const merchantApi = new TalerMerchantInstanceHttpClient( - parsedUri.merchantBaseUrl, - wex.http, - ); - - const cfg = await merchantApi.getConfig(); - if (cfg.type === "fail") { - if (cfg.detail) { - throw TalerError.fromUncheckedDetail(cfg.detail); - } else { - throw TalerError.fromException( - new Error("failed to get merchant remote config"), - ); - } - } - - // FIXME: Put body.currencies *and* body.currency in the set of - // supported currencies. - - return { - templateDetails, - supportedCurrencies: Object.keys(cfg.body.currencies), - }; -} - -/** - * Instantiate a pay template. - * - * @returns A taler://pay/ URI pointing to the order - */ -export async function instantiateTemplate( - wex: WalletExecutionContext, - req: PreparePayTemplateRequest, -): Promise<string> { - const parsedUri = Result.orUndefined( - TalerUris.parseRestricted( - req.talerPayTemplateUri, - TalerUriAction.PayTemplate, - ), - ); - - if (!parsedUri) { - throw Error("invalid taler-template URI"); - } - logger.trace(`parsed URI: ${j2s(parsedUri)}`); - const templateInfo = await downloadTemplate( - wex, - parsedUri.merchantBaseUrl, - parsedUri.templateId, - ); - - let templateDetails: UsingTemplateDetailsRequest; - switch (templateInfo.template_contract.template_type) { - case TalerMerchantApi.TemplateType.FIXED_ORDER: { - templateDetails = { - template_type: TemplateType.FIXED_ORDER, - }; - break; - } - - case TalerMerchantApi.TemplateType.INVENTORY_CART: { - templateDetails = { - template_type: TemplateType.INVENTORY_CART, - }; - break; - } - case TalerMerchantApi.TemplateType.PAIVANA: { - templateDetails = { - template_type: TemplateType.PAIVANA, - paivana_id: parsedUri.sessionId!, - website: parsedUri.fulfillmentUrl!, - }; - break; - } - default: - assertUnreachable(templateInfo.template_contract); - } - - const templateParamsAmount = req.templateParams?.amount; - if (templateParamsAmount == null) { - const amountFromUri = templateInfo.editable_defaults?.amount; - if (amountFromUri != null) { - templateDetails.amount = amountFromUri as AmountString; - } - } else { - templateDetails.amount = templateParamsAmount; - } - - const templateParamsSummary = req.templateParams?.summary; - if (templateParamsSummary == null) { - const summaryFromUri = templateInfo.editable_defaults?.summary; - if (summaryFromUri != null) { - templateDetails.summary = summaryFromUri; - } - } else { - templateDetails.summary = templateParamsSummary; - } - - const reqUrl = new URL( - `templates/${parsedUri.templateId}`, - parsedUri.merchantBaseUrl, - ); - const httpReq = await cancelableFetch(wex, reqUrl, { - method: "POST", - body: templateDetails, - }); - const resp = await readSuccessResponseJsonOrThrow( - httpReq, - codecForPostOrderResponse(), - ); - - return TalerUris.stringify({ - type: TalerUriAction.Pay, - merchantBaseUrl: parsedUri.merchantBaseUrl, - orderId: resp.order_id, - sessionId: parsedUri.sessionId ?? "", - claimToken: resp.token, - }); -} - export async function preparePayForTemplate( wex: WalletExecutionContext, req: PreparePayTemplateRequest, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -30,7 +30,7 @@ import { HttpStatusCode, Logger, ObservabilityEventType, - PayPullUriResult, + TalerPayPullUri, PeerContractTerms, PreparePeerPullDebitRequest, PreparePeerPullDebitResponse, @@ -819,7 +819,7 @@ export async function preparePeerPullDebit( throw Error("either talerUri or transactionId must be specified"); } - let uri: PayPullUriResult | undefined; + let uri: TalerPayPullUri | undefined; if (req.talerUri) { uri = Result.orUndefined( TalerUris.parseRestricted(req.talerUri, TalerUriAction.PayPull), diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -26,7 +26,7 @@ import { LegitimizationNeededResponse, Logger, NotificationType, - PayPushUriResult, + TalerPayPushUri, PeerContractTerms, PreparePeerPushCreditRequest, PreparePeerPushCreditResponse, @@ -476,7 +476,7 @@ export async function preparePeerPushCredit( throw Error("either talerUri or transactionId must be specified"); } - let uri: PayPushUriResult | undefined; + let uri: TalerPayPushUri | undefined; if (req.talerUri) { uri = Result.orUndefined( TalerUris.parseRestricted(req.talerUri, TalerUriAction.PayPush), diff --git a/packages/taler-wallet-core/src/pay-template.ts b/packages/taler-wallet-core/src/pay-template.ts @@ -0,0 +1,298 @@ +/* + 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 { + Amounts, + AmountString, + assertUnreachable, + CheckPayTemplateReponse, + CheckPayTemplateRequest, + codecForPostOrderResponse, + j2s, + Logger, + PreparePayTemplateRequest, + Result, + succeedOrThrow, + TalerMerchantApi, + TalerMerchantInstanceHttpClient, + TalerPayTemplateUri, + TalerUriAction, + TalerUris, + TemplateContractDetails, + TemplateContractDetailsDefaults, + TemplateParams, + TemplateType, + UsingTemplateDetailsRequest, +} from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { cancelableFetch } from "./common.js"; +import { WalletExecutionContext } from "./wallet.js"; + +const logger = new Logger("pay-template.ts"); + +/** + * Apply template overrides from the taler://pay-template URI. + * + * @param parsedUri parsed Taler pay-template URI + * @param templateContract template contract details that will be + * modified by this function to match the overrides from the URI. + * @param editableDefaults editable defaults passed on to the client, + * will be modified by this function to match the ovverrides from the URI + */ +export function applyTemplateUriOverrides( + parsedUri: TalerPayTemplateUri, + templateContract: TemplateContractDetails, + editableDefaults: TemplateContractDetailsDefaults, +): void { + switch (templateContract.template_type) { + case TemplateType.FIXED_ORDER: { + if (parsedUri.amount) { + if (parsedUri.editableAmount) { + editableDefaults.amount = parsedUri.amount; + } else { + delete editableDefaults.amount; + templateContract.amount = parsedUri.amount; + } + } + if (parsedUri.summary) { + if (parsedUri.editableSummary) { + editableDefaults.summary = parsedUri.summary; + } else { + delete editableDefaults.summary; + templateContract.summary = parsedUri.summary; + } + } + break; + } + case TemplateType.PAIVANA: + if (parsedUri.amount) { + if (parsedUri.editableAmount) { + // FIXME: Not clear if paivana templates should support + // defaults for editable amounts. + editableDefaults.amount = parsedUri.amount; + } else { + delete editableDefaults.amount; + for (const c of templateContract.choices) { + // Use the amount from the URI to override simple choices + // without fancy inputs. + if ( + Amounts.isSameCurrency(c.amount, parsedUri.amount) && + (c.inputs == null || c.inputs.length == 0) + ) { + c.amount = parsedUri.amount; + } + } + } + } + if (parsedUri.summary) { + if (parsedUri.editableSummary) { + editableDefaults.summary = parsedUri.summary; + } else { + delete editableDefaults.summary; + templateContract.summary = parsedUri.summary; + } + } + break; + case TemplateType.INVENTORY_CART: + // We don't support overrides for this template type. + break; + default: + throw Error("unsupported template type"); + } +} + +export function applyTemplateUserOverrides( + instDetails: UsingTemplateDetailsRequest, + parsedUri: TalerPayTemplateUri, + templateContract: TemplateContractDetails, + editableDefaults: TemplateContractDetailsDefaults, + params: TemplateParams, +): void { + switch (templateContract.template_type) { + case TemplateType.PAIVANA: + case TemplateType.FIXED_ORDER: { + if (parsedUri.amount) { + if (parsedUri.editableAmount) { + instDetails.amount = params.amount ?? parsedUri.amount; + } else { + instDetails.amount = parsedUri.amount; + } + } else if ( + templateContract.template_type !== TemplateType.PAIVANA && + !templateContract.amount + ) { + instDetails.amount = + params.amount ?? (editableDefaults.amount as AmountString); + } + if (parsedUri.summary) { + if (parsedUri.editableSummary) { + instDetails.summary = params.summary ?? parsedUri.summary; + } else { + instDetails.summary = parsedUri.summary; + } + } else if (!templateContract.summary) { + instDetails.summary = params.summary ?? editableDefaults.summary; + } + break; + } + case TemplateType.INVENTORY_CART: + // We don't support overrides for this template type. + break; + default: + throw Error("unsupported template type"); + } +} + +export async function checkPayForTemplate( + wex: WalletExecutionContext, + req: CheckPayTemplateRequest, +): Promise<CheckPayTemplateReponse> { + const parsedUri = Result.orUndefined( + TalerUris.parseRestricted( + req.talerPayTemplateUri, + TalerUriAction.PayTemplate, + ), + ); + if (!parsedUri) { + throw Error("invalid taler-template URI"); + } + + const merchantApi = new TalerMerchantInstanceHttpClient( + parsedUri.merchantBaseUrl, + wex.http, + undefined, + wex.cancellationToken, + ); + + const templateDetails = succeedOrThrow( + await merchantApi.useTemplateGetInfo(parsedUri.templateId), + ); + + const cfg = succeedOrThrow(await merchantApi.getConfig()); + + const currSet = new Set(Object.keys(cfg.currencies)); + currSet.add(cfg.currency); + + let editableDefaults = templateDetails.editable_defaults; + if (!editableDefaults) { + templateDetails.editable_defaults = editableDefaults = {}; + } + + applyTemplateUriOverrides( + parsedUri, + templateDetails.template_contract, + editableDefaults, + ); + + return { + templateDetails, + supportedCurrencies: [...currSet].sort(), + }; +} + +/** + * Instantiate a pay template. + * + * @returns A taler://pay/ URI pointing to the order + */ +export async function instantiateTemplate( + wex: WalletExecutionContext, + req: PreparePayTemplateRequest, +): Promise<string> { + const parsedUri = Result.orUndefined( + TalerUris.parseRestricted( + req.talerPayTemplateUri, + TalerUriAction.PayTemplate, + ), + ); + + if (!parsedUri) { + throw Error("invalid taler-template URI"); + } + + const merchantApi = new TalerMerchantInstanceHttpClient( + parsedUri.merchantBaseUrl, + wex.http, + undefined, + wex.cancellationToken, + ); + + logger.trace(`parsed URI: ${j2s(parsedUri)}`); + const templateInfo = succeedOrThrow( + await merchantApi.useTemplateGetInfo(parsedUri.templateId), + ); + + let templateDetails: UsingTemplateDetailsRequest; + switch (templateInfo.template_contract.template_type) { + case TalerMerchantApi.TemplateType.FIXED_ORDER: { + templateDetails = { + template_type: TemplateType.FIXED_ORDER, + }; + break; + } + + case TalerMerchantApi.TemplateType.INVENTORY_CART: { + templateDetails = { + template_type: TemplateType.INVENTORY_CART, + }; + break; + } + case TalerMerchantApi.TemplateType.PAIVANA: { + templateDetails = { + template_type: TemplateType.PAIVANA, + paivana_id: parsedUri.sessionId!, + website: parsedUri.fulfillmentUrl!, + }; + break; + } + default: + assertUnreachable(templateInfo.template_contract); + } + + let editableDefaults = templateInfo.editable_defaults; + if (!editableDefaults) { + editableDefaults = templateInfo.editable_defaults = {}; + } + + applyTemplateUserOverrides( + templateDetails, + parsedUri, + templateInfo.template_contract, + editableDefaults, + req.templateParams ?? {}, + ); + + const reqUrl = new URL( + `templates/${parsedUri.templateId}`, + parsedUri.merchantBaseUrl, + ); + const httpReq = await cancelableFetch(wex, reqUrl, { + method: "POST", + body: templateDetails, + }); + const resp = await readSuccessResponseJsonOrThrow( + httpReq, + codecForPostOrderResponse(), + ); + + return TalerUris.stringify({ + type: TalerUriAction.Pay, + merchantBaseUrl: parsedUri.merchantBaseUrl, + orderId: resp.order_id, + sessionId: parsedUri.sessionId ?? "", + claimToken: resp.token, + }); +}