implement fulfillment_message and make fulfillment_url optional

This commit is contained in:
Florian Dold 2020-08-24 19:39:09 +05:30
parent 69c4950762
commit 0e88ef9bd2
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
10 changed files with 140 additions and 81 deletions

View File

@ -349,7 +349,7 @@ export class GlobalTestState {
args: string[], args: string[],
logName: string, logName: string,
): ProcessWrapper { ): ProcessWrapper {
console.log(`spawning process (${command})`); console.log(`spawning process ${command} with arguments ${args})`);
const proc = spawn(command, args, { const proc = spawn(command, args, {
stdio: ["inherit", "pipe", "pipe"], stdio: ["inherit", "pipe", "pipe"],
}); });

View File

@ -79,3 +79,12 @@ export function str(stringSeq: TemplateStringsArray, ...values: any[]): string {
.fetch(...values); .fetch(...values);
return tr; return tr;
} }
/**
* Get an internationalized string (based on the globally set, current language)
* from a JSON object. Fall back to the default language of the JSON object
* if no match exists.
*/
export function getJsonI18n<K extends string>(obj: Record<K, string>, key: K): string {
return obj[key];
}

View File

@ -513,17 +513,6 @@ async function recordConfirmPay(
return t; return t;
} }
function getNextUrl(contractData: WalletContractData): string {
const f = contractData.fulfillmentUrl;
if (f.startsWith("http://") || f.startsWith("https://")) {
const fu = new URL(contractData.fulfillmentUrl);
fu.searchParams.set("order_id", contractData.orderId);
return fu.href;
} else {
return f;
}
}
async function incrementProposalRetry( async function incrementProposalRetry(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -642,7 +631,10 @@ async function processDownloadProposalImpl(
const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, { const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, {
timeout: getProposalRequestTimeout(proposal), timeout: getProposalRequestTimeout(proposal),
}); });
const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codecForProposal()); const r = await readSuccessResponseJsonOrErrorCode(
httpResponse,
codecForProposal(),
);
if (r.isError) { if (r.isError) {
switch (r.talerErrorResponse.code) { switch (r.talerErrorResponse.code) {
case TalerErrorCode.ORDERS_ALREADY_CLAIMED: case TalerErrorCode.ORDERS_ALREADY_CLAIMED:
@ -652,7 +644,8 @@ async function processDownloadProposalImpl(
{ {
orderId: proposal.orderId, orderId: proposal.orderId,
claimUrl: orderClaimUrl, claimUrl: orderClaimUrl,
}); },
);
default: default:
throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
} }
@ -723,8 +716,9 @@ async function processDownloadProposalImpl(
contractTermsRaw: JSON.stringify(proposalResp.contract_terms), contractTermsRaw: JSON.stringify(proposalResp.contract_terms),
}; };
if ( if (
fulfillmentUrl.startsWith("http://") || fulfillmentUrl &&
fulfillmentUrl.startsWith("https://") (fulfillmentUrl.startsWith("http://") ||
fulfillmentUrl.startsWith("https://"))
) { ) {
const differentPurchase = await tx.getIndexed( const differentPurchase = await tx.getIndexed(
Stores.purchases.fulfillmentUrlIndex, Stores.purchases.fulfillmentUrlIndex,
@ -968,15 +962,9 @@ export async function submitPay(
await storePayReplaySuccess(ws, proposalId, sessionId); await storePayReplaySuccess(ws, proposalId, sessionId);
} }
const nextUrl = getNextUrl(purchase.contractData);
ws.cachedNextUrl[purchase.contractData.fulfillmentUrl] = {
nextUrl,
lastSessionId: sessionId,
};
return { return {
type: ConfirmPayResultType.Done, type: ConfirmPayResultType.Done,
nextUrl, contractTerms: JSON.parse(purchase.contractTermsRaw),
}; };
} }
@ -1089,7 +1077,6 @@ export async function preparePayForUri(
contractTerms: JSON.parse(purchase.contractTermsRaw), contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTermsHash: purchase.contractData.contractTermsHash, contractTermsHash: purchase.contractData.contractTermsHash,
paid: true, paid: true,
nextUrl: r.nextUrl,
amountRaw: Amounts.stringify(purchase.contractData.amount), amountRaw: Amounts.stringify(purchase.contractData.amount),
amountEffective: Amounts.stringify(purchase.payCostInfo.totalCost), amountEffective: Amounts.stringify(purchase.payCostInfo.totalCost),
}; };

View File

