wallet-core,harness: implement pay templating

This commit is contained in:
Florian Dold 2023-02-11 14:24:29 +01:00
parent a9073a6797
commit 04ab9f3780
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
12 changed files with 346 additions and 38 deletions

View File

@ -38,6 +38,7 @@ import {
hash,
j2s,
Logger,
MerchantTemplateAddDetails,
parsePaytoUri,
stringToBytes,
TalerProtocolDuration,
@ -66,15 +67,15 @@ import { CoinConfig } from "./denomStructures.js";
import { LibeufinNexusApi, LibeufinSandboxApi } from "./libeufin-apis.js";
import {
codecForMerchantOrderPrivateStatusResponse,
codecForPostOrderResponse,
codecForMerchantPostOrderResponse,
MerchantInstancesResponse,
MerchantOrderPrivateStatusResponse,
PostOrderRequest,
PostOrderResponse,
MerchantPostOrderRequest,
MerchantPostOrderResponse,
TipCreateConfirmation,
TipCreateRequest,
TippingReserveStatus,
} from "./merchantApiTypes.js";
} from "@gnu-taler/taler-util";
import {
createRemoteWallet,
getClientFromRemoteWallet,
@ -1473,15 +1474,31 @@ export namespace MerchantPrivateApi {
export async function createOrder(
merchantService: MerchantServiceInterface,
instanceName: string,
req: PostOrderRequest,
req: MerchantPostOrderRequest,
withAuthorization: WithAuthorization = {},
): Promise<PostOrderResponse> {
): Promise<MerchantPostOrderResponse> {
const baseUrl = merchantService.makeInstanceBaseUrl(instanceName);
let url = new URL("private/orders", baseUrl);
const resp = await axios.post(url.href, req, {
headers: withAuthorization as Record<string, string>,
});
return codecForPostOrderResponse().decode(resp.data);
return codecForMerchantPostOrderResponse().decode(resp.data);
}
export async function createTemplate(
merchantService: MerchantServiceInterface,
instanceName: string,
req: MerchantTemplateAddDetails,
withAuthorization: WithAuthorization = {},
) {
const baseUrl = merchantService.makeInstanceBaseUrl(instanceName);
let url = new URL("private/templates", baseUrl);
const resp = await axios.post(url.href, req, {
headers: withAuthorization as Record<string, string>,
});
if (resp.status !== 204) {
throw Error("failed to create template");
}
}
export async function queryPrivateOrderStatus(

View File

@ -0,0 +1,95 @@
/*
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 { ConfirmPayResultType, Duration, PreparePayResultType, TalerProtocolTimestamp } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
import {
createSimpleTestkudosEnvironment,
withdrawViaBank,
makeTestPayment,
} from "../harness/helpers.js";
/**
* Test for taler://payment-template/ URIs
*/
export async function runPaymentTemplateTest(t: GlobalTestState) {
// Set up test environment
const { wallet, bank, exchange, merchant } =
await createSimpleTestkudosEnvironment(t);
await MerchantPrivateApi.createTemplate(merchant, "default", {
template_id: "template1",
template_description: "my test template",
template_contract: {
minimum_age: 0,
pay_duration: Duration.toTalerProtocolDuration(
Duration.fromSpec({
minutes: 2,
}),
),
summary: "hello, I'm a summary",
},
});
// Withdraw digital cash into the wallet.
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
// Request a template payment
const preparePayResult = await wallet.client.call(
WalletApiOperation.PreparePayForTemplate,
{
talerPayTemplateUri: `taler+http://pay-template/localhost:${merchant.port}/template1?amount=TESTKUDOS:1`,
templateParams: {},
},
);
console.log(preparePayResult);
t.assertTrue(
preparePayResult.status === PreparePayResultType.PaymentPossible,
);
// Pay for it
const r2 = await wallet.client.call(WalletApiOperation.ConfirmPay, {
proposalId: preparePayResult.proposalId,
});
t.assertTrue(r2.type === ConfirmPayResultType.Done);
// Check if payment was successful.
const orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
merchant,
{
orderId: preparePayResult.contractTerms.order_id,
instance: "default",
},
);
t.assertTrue(orderStatus.order_status === "paid");
await wallet.runUntilDone();
}
runPaymentTemplateTest.suites = ["wallet"];

View File

@ -100,6 +100,7 @@ import { runKycTest } from "./test-kyc.js";
import { runPaymentAbortTest } from "./test-payment-abort.js";
import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js";
import { runWalletBalanceTest } from "./test-wallet-balance.js";
import { runPaymentTemplateTest } from "./test-payment-template.js";
/**
* Test runner.
@ -163,6 +164,7 @@ const allTests: TestMainFunction[] = [
runPaymentIdempotencyTest,
runPaymentMultipleTest,
runPaymentTest,
runPaymentTemplateTest,
runPaymentAbortTest,
runPaymentTransientTest,
runPaymentZeroTest,

View File

@ -21,7 +21,7 @@
"baseUrl": "./src",
"typeRoots": ["./node_modules/@types"]
},
"include": ["src/**/*"],
"include": ["src/**/*", "../taler-util/src/merchant-api-types.ts"],
"references": [
{
"path": "../taler-wallet-core/"

View File

@ -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";

View File

@ -26,7 +26,6 @@
*/
import {
MerchantContractTerms,
Duration,
Codec,
buildCodecForObject,
codecForString,
@ -47,7 +46,7 @@ import {
TalerProtocolTimestamp,
} from "@gnu-taler/taler-util";
export interface PostOrderRequest {
export interface MerchantPostOrderRequest {
// The order must at least contain the minimal
// order detail, but can override all
order: Partial<MerchantContractTerms>;
@ -71,18 +70,18 @@ export interface PostOrderRequest {
export type ClaimToken = string;
export interface PostOrderResponse {
export interface MerchantPostOrderResponse {
order_id: string;
token?: ClaimToken;
}
export const codecForPostOrderResponse = (): Codec<PostOrderResponse> =>
buildCodecForObject<PostOrderResponse>()
export const codecForMerchantPostOrderResponse = (): Codec<MerchantPostOrderResponse> =>
buildCodecForObject<MerchantPostOrderResponse>()
.property("order_id", codecForString())
.property("token", codecOptional(codecForString()))
.build("PostOrderResponse");
export const codecForRefundDetails = (): Codec<RefundDetails> =>
export const codecForMerchantRefundDetails = (): Codec<RefundDetails> =>
buildCodecForObject<RefundDetails>()
.property("reason", codecForString())
.property("pending", codecForBoolean())
@ -90,9 +89,9 @@ export const codecForRefundDetails = (): Codec<RefundDetails> =>
.property("timestamp", codecForTimestamp)
.build("PostOrderResponse");
export const codecForCheckPaymentPaidResponse =
(): Codec<CheckPaymentPaidResponse> =>
buildCodecForObject<CheckPaymentPaidResponse>()
export const codecForMerchantCheckPaymentPaidResponse =
(): Codec<MerchantCheckPaymentPaidResponse> =>
buildCodecForObject<MerchantCheckPaymentPaidResponse>()
.property("order_status_url", codecForString())
.property("order_status", codecForConstString("paid"))
.property("refunded", codecForBoolean())
@ -128,13 +127,13 @@ export const codecForMerchantOrderPrivateStatusResponse =
(): Codec<MerchantOrderPrivateStatusResponse> =>
buildCodecForUnion<MerchantOrderPrivateStatusResponse>()
.discriminateOn("order_status")
.alternative("paid", codecForCheckPaymentPaidResponse())
.alternative("paid", codecForMerchantCheckPaymentPaidResponse())
.alternative("unpaid", codecForCheckPaymentUnpaidResponse())
.alternative("claimed", codecForCheckPaymentClaimedResponse())
.build("MerchantOrderPrivateStatusResponse");
export type MerchantOrderPrivateStatusResponse =
| CheckPaymentPaidResponse
| MerchantCheckPaymentPaidResponse
| CheckPaymentUnpaidResponse
| CheckPaymentClaimedResponse;
@ -145,7 +144,7 @@ export interface CheckPaymentClaimedResponse {
contract_terms: MerchantContractTerms;
}
export interface CheckPaymentPaidResponse {
export interface MerchantCheckPaymentPaidResponse {
// did the customer pay for this contract
order_status: "paid";
@ -334,3 +333,36 @@ export interface MerchantInstanceDetail {
// 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;
}

View File

@ -1481,10 +1481,11 @@ export const codecForWithdrawResponse = (): Codec<ExchangeWithdrawResponse> =>
.property("ev_sig", codecForBlindedDenominationSignature())
.build("WithdrawResponse");
export const codecForWithdrawBatchResponse = (): Codec<ExchangeWithdrawBatchResponse> =>
buildCodecForObject<ExchangeWithdrawBatchResponse>()
.property("ev_sigs", codecForList(codecForWithdrawResponse()))
.build("WithdrawBatchResponse");
export const codecForWithdrawBatchResponse =
(): Codec<ExchangeWithdrawBatchResponse> =>
buildCodecForObject<ExchangeWithdrawBatchResponse>()
.property("ev_sigs", codecForList(codecForWithdrawResponse()))
.build("WithdrawBatchResponse");
export const codecForMerchantPayResponse = (): Codec<MerchantPayResponse> =>
buildCodecForObject<MerchantPayResponse>()
@ -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<WalletKycUuid> =>
.property("requirement_row", codecForNumber())
.property("h_payto", codecForString())
.build("WalletKycUuid");
export interface MerchantUsingTemplateDetails {
summary?: string;
amount?: AmountString;
}

View File

@ -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");
});

View File

@ -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<string, string>;
}
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<string, string> = {};
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;
}

View File

@ -1418,6 +1418,17 @@ export const codecForPreparePayRequest = (): Codec<PreparePayRequest> =>
.property("talerPayUri", codecForString())
.build("PreparePay");
export interface PreparePayTemplateRequest {
talerPayTemplateUri: string;
templateParams: Record<string, string>;
}
export const codecForPreparePayTemplateRequest = (): Codec<PreparePayTemplateRequest> =>
buildCodecForObject<PreparePayTemplateRequest>()
.property("talerPayTemplateUri", codecForString())
.property("templateParams", codecForAny())
.build("PreparePayTemplate");
export interface ConfirmPayRequest {
proposalId: string;
sessionId?: string;

View File

@ -78,6 +78,7 @@ import {
PrepareDepositResponse,
PreparePayRequest,
PreparePayResult,
PreparePayTemplateRequest,
PreparePeerPullPaymentRequest,
PreparePeerPullPaymentResponse,
PreparePeerPushPaymentRequest,
@ -126,6 +127,7 @@ export enum WalletApiOperation {
WithdrawTestkudos = "withdrawTestkudos",
WithdrawTestBalance = "withdrawTestBalance",
PreparePayForUri = "preparePayForUri",
PreparePayForTemplate = "preparePayForTemplate",
GetContractTermsDetails = "getContractTermsDetails",
RunIntegrationTest = "runIntegrationTest",
TestCrypto = "testCrypto",
@ -313,7 +315,7 @@ export type AcceptManualWithdrawalOp = {
// group: Merchant Payments
/**
* Prepare to make a payment
* Prepare to make a payment based on a taler://pay/ URI.
*/
export type PreparePayForUriOp = {
op: WalletApiOperation.PreparePayForUri;
@ -321,6 +323,15 @@ export type PreparePayForUriOp = {
response: PreparePayResult;
};
/**
* Prepare to make a payment based on a taler://pay-template/ URI.
*/
export type PreparePayForTemplateOp = {
op: WalletApiOperation.PreparePayForTemplate;
request: PreparePayTemplateRequest;
response: PreparePayResult;
};
export type GetContractTermsDetailsOp = {
op: WalletApiOperation.GetContractTermsDetails;
request: GetContractTermsDetailsRequest;
@ -835,6 +846,7 @@ export type WalletOperations = {
[WalletApiOperation.GetVersion]: GetVersionOp;
[WalletApiOperation.WithdrawFakebank]: WithdrawFakebankOp;
[WalletApiOperation.PreparePayForUri]: PreparePayForUriOp;
[WalletApiOperation.PreparePayForTemplate]: PreparePayForTemplateOp;
[WalletApiOperation.GetContractTermsDetails]: GetContractTermsDetailsOp;
[WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp;
[WalletApiOperation.ConfirmPay]: ConfirmPayOp;

View File

@ -56,8 +56,10 @@ import {
codecForInitiatePeerPushPaymentRequest,
codecForIntegrationTestArgs,
codecForListKnownBankAccounts,
codecForMerchantPostOrderResponse,
codecForPrepareDepositRequest,
codecForPreparePayRequest,
codecForPreparePayTemplateRequest,
codecForPreparePeerPullPaymentRequest,
codecForPreparePeerPushPaymentRequest,
codecForPrepareRefundRequest,
@ -77,6 +79,7 @@ import {
CoinDumpJson,
CoinRefreshRequest,
CoinStatus,
constructPayUri,
CoreApiResponse,
DenominationInfo,
DenomOperationMap,
@ -88,7 +91,6 @@ import {
ExchangesListResponse,
ExchangeTosStatusDetails,
FeeDescription,
GetBalanceDetailRequest,
GetExchangeTosResult,
InitResponse,
j2s,
@ -96,7 +98,9 @@ import {
KnownBankAccountsInfo,
Logger,
ManualWithdrawalDetails,
MerchantUsingTemplateDetails,
NotificationType,
parsePayTemplateUri,
parsePaytoUri,
RefreshReason,
TalerErrorCode,
@ -156,11 +160,7 @@ import {
runBackupCycle,
} from "./operations/backup/index.js";
import { setWalletDeviceId } from "./operations/backup/state.js";
import {
getBalanceDetail,
getBalances,
getMerchantPaymentBalanceDetails,
} from "./operations/balance.js";
import { getBalanceDetail, getBalances } from "./operations/balance.js";
import {
getExchangeTosStatus,
makeExchangeListItem,
@ -186,7 +186,6 @@ import {
} from "./operations/exchanges.js";
import { getMerchantInfo } from "./operations/merchants.js";
import {
abortPay as abortPay,
applyRefund,
applyRefundFromPurchaseId,
confirmPay,
@ -1171,11 +1170,50 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
await runPending(ws, true);
return {};
}
// FIXME: Deprecate one of the aliases!
case WalletApiOperation.PreparePayForUri: {
const req = codecForPreparePayRequest().decode(payload);
return await preparePayForUri(ws, req.talerPayUri);
}
case WalletApiOperation.PreparePayForTemplate: {
const req = codecForPreparePayTemplateRequest().decode(payload);
const url = parsePayTemplateUri(req.talerPayTemplateUri);
const templateDetails: MerchantUsingTemplateDetails = {};
if (!url) {
throw Error("invalid taler-template URI");
}
if (
url.templateParams.amount &&
typeof url.templateParams.amount === "string"
) {
templateDetails.amount =
req.templateParams.amount ?? url.templateParams.amount;
}
if (
url.templateParams.summary &&
typeof url.templateParams.summary === "string"
) {
templateDetails.summary =
req.templateParams.summary ?? url.templateParams.summary;
}
const reqUrl = new URL(
`templates/${url.templateId}`,
url.merchantBaseUrl,
);
const httpReq = await ws.http.postJson(reqUrl.href, templateDetails);
const resp = await readSuccessResponseJsonOrThrow(
httpReq,
codecForMerchantPostOrderResponse(),
);
const payUri = constructPayUri(
url.merchantBaseUrl,
resp.order_id,
"",
resp.token,
);
return await preparePayForUri(ws, payUri);
}
case WalletApiOperation.ConfirmPay: {
const req = codecForConfirmPayRequest().decode(payload);
return await confirmPay(ws, req.proposalId, req.sessionId);
@ -1434,6 +1472,8 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
case WalletApiOperation.GetVersion: {
return getVersion(ws);
}
//default:
// assertUnreachable(operation);
}
throw TalerError.fromDetail(
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,