new transactions API: purchases and refunds

This commit is contained in:
Florian Dold 2020-05-12 15:44:48 +05:30
parent 6206b418ff
commit 67dd0eb06e
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
5 changed files with 161 additions and 44 deletions

View File

@ -122,6 +122,10 @@ export interface AvailableCoinInfo {
feeDeposit: AmountJson; feeDeposit: AmountJson;
} }
export interface PayCostInfo {
totalCost: AmountJson;
}
/** /**
* Compute the total cost of a payment to the customer. * Compute the total cost of a payment to the customer.
* *
@ -132,7 +136,7 @@ export interface AvailableCoinInfo {
export async function getTotalPaymentCost( export async function getTotalPaymentCost(
ws: InternalWalletState, ws: InternalWalletState,
pcs: PayCoinSelection, pcs: PayCoinSelection,
): Promise<AmountJson> { ): Promise<PayCostInfo> {
const costs = [ const costs = [
pcs.paymentAmount, pcs.paymentAmount,
pcs.customerDepositFees, pcs.customerDepositFees,
@ -163,7 +167,9 @@ export async function getTotalPaymentCost(
const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft); const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
costs.push(refreshCost); costs.push(refreshCost);
} }
return Amounts.sum(costs).amount; return {
totalCost: Amounts.sum(costs).amount
};
} }
/** /**
@ -434,6 +440,7 @@ async function recordConfirmPay(
contractTermsRaw: d.contractTermsRaw, contractTermsRaw: d.contractTermsRaw,
contractData: d.contractData, contractData: d.contractData,
lastSessionId: sessionId, lastSessionId: sessionId,
payCoinSelection: coinSelection,
payReq, payReq,
timestampAccept: getTimestampNow(), timestampAccept: getTimestampNow(),
timestampLastRefundStatus: undefined, timestampLastRefundStatus: undefined,
@ -903,8 +910,8 @@ export async function preparePayForUri(
}; };
} }
const totalCost = await getTotalPaymentCost(ws, res); const costInfo = await getTotalPaymentCost(ws, res);
const totalFees = Amounts.sub(totalCost, res.paymentAmount).amount; const totalFees = Amounts.sub(costInfo.totalCost, res.paymentAmount).amount;
return { return {
status: "payment-possible", status: "payment-possible",

View File

@ -36,7 +36,6 @@ import {
CoinStatus, CoinStatus,
RefundReason, RefundReason,
RefundEventRecord, RefundEventRecord,
RefundInfo,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { parseRefundUri } from "../util/taleruri"; import { parseRefundUri } from "../util/taleruri";
@ -48,7 +47,7 @@ import {
codecForMerchantRefundResponse, codecForMerchantRefundResponse,
} from "../types/talerTypes"; } from "../types/talerTypes";
import { AmountJson } from "../util/amounts"; import { AmountJson } from "../util/amounts";
import { guardOperationException, OperationFailedError } from "./errors"; import { guardOperationException } from "./errors";
import { randomBytes } from "../crypto/primitives/nacl-fast"; import { randomBytes } from "../crypto/primitives/nacl-fast";
import { encodeCrock } from "../crypto/talerCrypto"; import { encodeCrock } from "../crypto/talerCrypto";
import { getTimestampNow } from "../util/time"; import { getTimestampNow } from "../util/time";
@ -159,6 +158,8 @@ async function acceptRefundResponse(
} }
} }
const now = getTimestampNow();
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
[Stores.purchases, Stores.coins, Stores.refreshGroups, Stores.refundEvents], [Stores.purchases, Stores.coins, Stores.refreshGroups, Stores.refundEvents],
async (tx) => { async (tx) => {
@ -253,10 +254,16 @@ async function acceptRefundResponse(
if (numNewRefunds === 0) { if (numNewRefunds === 0) {
if ( if (
p.autoRefundDeadline && p.autoRefundDeadline &&
p.autoRefundDeadline.t_ms > getTimestampNow().t_ms p.autoRefundDeadline.t_ms > now.t_ms
) { ) {
queryDone = false; queryDone = false;
} }
} else {
p.refundGroups.push({
reason: RefundReason.NormalRefund,
refundGroupId,
timestampQueried: getTimestampNow(),
});
} }
if (Object.keys(unfinishedRefunds).length != 0) { if (Object.keys(unfinishedRefunds).length != 0) {
@ -264,14 +271,14 @@ async function acceptRefundResponse(
} }
if (queryDone) { if (queryDone) {
p.timestampLastRefundStatus = getTimestampNow(); p.timestampLastRefundStatus = now;
p.lastRefundStatusError = undefined; p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo(false); p.refundStatusRetryInfo = initRetryInfo(false);
p.refundStatusRequested = false; p.refundStatusRequested = false;
console.log("refund query done"); console.log("refund query done");
} else { } else {
// No error, but we need to try again! // No error, but we need to try again!
p.timestampLastRefundStatus = getTimestampNow(); p.timestampLastRefundStatus = now;
p.refundStatusRetryInfo.retryCounter++; p.refundStatusRetryInfo.retryCounter++;
updateRetryInfoTimeout(p.refundStatusRetryInfo); updateRetryInfoTimeout(p.refundStatusRetryInfo);
p.lastRefundStatusError = undefined; p.lastRefundStatusError = undefined;
@ -291,7 +298,6 @@ async function acceptRefundResponse(
// Check if any of the refund groups are done, and we // Check if any of the refund groups are done, and we
// can emit an corresponding event. // can emit an corresponding event.
const now = getTimestampNow();
for (const g of Object.keys(changedGroups)) { for (const g of Object.keys(changedGroups)) {
let groupDone = true; let groupDone = true;
for (const pk of Object.keys(p.refundsPending)) { for (const pk of Object.keys(p.refundsPending)) {

View File

@ -18,8 +18,8 @@
* Imports. * Imports.
*/ */
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { Stores, ProposalRecord, ReserveRecordStatus } from "../types/dbTypes"; import { Stores, ReserveRecordStatus, PurchaseRecord } from "../types/dbTypes";
import { Amounts } from "../util/amounts"; import { Amounts, AmountJson } from "../util/amounts";
import { timestampCmp } from "../util/time"; import { timestampCmp } from "../util/time";
import { import {
TransactionsRequest, TransactionsRequest,
@ -27,7 +27,7 @@ import {
Transaction, Transaction,
TransactionType, TransactionType,
} from "../types/transactions"; } from "../types/transactions";
import { OrderShortInfo } from "../types/history"; import { getTotalPaymentCost } from "./pay";
/** /**
* Create an event ID from the type and the primary key for the event. * Create an event ID from the type and the primary key for the event.
@ -36,21 +36,49 @@ function makeEventId(type: TransactionType, ...args: string[]): string {
return type + ";" + args.map((x) => encodeURIComponent(x)).join(";"); return type + ";" + args.map((x) => encodeURIComponent(x)).join(";");
} }
function getOrderShortInfo(
proposal: ProposalRecord, interface RefundStats {
): OrderShortInfo | undefined { amountInvalid: AmountJson;
const download = proposal.download; amountEffective: AmountJson;
if (!download) { amountRaw: AmountJson;
return undefined; }
function getRefundStats(pr: PurchaseRecord, refundGroupId: string): RefundStats {
let amountEffective = Amounts.getZero(pr.contractData.amount.currency);
let amountInvalid = Amounts.getZero(pr.contractData.amount.currency);
let amountRaw = Amounts.getZero(pr.contractData.amount.currency);
for (const rk of Object.keys(pr.refundsDone)) {
const perm = pr.refundsDone[rk].perm;
if (pr.refundsDone[rk].refundGroupId !== refundGroupId) {
continue;
}
amountEffective = Amounts.add(amountEffective, Amounts.parseOrThrow(perm.refund_amount)).amount;
amountRaw = Amounts.add(amountRaw, Amounts.parseOrThrow(perm.refund_amount)).amount;
} }
for (const rk of Object.keys(pr.refundsDone)) {
const perm = pr.refundsDone[rk].perm;
if (pr.refundsDone[rk].refundGroupId !== refundGroupId) {
continue;
}
amountEffective = Amounts.sub(amountEffective, Amounts.parseOrThrow(perm.refund_fee)).amount;
}
for (const rk of Object.keys(pr.refundsFailed)) {
const perm = pr.refundsDone[rk].perm;
if (pr.refundsDone[rk].refundGroupId !== refundGroupId) {
continue;
}
amountInvalid = Amounts.add(amountInvalid, Amounts.parseOrThrow(perm.refund_fee)).amount;
}
return { return {
amount: Amounts.stringify(download.contractData.amount), amountEffective,
fulfillmentUrl: download.contractData.fulfillmentUrl, amountInvalid,
orderId: download.contractData.orderId, amountRaw,
merchantBaseUrl: download.contractData.merchantBaseUrl, }
proposalId: proposal.proposalId,
summary: download.contractData.summary,
};
} }
/** /**
@ -82,24 +110,39 @@ export async function getTransactions(
], ],
async (tx) => { async (tx) => {
tx.iter(Stores.withdrawalGroups).forEach((wsr) => { tx.iter(Stores.withdrawalGroups).forEach((wsr) => {
if (wsr.timestampFinish) { if (
transactions.push({ transactionsRequest?.currency &&
type: TransactionType.Withdrawal, wsr.rawWithdrawalAmount.currency != transactionsRequest.currency
amountEffective: Amounts.stringify(wsr.denomsSel.totalWithdrawCost), ) {
amountRaw: Amounts.stringify(wsr.denomsSel.totalCoinValue), return;
confirmed: true,
exchangeBaseUrl: wsr.exchangeBaseUrl,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
transactionId: makeEventId(
TransactionType.Withdrawal,
wsr.withdrawalGroupId,
),
});
} }
if (wsr.rawWithdrawalAmount.currency)
if (wsr.timestampFinish) {
transactions.push({
type: TransactionType.Withdrawal,
amountEffective: Amounts.stringify(
wsr.denomsSel.totalWithdrawCost,
),
amountRaw: Amounts.stringify(wsr.denomsSel.totalCoinValue),
confirmed: true,
exchangeBaseUrl: wsr.exchangeBaseUrl,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
transactionId: makeEventId(
TransactionType.Withdrawal,
wsr.withdrawalGroupId,
),
});
}
}); });
tx.iter(Stores.reserves).forEach((r) => { tx.iter(Stores.reserves).forEach((r) => {
if (
transactionsRequest?.currency &&
r.currency != transactionsRequest.currency
) {
return;
}
if (r.reserveStatus !== ReserveRecordStatus.WAIT_CONFIRM_BANK) { if (r.reserveStatus !== ReserveRecordStatus.WAIT_CONFIRM_BANK) {
return; return;
} }
@ -121,6 +164,63 @@ export async function getTransactions(
), ),
}); });
}); });
tx.iter(Stores.purchases).forEachAsync(async (pr) => {
if (
transactionsRequest?.currency &&
pr.contractData.amount.currency != transactionsRequest.currency
) {
return;
}
const proposal = await tx.get(Stores.proposals, pr.proposalId);
if (!proposal) {
return;
}
const cost = await getTotalPaymentCost(ws, pr.payCoinSelection);
transactions.push({
type: TransactionType.Payment,
amountRaw: Amounts.stringify(pr.contractData.amount),
amountEffective: Amounts.stringify(cost.totalCost),
failed: false,
pending: !pr.timestampFirstSuccessfulPay,
timestamp: pr.timestampAccept,
transactionId: makeEventId(TransactionType.Payment, pr.proposalId),
info: {
fulfillmentUrl: pr.contractData.fulfillmentUrl,
merchant: {},
orderId: pr.contractData.orderId,
products: [],
summary: pr.contractData.summary,
summary_i18n: {},
},
});
for (const rg of pr.refundGroups) {
const pending = Object.keys(pr.refundsDone).length > 0;
const stats = getRefundStats(pr, rg.refundGroupId);
transactions.push({
type: TransactionType.Refund,
pending,
info: {
fulfillmentUrl: pr.contractData.fulfillmentUrl,
merchant: {},
orderId: pr.contractData.orderId,
products: [],
summary: pr.contractData.summary,
summary_i18n: {},
},
timestamp: rg.timestampQueried,
transactionId: makeEventId(TransactionType.Refund, `{rg.timestampQueried.t_ms}`),
refundedTransactionId: makeEventId(TransactionType.Payment, pr.proposalId),
amountEffective: Amounts.stringify(stats.amountEffective),
amountInvalid: Amounts.stringify(stats.amountInvalid),
amountRaw: Amounts.stringify(stats.amountRaw),
});
}
});
}, },
); );

View File

@ -43,6 +43,7 @@ import {
ReserveRecoupTransaction, ReserveRecoupTransaction,
} from "./ReserveTransaction"; } from "./ReserveTransaction";
import { Timestamp, Duration, getTimestampNow } from "../util/time"; import { Timestamp, Duration, getTimestampNow } from "../util/time";
import { PayCoinSelection } from "../operations/pay";
export enum ReserveRecordStatus { export enum ReserveRecordStatus {
/** /**
@ -1133,6 +1134,7 @@ export const enum RefundReason {
} }
export interface RefundGroupInfo { export interface RefundGroupInfo {
refundGroupId: string;
timestampQueried: Timestamp; timestampQueried: Timestamp;
reason: RefundReason; reason: RefundReason;
} }
@ -1222,6 +1224,8 @@ export interface PurchaseRecord {
*/ */
payReq: PayReq; payReq: PayReq;
payCoinSelection: PayCoinSelection;
/** /**
* Timestamp of the first time that sending a payment to the merchant * Timestamp of the first time that sending a payment to the merchant
* for this purchase was successful. * for this purchase was successful.

View File

@ -115,7 +115,7 @@ interface TransactionPayment extends TransactionCommon {
type: TransactionType.Payment; type: TransactionType.Payment;
// Additional information about the payment. // Additional information about the payment.
info: TransactionInfo; info: PaymentShortInfo;
// true if the payment failed, false otherwise. // true if the payment failed, false otherwise.
// Note that failed payments with zero effective amount will not be returned by the API. // Note that failed payments with zero effective amount will not be returned by the API.
@ -125,11 +125,11 @@ interface TransactionPayment extends TransactionCommon {
amountRaw: AmountString; amountRaw: AmountString;
// Amount that was paid, including deposit, wire and refresh fees. // Amount that was paid, including deposit, wire and refresh fees.
amountEffective: AmountString; amountEffective?: AmountString;
} }
interface TransactionInfo { interface PaymentShortInfo {
// Order ID, uniquely identifies the order within a merchant instance // Order ID, uniquely identifies the order within a merchant instance
orderId: string; orderId: string;
@ -157,7 +157,7 @@ interface TransactionRefund extends TransactionCommon {
refundedTransactionId: string; refundedTransactionId: string;
// Additional information about the refunded payment // Additional information about the refunded payment
info: TransactionInfo; info: PaymentShortInfo;
// Part of the refund that couldn't be applied because the refund permissions were expired // Part of the refund that couldn't be applied because the refund permissions were expired
amountInvalid: AmountString; amountInvalid: AmountString;