@ -15,7 +15,7 @@
*/ */
import { HttpRequestLibrary } from "../util/http"; import { HttpRequestLibrary } from "../util/http";
import { NextUrlResult, BalancesResponse } from "../types/walletTypes"; import { BalancesResponse } from "../types/walletTypes";
import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi"; import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi";
import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo"; import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
@ -32,7 +32,6 @@ export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock"; export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock";
export class InternalWalletState { export class InternalWalletState {
cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {};
memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
memoGetPending: AsyncOpMemoSingle< memoGetPending: AsyncOpMemoSingle<

View File

@ -35,7 +35,7 @@ import {
PaymentStatus, PaymentStatus,
WithdrawalType, WithdrawalType,
WithdrawalDetails, WithdrawalDetails,
PaymentShortInfo, OrderShortInfo,
} from "../types/transactions"; } from "../types/transactions";
import { getFundingPaytoUris } from "./reserves"; import { getFundingPaytoUris } from "./reserves";
@ -234,7 +234,7 @@ export async function getTransactions(
if (!proposal) { if (!proposal) {
return; return;
} }
const info: PaymentShortInfo = { const info: OrderShortInfo = {
fulfillmentUrl: pr.contractData.fulfillmentUrl, fulfillmentUrl: pr.contractData.fulfillmentUrl,
merchant: pr.contractData.merchant, merchant: pr.contractData.merchant,
orderId: pr.contractData.orderId, orderId: pr.contractData.orderId,

View File

@ -31,6 +31,7 @@ import {
ExchangeSignKeyJson, ExchangeSignKeyJson,
MerchantInfo, MerchantInfo,
Product, Product,
InternationalizedString,
} from "./talerTypes"; } from "./talerTypes";
import { Index, Store } from "../util/query"; import { Index, Store } from "../util/query";
@ -1270,8 +1271,10 @@ export interface AllowedExchangeInfo {
export interface WalletContractData { export interface WalletContractData {
products?: Product[]; products?: Product[];
summaryI18n: { [lang_tag: string]: string } | undefined; summaryI18n: { [lang_tag: string]: string } | undefined;
fulfillmentUrl: string; fulfillmentUrl?: string;
contractTermsHash: string; contractTermsHash: string;
fulfillmentMessage?: string;
fulfillmentMessageI18n?: InternationalizedString;
merchantSig: string; merchantSig: string;
merchantPub: string; merchantPub: string;
merchant: MerchantInfo; merchant: MerchantInfo;

View File

@ -314,6 +314,10 @@ export interface Product {
delivery_location?: string; delivery_location?: string;
} }
export interface InternationalizedString {
[lang_tag: string]: string;
}
/** /**
* Contract terms from a merchant. * Contract terms from a merchant.
*/ */
@ -338,7 +342,7 @@ export class ContractTerms {
*/ */
summary: string; summary: string;
summary_i18n?: { [lang_tag: string]: string }; summary_i18n?: InternationalizedString;
/** /**
* Nonce used to ensure freshness. * Nonce used to ensure freshness.
@ -420,7 +424,17 @@ export class ContractTerms {
* Fulfillment URL to view the product or * Fulfillment URL to view the product or
* delivery status. * delivery status.
*/ */
fulfillment_url: string; fulfillment_url?: string;
/**
* Plain text fulfillment message in the merchant's default language.
*/
fulfillment_message?: string;
/**
* Internationalized fulfillment messages.
*/
fulfillment_message_i18n?: InternationalizedString;
/** /**
* Share of the wire fee that must be settled with one payment. * Share of the wire fee that must be settled with one payment.
@ -1032,14 +1046,14 @@ export const codecForTax = (): Codec<Tax> =>
.property("tax", codecForString()) .property("tax", codecForString())
.build("Tax"); .build("Tax");
export const codecForI18n = (): Codec<{ [lang_tag: string]: string }> => export const codecForInternationalizedString = (): Codec<InternationalizedString> =>
codecForMap(codecForString()); codecForMap(codecForString());
export const codecForProduct = (): Codec<Product> => export const codecForProduct = (): Codec<Product> =>
buildCodecForObject<Product>() buildCodecForObject<Product>()
.property("product_id", codecOptional(codecForString())) .property("product_id", codecOptional(codecForString()))
.property("description", codecForString()) .property("description", codecForString())
.property("description_i18n", codecOptional(codecForI18n())) .property("description_i18n", codecOptional(codecForInternationalizedString()))
.property("quantity", codecOptional(codecForNumber())) .property("quantity", codecOptional(codecForNumber()))
.property("unit", codecOptional(codecForString())) .property("unit", codecOptional(codecForString()))
.property("price", codecOptional(codecForString())) .property("price", codecOptional(codecForString()))
@ -1050,13 +1064,15 @@ export const codecForProduct = (): Codec<Product> =>
export const codecForContractTerms = (): Codec<ContractTerms> => export const codecForContractTerms = (): Codec<ContractTerms> =>
buildCodecForObject<ContractTerms>() buildCodecForObject<ContractTerms>()
.property("order_id", codecForString()) .property("order_id", codecForString())
.property("fulfillment_url", codecForString()) .property("fulfillment_url", codecOptional(codecForString()))
.property("fulfillment_message", codecOptional(codecForString()))
.property("fulfillment_message_i18n", codecOptional(codecForInternationalizedString()))
.property("merchant_base_url", codecForString()) .property("merchant_base_url", codecForString())
.property("h_wire", codecForString()) .property("h_wire", codecForString())
.property("auto_refund", codecOptional(codecForDuration)) .property("auto_refund", codecOptional(codecForDuration))
.property("wire_method", codecForString()) .property("wire_method", codecForString())
.property("summary", codecForString()) .property("summary", codecForString())
.property("summary_i18n", codecOptional(codecForI18n())) .property("summary_i18n", codecOptional(codecForInternationalizedString()))
.property("nonce", codecForString()) .property("nonce", codecForString())
.property("amount", codecForString()) .property("amount", codecForString())
.property("auditors", codecForList(codecForAuditorHandle())) .property("auditors", codecForList(codecForAuditorHandle()))

View File

@ -25,7 +25,15 @@
* Imports. * Imports.
*/ */
import { Timestamp } from "../util/time"; import { Timestamp } from "../util/time";
import { AmountString, Product } from "./talerTypes"; import {
AmountString,
Product,
InternationalizedString,
MerchantInfo,
codecForInternationalizedString,
codecForMerchantInfo,
codecForProduct,
} from "./talerTypes";
import { import {
Codec, Codec,
buildCodecForObject, buildCodecForObject,
@ -202,7 +210,7 @@ export interface TransactionPayment extends TransactionCommon {
/** /**
* Additional information about the payment. * Additional information about the payment.
*/ */
info: PaymentShortInfo; info: OrderShortInfo;
/** /**
* How far did the wallet get with processing the payment? * How far did the wallet get with processing the payment?
@ -220,7 +228,7 @@ export interface TransactionPayment extends TransactionCommon {
amountEffective: AmountString; amountEffective: AmountString;
} }
export interface PaymentShortInfo { export interface OrderShortInfo {
/** /**
* Order ID, uniquely identifies the order within a merchant instance * Order ID, uniquely identifies the order within a merchant instance
*/ */
@ -234,7 +242,7 @@ export interface PaymentShortInfo {
/** /**
* More information about the merchant * More information about the merchant
*/ */
merchant: any; merchant: MerchantInfo;
/** /**
* Summary of the order, given by the merchant * Summary of the order, given by the merchant
@ -244,7 +252,7 @@ export interface PaymentShortInfo {
/** /**
* Map from IETF BCP 47 language tags to localized summaries * Map from IETF BCP 47 language tags to localized summaries
*/ */
summary_i18n?: { [lang_tag: string]: string }; summary_i18n?: InternationalizedString;
/** /**
* List of products that are part of the order * List of products that are part of the order
@ -254,7 +262,18 @@ export interface PaymentShortInfo {
/** /**
* URL of the fulfillment, given by the merchant * URL of the fulfillment, given by the merchant
*/ */
fulfillmentUrl: string; fulfillmentUrl?: string;
/**
* Plain text message that should be shown to the user
* when the payment is complete.
*/
fulfillmentMessage?: string;
/**
* Translations of fulfillmentMessage.
*/
fulfillmentMessage_i18n?: InternationalizedString;
} }
interface TransactionRefund extends TransactionCommon { interface TransactionRefund extends TransactionCommon {
@ -264,7 +283,7 @@ interface TransactionRefund extends TransactionCommon {
refundedTransactionId: string; refundedTransactionId: string;
// Additional information about the refunded payment // Additional information about the refunded payment
info: PaymentShortInfo; info: OrderShortInfo;
// Amount that has been refunded by the merchant // Amount that has been refunded by the merchant
amountRaw: AmountString; amountRaw: AmountString;
@ -322,3 +341,19 @@ export const codecForTransactionsResponse = (): Codec<TransactionsResponse> =>
buildCodecForObject<TransactionsResponse>() buildCodecForObject<TransactionsResponse>()
.property("transactions", codecForList(codecForAny())) .property("transactions", codecForList(codecForAny()))
.build("TransactionsResponse"); .build("TransactionsResponse");
export const codecForOrderShortInfo = (): Codec<OrderShortInfo> =>
buildCodecForObject<OrderShortInfo>()
.property("contractTermsHash", codecForString())
.property("fulfillmentMessage", codecOptional(codecForString()))
.property(
"fulfillmentMessage_i18n",
codecOptional(codecForInternationalizedString()),
)
.property("fulfillmentUrl", codecOptional(codecForString()))
.property("merchant", codecForMerchantInfo())
.property("orderId", codecForString())
.property("products", codecOptional(codecForList(codecForProduct())))
.property("summary", codecForString())
.property("summary_i18n", codecOptional(codecForInternationalizedString()))
.build("OrderShortInfo");

View File

@ -50,8 +50,8 @@ import {
codecForAny, codecForAny,
buildCodecForUnion, buildCodecForUnion,
} from "../util/codec"; } from "../util/codec";
import { AmountString, codecForContractTerms } from "./talerTypes"; import { AmountString, codecForContractTerms, ContractTerms } from "./talerTypes";
import { TransactionError } from "./transactions"; import { TransactionError, OrderShortInfo, codecForOrderShortInfo } from "./transactions";
/** /**
* Response for the create reserve request to the wallet. * Response for the create reserve request to the wallet.
@ -209,8 +209,7 @@ export const enum ConfirmPayResultType {
*/ */
export interface ConfirmPayResultDone { export interface ConfirmPayResultDone {
type: ConfirmPayResultType.Done; type: ConfirmPayResultType.Done;
contractTerms: ContractTerms;
nextUrl: string;
} }
export interface ConfirmPayResultPending { export interface ConfirmPayResultPending {
@ -232,7 +231,7 @@ export const codecForConfirmPayResultPending = (): Codec<
export const codecForConfirmPayResultDone = (): Codec<ConfirmPayResultDone> => export const codecForConfirmPayResultDone = (): Codec<ConfirmPayResultDone> =>
buildCodecForObject<ConfirmPayResultDone>() buildCodecForObject<ConfirmPayResultDone>()
.property("type", codecForConstString(ConfirmPayResultType.Done)) .property("type", codecForConstString(ConfirmPayResultType.Done))
.property("nextUrl", codecForString()) .property("contractTerms", codecForContractTerms())
.build("ConfirmPayResultDone"); .build("ConfirmPayResultDone");
export const codecForConfirmPayResult = (): Codec<ConfirmPayResult> => export const codecForConfirmPayResult = (): Codec<ConfirmPayResult> =>
@ -368,14 +367,6 @@ export interface BenchmarkResult {
repetitions: number; repetitions: number;
} }
/**
* Cached next URL for a particular session id.
*/
export interface NextUrlResult {
nextUrl: string;
lastSessionId: string | undefined;
}
export const enum PreparePayResultType { export const enum PreparePayResultType {
PaymentPossible = "payment-possible", PaymentPossible = "payment-possible",
InsufficientBalance = "insufficient-balance", InsufficientBalance = "insufficient-balance",
@ -388,7 +379,7 @@ export const codecForPreparePayResultPaymentPossible = (): Codec<
buildCodecForObject<PreparePayResultPaymentPossible>() buildCodecForObject<PreparePayResultPaymentPossible>()
.property("amountEffective", codecForAmountString()) .property("amountEffective", codecForAmountString())
.property("amountRaw", codecForAmountString()) .property("amountRaw", codecForAmountString())
.property("contractTerms", codecForAny()) .property("contractTerms", codecForContractTerms())
.property("proposalId", codecForString()) .property("proposalId", codecForString())
.property( .property(
"status", "status",
@ -419,7 +410,6 @@ export const codecForPreparePayResultAlreadyConfirmed = (): Codec<
) )
.property("amountEffective", codecForAmountString()) .property("amountEffective", codecForAmountString())
.property("amountRaw", codecForAmountString()) .property("amountRaw", codecForAmountString())
.property("nextUrl", codecForString())
.property("paid", codecForBoolean) .property("paid", codecForBoolean)
.property("contractTerms", codecForAny()) .property("contractTerms", codecForAny())
.property("contractTermsHash", codecForString()) .property("contractTermsHash", codecForString())
@ -450,7 +440,7 @@ export type PreparePayResult =
export interface PreparePayResultPaymentPossible { export interface PreparePayResultPaymentPossible {
status: PreparePayResultType.PaymentPossible; status: PreparePayResultType.PaymentPossible;
proposalId: string; proposalId: string;
contractTerms: Record<string, unknown>; contractTerms: ContractTerms;
amountRaw: string; amountRaw: string;
amountEffective: string; amountEffective: string;
} }
@ -458,19 +448,16 @@ export interface PreparePayResultPaymentPossible {
export interface PreparePayResultInsufficientBalance { export interface PreparePayResultInsufficientBalance {
status: PreparePayResultType.InsufficientBalance; status: PreparePayResultType.InsufficientBalance;
proposalId: string; proposalId: string;
contractTerms: Record<string, unknown>; contractTerms: ContractTerms;
amountRaw: string; amountRaw: string;
} }
export interface PreparePayResultAlreadyConfirmed { export interface PreparePayResultAlreadyConfirmed {
status: PreparePayResultType.AlreadyConfirmed; status: PreparePayResultType.AlreadyConfirmed;
contractTerms: Record<string, unknown>; contractTerms: ContractTerms;
paid: boolean; paid: boolean;
amountRaw: string; amountRaw: string;
amountEffective: string; amountEffective: string;
// Only specified if paid.
nextUrl?: string;
contractTermsHash: string; contractTermsHash: string;
} }

View File

@ -37,10 +37,13 @@ import {
ContractTerms, ContractTerms,
codecForContractTerms, codecForContractTerms,
ConfirmPayResultType, ConfirmPayResultType,
ConfirmPayResult,
getJsonI18n,
} from "taler-wallet-core"; } from "taler-wallet-core";
function TalerPayDialog({ talerPayUri }: { talerPayUri: string }): JSX.Element { function TalerPayDialog({ talerPayUri }: { talerPayUri: string }): JSX.Element {
const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>(); const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>();
const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>();
const [payErrMsg, setPayErrMsg] = useState<string | undefined>(""); const [payErrMsg, setPayErrMsg] = useState<string | undefined>("");
const [numTries, setNumTries] = useState(0); const [numTries, setNumTries] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -71,25 +74,25 @@ function TalerPayDialog({ talerPayUri }: { talerPayUri: string }): JSX.Element {
payStatus.status === PreparePayResultType.AlreadyConfirmed && payStatus.status === PreparePayResultType.AlreadyConfirmed &&
numTries === 0 numTries === 0
) { ) {
const fulfillmentUrl = payStatus.contractTerms.fulfillment_url;
if (fulfillmentUrl) {
return ( return (
<span> <span>
You have already paid for this article. Click{" "} You have already paid for this article. Click{" "}
<a href={payStatus.nextUrl}>here</a> to view it again. <a href={fulfillmentUrl}>here</a> to view it again.
</span> </span>
); );
} else {
<span>
You have already paid for this article:{" "}
<em>
{payStatus.contractTerms.fulfillment_message ?? "no message given"}
</em>
</span>;
}
} }
let contractTerms: ContractTerms; let contractTerms: ContractTerms = payStatus.contractTerms;
try {
contractTerms = codecForContractTerms().decode(payStatus.contractTerms);
} catch (e) {
// This should never happen, as the wallet is supposed to check the contract terms
// before storing them.
console.error(e);
console.log("raw contract terms were", payStatus.contractTerms);
return <span>Invalid contract terms.</span>;
}
if (!contractTerms) { if (!contractTerms) {
return ( return (
@ -122,13 +125,33 @@ function TalerPayDialog({ talerPayUri }: { talerPayUri: string }): JSX.Element {
if (res.type !== ConfirmPayResultType.Done) { if (res.type !== ConfirmPayResultType.Done) {
throw Error("payment pending"); throw Error("payment pending");
} }
document.location.href = res.nextUrl; const fu = res.contractTerms.fulfillment_url;
if (fu) {
document.location.href = fu;
}
setPayResult(res);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setPayErrMsg(e.message); setPayErrMsg(e.message);
} }
}; };
if (payResult && payResult.type === ConfirmPayResultType.Done) {
if (payResult.contractTerms.fulfillment_message) {
const obj = {
fulfillment_message: payResult.contractTerms.fulfillment_message,
fulfillment_message_i18n: payResult.contractTerms.fulfillment_message_i18n,
};
const msg = getJsonI18n(obj, "fulfillment_message")
return <div>
<p>Payment succeeded.</p>
<p>{msg}</p>
</div>;
} else {
return <span>Redirecting ...</span>;
}
}
return ( return (
<div> <div>
<p> <p>