wallet-core: DD37 fixes and FIXME comments for merchant payments

This commit is contained in:
Florian Dold 2023-05-25 11:13:19 +02:00
parent 4859883c9a
commit 0406160869
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
2 changed files with 96 additions and 34 deletions

View File

@ -86,8 +86,6 @@ export enum TransactionMajorState {
Failed = "failed", Failed = "failed",
// Only used for the notification, never in the transaction history // Only used for the notification, never in the transaction history
Deleted = "deleted", Deleted = "deleted",
// Placeholder until D37 is fully implemented
Unknown = "unknown",
} }
export enum TransactionMinorState { export enum TransactionMinorState {

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler This file is part of GNU Taler
(C) 2019-2022 Taler Systems S.A. (C) 2019-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the 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 terms of the GNU General Public License as published by the Free Software
@ -40,7 +40,6 @@ import {
CoinRefreshRequest, CoinRefreshRequest,
ConfirmPayResult, ConfirmPayResult,
ConfirmPayResultType, ConfirmPayResultType,
constructPayUri,
ContractTermsUtil, ContractTermsUtil,
Duration, Duration,
encodeCrock, encodeCrock,
@ -63,6 +62,7 @@ import {
randomBytes, randomBytes,
RefreshReason, RefreshReason,
StartRefundQueryForUriResponse, StartRefundQueryForUriResponse,
stringifyTalerUri,
TalerError, TalerError,
TalerErrorCode, TalerErrorCode,
TalerErrorDetail, TalerErrorDetail,
@ -197,16 +197,25 @@ async function failProposalPermanently(
proposalId: string, proposalId: string,
err: TalerErrorDetail, err: TalerErrorDetail,
): Promise<void> { ): Promise<void> {
await ws.db const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const transitionInfo = await ws.db
.mktx((x) => [x.purchases]) .mktx((x) => [x.purchases])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId); const p = await tx.purchases.get(proposalId);
if (!p) { if (!p) {
return; return;
} }
// FIXME: We don't store the error detail here?!
const oldTxState = computePayMerchantTransactionState(p);
p.purchaseStatus = PurchaseStatus.FailedClaim; p.purchaseStatus = PurchaseStatus.FailedClaim;
const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p); await tx.purchases.put(p);
return { oldTxState, newTxState };
}); });
notifyTransition(ws, transactionId, transitionInfo);
} }
function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration { function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration {
@ -226,8 +235,6 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
/** /**
* Return the proposal download data for a purchase, throw if not available. * Return the proposal download data for a purchase, throw if not available.
*
* (Async since in the future this will query the DB.)
*/ */
export async function expectProposalDownload( export async function expectProposalDownload(
ws: InternalWalletState, ws: InternalWalletState,
@ -314,10 +321,9 @@ export function extractContractData(
}; };
} }
export async function processDownloadProposal( async function processDownloadProposal(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
options: object = {},
): Promise<OperationAttemptResult> { ): Promise<OperationAttemptResult> {
const proposal = await ws.db const proposal = await ws.db
.mktx((x) => [x.purchases]) .mktx((x) => [x.purchases])
@ -339,6 +345,11 @@ export async function processDownloadProposal(
}; };
} }
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const orderClaimUrl = new URL( const orderClaimUrl = new URL(
`orders/${proposal.orderId}/claim`, `orders/${proposal.orderId}/claim`,
proposal.merchantBaseUrl, proposal.merchantBaseUrl,
@ -363,7 +374,8 @@ export async function processDownloadProposal(
}); });
// FIXME: Do this in the background using the new return value // FIXME: Do this in the background using the new return value
const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, { const httpResponse = await ws.http.fetch(orderClaimUrl, {
body: requestBody,
timeout: getProposalRequestTimeout(retryRecord?.retryInfo), timeout: getProposalRequestTimeout(retryRecord?.retryInfo),
}); });
const r = await readSuccessResponseJsonOrErrorCode( const r = await readSuccessResponseJsonOrErrorCode(
@ -388,7 +400,7 @@ export async function processDownloadProposal(
const proposalResp = r.response; const proposalResp = r.response;
// The proposalResp contains the contract terms as raw JSON, // The proposalResp contains the contract terms as raw JSON,
// as the coded to parse them doesn't necessarily round-trip. // as the code to parse them doesn't necessarily round-trip.
// We need this raw JSON to compute the contract terms hash. // We need this raw JSON to compute the contract terms hash.
// FIXME: Do better error handling, check if the // FIXME: Do better error handling, check if the
@ -496,7 +508,7 @@ export async function processDownloadProposal(
logger.trace(`extracted contract data: ${j2s(contractData)}`); logger.trace(`extracted contract data: ${j2s(contractData)}`);
await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.purchases, x.contractTerms]) .mktx((x) => [x.purchases, x.contractTerms])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId); const p = await tx.purchases.get(proposalId);
@ -506,6 +518,7 @@ export async function processDownloadProposal(
if (p.purchaseStatus !== PurchaseStatus.DownloadingProposal) { if (p.purchaseStatus !== PurchaseStatus.DownloadingProposal) {
return; return;
} }
const oldTxState = computePayMerchantTransactionState(p);
p.download = { p.download = {
contractTermsHash, contractTermsHash,
contractTermsMerchantSig: contractData.merchantSig, contractTermsMerchantSig: contractData.merchantSig,
@ -523,18 +536,28 @@ export async function processDownloadProposal(
) { ) {
const differentPurchase = const differentPurchase =
await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl); await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
// FIXME: Adjust this to account for refunds, don't count as repurchase
// if original order is refunded.
if (differentPurchase) { if (differentPurchase) {
logger.warn("repurchase detected"); logger.warn("repurchase detected");
p.purchaseStatus = PurchaseStatus.RepurchaseDetected; p.purchaseStatus = PurchaseStatus.RepurchaseDetected;
p.repurchaseProposalId = differentPurchase.proposalId; p.repurchaseProposalId = differentPurchase.proposalId;
await tx.purchases.put(p); await tx.purchases.put(p);
return;
}
} }
} else {
p.purchaseStatus = PurchaseStatus.Proposed; p.purchaseStatus = PurchaseStatus.Proposed;
await tx.purchases.put(p); await tx.purchases.put(p);
}
const newTxState = computePayMerchantTransactionState(p);
return {
oldTxState,
newTxState,
}
}); });
notifyTransition(ws, transactionId, transitionInfo);
// FIXME: Deprecated pre-DD37 notification, remove eventually
ws.notify({ ws.notify({
type: NotificationType.ProposalDownloaded, type: NotificationType.ProposalDownloaded,
proposalId: proposal.proposalId, proposalId: proposal.proposalId,
@ -547,13 +570,11 @@ export async function processDownloadProposal(
} }
/** /**
* Download a proposal and store it in the database. * Create a new purchase transaction if necessary. If a purchase
* Returns an id for it to retrieve it later. * record for the provided arguments already exists,
* * return the old proposal ID.
* @param sessionId Current session ID, if the proposal is being
* downloaded in the context of a session ID.
*/ */
async function startDownloadProposal( async function createPurchase(
ws: InternalWalletState, ws: InternalWalletState,
merchantBaseUrl: string, merchantBaseUrl: string,
orderId: string, orderId: string,
@ -619,7 +640,7 @@ async function startDownloadProposal(
posConfirmation: undefined, posConfirmation: undefined,
}; };
await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.purchases]) .mktx((x) => [x.purchases])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([ const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([
@ -628,11 +649,25 @@ async function startDownloadProposal(
]); ]);
if (existingRecord) { if (existingRecord) {
// Created concurrently // Created concurrently
return; return undefined;
} }
await tx.purchases.put(proposalRecord); await tx.purchases.put(proposalRecord);
const oldTxState: TransactionState = {
major: TransactionMajorState.None,
};
const newTxState = computePayMerchantTransactionState(proposalRecord);
return {
oldTxState,
newTxState,
}
}); });
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
notifyTransition(ws, transactionId, transitionInfo);
await processDownloadProposal(ws, proposalId); await processDownloadProposal(ws, proposalId);
return proposalId; return proposalId;
} }
@ -643,8 +678,12 @@ async function storeFirstPaySuccess(
sessionId: string | undefined, sessionId: string | undefined,
payResponse: MerchantPayResponse, payResponse: MerchantPayResponse,
): Promise<void> { ): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.purchases, x.contractTerms]) .mktx((x) => [x.purchases, x.contractTerms])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId); const purchase = await tx.purchases.get(proposalId);
@ -658,6 +697,7 @@ async function storeFirstPaySuccess(
logger.warn("payment success already stored"); logger.warn("payment success already stored");
return; return;
} }
const oldTxState = computePayMerchantTransactionState(purchase);
if (purchase.purchaseStatus === PurchaseStatus.Paying) { if (purchase.purchaseStatus === PurchaseStatus.Paying) {
purchase.purchaseStatus = PurchaseStatus.Done; purchase.purchaseStatus = PurchaseStatus.Done;
} }
@ -686,7 +726,13 @@ async function storeFirstPaySuccess(
); );
} }
await tx.purchases.put(purchase); await tx.purchases.put(purchase);
const newTxState = computePayMerchantTransactionState(purchase);
return {
oldTxState,
newTxState,
}
}); });
notifyTransition(ws, transactionId, transitionInfo);
} }
async function storePayReplaySuccess( async function storePayReplaySuccess(
@ -694,7 +740,11 @@ async function storePayReplaySuccess(
proposalId: string, proposalId: string,
sessionId: string | undefined, sessionId: string | undefined,
): Promise<void> { ): Promise<void> {
await ws.db const transactionId = constructTransactionIdentifier({
tag: TransactionType.Payment,
proposalId,
});
const transitionInfo = await ws.db
.mktx((x) => [x.purchases]) .mktx((x) => [x.purchases])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId); const purchase = await tx.purchases.get(proposalId);
@ -707,6 +757,7 @@ async function storePayReplaySuccess(
if (isFirst) { if (isFirst) {
throw Error("invalid payment state"); throw Error("invalid payment state");
} }
const oldTxState = computePayMerchantTransactionState(purchase);
if ( if (
purchase.purchaseStatus === PurchaseStatus.Paying || purchase.purchaseStatus === PurchaseStatus.Paying ||
purchase.purchaseStatus === PurchaseStatus.PayingReplay purchase.purchaseStatus === PurchaseStatus.PayingReplay
@ -715,7 +766,10 @@ async function storePayReplaySuccess(
} }
purchase.lastSessionId = sessionId; purchase.lastSessionId = sessionId;
await tx.purchases.put(purchase); await tx.purchases.put(purchase);
const newTxState = computePayMerchantTransactionState(purchase);
return { oldTxState, newTxState };
}); });
notifyTransition(ws, transactionId, transitionInfo);
} }
/** /**
@ -876,6 +930,10 @@ async function unblockBackup(
}); });
} }
// FIXME: Should probably not be exported in its current state
// FIXME: Should take a transaction ID instead of a proposal ID
// FIXME: Does way more than checking the payment
// FIXME: Should return immediately.
export async function checkPaymentByProposalId( export async function checkPaymentByProposalId(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -918,13 +976,14 @@ export async function checkPaymentByProposalId(
proposalId, proposalId,
}); });
const talerUri = constructPayUri( const talerUri = stringifyTalerUri({
proposal.merchantBaseUrl, type: TalerUriAction.Pay,
proposal.orderId, merchantBaseUrl: proposal.merchantBaseUrl,
proposal.lastSessionId ?? proposal.downloadSessionId ?? "", orderId: proposal.orderId,
proposal.claimToken, sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "",
proposal.noncePriv, claimToken: proposal.claimToken,
); noncePriv: proposal.noncePriv,
});
// First check if we already paid for it. // First check if we already paid for it.
const purchase = await ws.db const purchase = await ws.db
@ -989,17 +1048,22 @@ export async function checkPaymentByProposalId(
"automatically re-submitting payment with different session ID", "automatically re-submitting payment with different session ID",
); );
logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`); logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.purchases]) .mktx((x) => [x.purchases])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId); const p = await tx.purchases.get(proposalId);
if (!p) { if (!p) {
return; return;
} }
const oldTxState = computePayMerchantTransactionState(p);
p.lastSessionId = sessionId; p.lastSessionId = sessionId;
p.purchaseStatus = PurchaseStatus.PayingReplay; p.purchaseStatus = PurchaseStatus.PayingReplay;
await tx.purchases.put(p); await tx.purchases.put(p);
const newTxState = computePayMerchantTransactionState(p);
return { oldTxState, newTxState };
}); });
notifyTransition(ws, transactionId, transitionInfo);
// FIXME: What about error handling?! This doesn't properly store errors in the DB.
const r = await processPurchasePay(ws, proposalId, { forceNow: true }); const r = await processPurchasePay(ws, proposalId, { forceNow: true });
if (r.type !== OperationAttemptResultType.Finished) { if (r.type !== OperationAttemptResultType.Finished) {
// FIXME: This does not surface the original error // FIXME: This does not surface the original error
@ -1092,7 +1156,7 @@ export async function preparePayForUri(
); );
} }
const proposalId = await startDownloadProposal( const proposalId = await createPurchase(
ws, ws,
uriResult.merchantBaseUrl, uriResult.merchantBaseUrl,
uriResult.orderId, uriResult.orderId,