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[],
logName: string,
): ProcessWrapper {
console.log(`spawning process (${command})`);
console.log(`spawning process ${command} with arguments ${args})`);
const proc = spawn(command, args, {
stdio: ["inherit", "pipe", "pipe"],
});

View File

@ -79,3 +79,12 @@ export function str(stringSeq: TemplateStringsArray, ...values: any[]): string {
.fetch(...values);
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;
}
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(
ws: InternalWalletState,
proposalId: string,
@ -642,7 +631,10 @@ async function processDownloadProposalImpl(
const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, {
timeout: getProposalRequestTimeout(proposal),
});
const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codecForProposal());
const r = await readSuccessResponseJsonOrErrorCode(
httpResponse,
codecForProposal(),
);
if (r.isError) {
switch (r.talerErrorResponse.code) {
case TalerErrorCode.ORDERS_ALREADY_CLAIMED:
@ -652,7 +644,8 @@ async function processDownloadProposalImpl(
{
orderId: proposal.orderId,
claimUrl: orderClaimUrl,
});
},
);
default:
throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
}
@ -723,8 +716,9 @@ async function processDownloadProposalImpl(
contractTermsRaw: JSON.stringify(proposalResp.contract_terms),
};
if (
fulfillmentUrl.startsWith("http://") ||
fulfillmentUrl.startsWith("https://")
fulfillmentUrl &&
(fulfillmentUrl.startsWith("http://") ||
fulfillmentUrl.startsWith("https://"))
) {
const differentPurchase = await tx.getIndexed(
Stores.purchases.fulfillmentUrlIndex,
@ -968,15 +962,9 @@ export async function submitPay(
await storePayReplaySuccess(ws, proposalId, sessionId);
}
const nextUrl = getNextUrl(purchase.contractData);
ws.cachedNextUrl[purchase.contractData.fulfillmentUrl] = {
nextUrl,
lastSessionId: sessionId,
};
return {
type: ConfirmPayResultType.Done,
nextUrl,
contractTerms: JSON.parse(purchase.contractTermsRaw),
};
}
@ -1089,7 +1077,6 @@ export async function preparePayForUri(
contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTermsHash: purchase.contractData.contractTermsHash,
paid: true,
nextUrl: r.nextUrl,
amountRaw: Amounts.stringify(purchase.contractData.amount),
amountEffective: Amounts.stringify(purchase.payCostInfo.totalCost),
};

View File

@ -15,7 +15,7 @@
*/
import { HttpRequestLibrary } from "../util/http";
import { NextUrlResult, BalancesResponse } from "../types/walletTypes";
import { BalancesResponse } from "../types/walletTypes";
import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi";
import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo";
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 class InternalWalletState {
cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {};
memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
memoGetPending: AsyncOpMemoSingle<

View File

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

View File

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

View File

@ -314,6 +314,10 @@ export interface Product {
delivery_location?: string;
}
export interface InternationalizedString {
[lang_tag: string]: string;
}
/**
* Contract terms from a merchant.
*/
@ -338,7 +342,7 @@ export class ContractTerms {
*/
summary: string;
summary_i18n?: { [lang_tag: string]: string };
summary_i18n?: InternationalizedString;
/**
* Nonce used to ensure freshness.
@ -420,7 +424,17 @@ export class ContractTerms {
* Fulfillment URL to view the product or
* 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.
@ -1032,14 +1046,14 @@ export const codecForTax = (): Codec<Tax> =>
.property("tax", codecForString())
.build("Tax");
export const codecForI18n = (): Codec<{ [lang_tag: string]: string }> =>
export const codecForInternationalizedString = (): Codec<InternationalizedString> =>
codecForMap(codecForString());
export const codecForProduct = (): Codec<Product> =>
buildCodecForObject<Product>()
.property("product_id", codecOptional(codecForString()))
.property("description", codecForString())
.property("description_i18n", codecOptional(codecForI18n()))
.property("description_i18n", codecOptional(codecForInternationalizedString()))
.property("quantity", codecOptional(codecForNumber()))
.property("unit", codecOptional(codecForString()))
.property("price", codecOptional(codecForString()))
@ -1050,13 +1064,15 @@ export const codecForProduct = (): Codec<Product> =>
export const codecForContractTerms = (): Codec<ContractTerms> =>
buildCodecForObject<ContractTerms>()
.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("h_wire", codecForString())
.property("auto_refund", codecOptional(codecForDuration))
.property("wire_method", codecForString())
.property("summary", codecForString())
.property("summary_i18n", codecOptional(codecForI18n()))
.property("summary_i18n", codecOptional(codecForInternationalizedString()))
.property("nonce", codecForString())
.property("amount", codecForString())
.property("auditors", codecForList(codecForAuditorHandle()))

View File

@ -25,7 +25,15 @@
* Imports.
*/
import { Timestamp } from "../util/time";
import { AmountString, Product } from "./talerTypes";
import {
AmountString,
Product,
InternationalizedString,
MerchantInfo,
codecForInternationalizedString,
codecForMerchantInfo,
codecForProduct,
} from "./talerTypes";
import {
Codec,
buildCodecForObject,
@ -202,7 +210,7 @@ export interface TransactionPayment extends TransactionCommon {
/**
* Additional information about the payment.
*/
info: PaymentShortInfo;
info: OrderShortInfo;
/**
* How far did the wallet get with processing the payment?
@ -220,7 +228,7 @@ export interface TransactionPayment extends TransactionCommon {
amountEffective: AmountString;
}
export interface PaymentShortInfo {
export interface OrderShortInfo {
/**
* Order ID, uniquely identifies the order within a merchant instance
*/
@ -234,7 +242,7 @@ export interface PaymentShortInfo {
/**
* More information about the merchant
*/
merchant: any;
merchant: MerchantInfo;
/**
* 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
*/
summary_i18n?: { [lang_tag: string]: string };
summary_i18n?: InternationalizedString;
/**
* List of products that are part of the order
@ -254,7 +262,18 @@ export interface PaymentShortInfo {
/**
* 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 {
@ -264,7 +283,7 @@ interface TransactionRefund extends TransactionCommon {
refundedTransactionId: string;
// Additional information about the refunded payment
info: PaymentShortInfo;
info: OrderShortInfo;
// Amount that has been refunded by the merchant
amountRaw: AmountString;
@ -321,4 +340,20 @@ export const codecForTransactionsRequest = (): Codec<TransactionsRequest> =>
export const codecForTransactionsResponse = (): Codec<TransactionsResponse> =>
buildCodecForObject<TransactionsResponse>()
.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,
buildCodecForUnion,
} from "../util/codec";
import { AmountString, codecForContractTerms } from "./talerTypes";
import { TransactionError } from "./transactions";
import { AmountString, codecForContractTerms, ContractTerms } from "./talerTypes";
import { TransactionError, OrderShortInfo, codecForOrderShortInfo } from "./transactions";
/**
* Response for the create reserve request to the wallet.
@ -209,8 +209,7 @@ export const enum ConfirmPayResultType {
*/
export interface ConfirmPayResultDone {
type: ConfirmPayResultType.Done;
nextUrl: string;
contractTerms: ContractTerms;
}
export interface ConfirmPayResultPending {
@ -232,7 +231,7 @@ export const codecForConfirmPayResultPending = (): Codec<
export const codecForConfirmPayResultDone = (): Codec<ConfirmPayResultDone> =>
buildCodecForObject<ConfirmPayResultDone>()
.property("type", codecForConstString(ConfirmPayResultType.Done))
.property("nextUrl", codecForString())
.property("contractTerms", codecForContractTerms())
.build("ConfirmPayResultDone");
export const codecForConfirmPayResult = (): Codec<ConfirmPayResult> =>
@ -368,14 +367,6 @@ export interface BenchmarkResult {
repetitions: number;
}
/**
* Cached next URL for a particular session id.
*/
export interface NextUrlResult {
nextUrl: string;
lastSessionId: string | undefined;
}
export const enum PreparePayResultType {
PaymentPossible = "payment-possible",
InsufficientBalance = "insufficient-balance",
@ -388,7 +379,7 @@ export const codecForPreparePayResultPaymentPossible = (): Codec<
buildCodecForObject<PreparePayResultPaymentPossible>()
.property("amountEffective", codecForAmountString())
.property("amountRaw", codecForAmountString())
.property("contractTerms", codecForAny())
.property("contractTerms", codecForContractTerms())
.property("proposalId", codecForString())
.property(
"status",
@ -419,7 +410,6 @@ export const codecForPreparePayResultAlreadyConfirmed = (): Codec<
)
.property("amountEffective", codecForAmountString())
.property("amountRaw", codecForAmountString())
.property("nextUrl", codecForString())
.property("paid", codecForBoolean)
.property("contractTerms", codecForAny())
.property("contractTermsHash", codecForString())
@ -450,7 +440,7 @@ export type PreparePayResult =
export interface PreparePayResultPaymentPossible {
status: PreparePayResultType.PaymentPossible;
proposalId: string;
contractTerms: Record<string, unknown>;
contractTerms: ContractTerms;
amountRaw: string;
amountEffective: string;
}
@ -458,19 +448,16 @@ export interface PreparePayResultPaymentPossible {
export interface PreparePayResultInsufficientBalance {
status: PreparePayResultType.InsufficientBalance;
proposalId: string;
contractTerms: Record<string, unknown>;
contractTerms: ContractTerms;
amountRaw: string;
}
export interface PreparePayResultAlreadyConfirmed {
status: PreparePayResultType.AlreadyConfirmed;
contractTerms: Record<string, unknown>;
contractTerms: ContractTerms;
paid: boolean;
amountRaw: string;
amountEffective: string;
// Only specified if paid.
nextUrl?: string;
contractTermsHash: string;
}

View File

@ -37,10 +37,13 @@ import {
ContractTerms,
codecForContractTerms,
ConfirmPayResultType,
ConfirmPayResult,
getJsonI18n,
} from "taler-wallet-core";
function TalerPayDialog({ talerPayUri }: { talerPayUri: string }): JSX.Element {
const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>();
const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>();
const [payErrMsg, setPayErrMsg] = useState<string | undefined>("");
const [numTries, setNumTries] = useState(0);
const [loading, setLoading] = useState(false);
@ -71,25 +74,25 @@ function TalerPayDialog({ talerPayUri }: { talerPayUri: string }): JSX.Element {
payStatus.status === PreparePayResultType.AlreadyConfirmed &&
numTries === 0
) {
return (
const fulfillmentUrl = payStatus.contractTerms.fulfillment_url;
if (fulfillmentUrl) {
return (
<span>
You have already paid for this article. Click{" "}
<a href={fulfillmentUrl}>here</a> to view it again.
</span>
);
} else {
<span>
You have already paid for this article. Click{" "}
<a href={payStatus.nextUrl}>here</a> to view it again.
</span>
);
You have already paid for this article:{" "}
<em>
{payStatus.contractTerms.fulfillment_message ?? "no message given"}
</em>
</span>;
}
}
let contractTerms: 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>;
}
let contractTerms: ContractTerms = payStatus.contractTerms;
if (!contractTerms) {
return (
@ -122,13 +125,33 @@ function TalerPayDialog({ talerPayUri }: { talerPayUri: string }): JSX.Element {
if (res.type !== ConfirmPayResultType.Done) {
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) {
console.error(e);
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 (
<div>
<p>