backup import

This commit is contained in:
Florian Dold 2021-01-04 13:30:38 +01:00
parent 95568395ce
commit 03810fd248
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
8 changed files with 415 additions and 73 deletions

View File

@ -60,6 +60,7 @@ import {
DenomSelectionState,
ExchangeUpdateStatus,
ExchangeWireInfo,
PayCoinSelection,
ProposalDownload,
ProposalStatus,
RefreshSessionRecord,
@ -67,6 +68,8 @@ import {
ReserveBankInfo,
ReserveRecordStatus,
Stores,
WalletContractData,
WalletRefundItem,
} from "../types/dbTypes";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants";
import { AmountJson, Amounts, codecForAmountString } from "../util/amounts";
@ -77,6 +80,7 @@ import {
encodeCrock,
getRandomBytes,
hash,
rsaBlind,
stringToBytes,
} from "../crypto/talerCrypto";
import { canonicalizeBaseUrl, canonicalJson, j2s } from "../util/helpers";
@ -102,6 +106,7 @@ import { gzipSync } from "fflate";
import { kdf } from "../crypto/primitives/kdf";
import { initRetryInfo } from "../util/retries";
import { RefreshReason } from "../types/walletTypes";
import { CryptoApi } from "../crypto/workers/cryptoApi";
interface WalletBackupConfState {
deviceId: string;
@ -461,6 +466,8 @@ export async function exportBackup(
? undefined
: purch.abortStatus,
nonce_priv: purch.noncePriv,
merchant_sig: purch.download.contractData.merchantSig,
total_pay_cost: Amounts.stringify(purch.totalPayCost),
});
});
@ -607,11 +614,77 @@ interface CompletedCoin {
interface BackupCryptoPrecomputedData {
denomPubToHash: Record<string, string>;
coinPrivToCompletedCoin: Record<string, CompletedCoin>;
proposalNoncePrivToProposalPub: { [priv: string]: string };
proposalNoncePrivToPub: { [priv: string]: string };
proposalIdToContractTermsHash: { [proposalId: string]: string };
reservePrivToPub: Record<string, string>;
}
/**
* Compute cryptographic values for a backup blob.
*
* FIXME: Take data that we already know from the DB.
* FIXME: Move computations into crypto worker.
*/
async function computeBackupCryptoData(
cryptoApi: CryptoApi,
backupContent: WalletBackupContentV1,
): Promise<BackupCryptoPrecomputedData> {
const cryptoData: BackupCryptoPrecomputedData = {
coinPrivToCompletedCoin: {},
denomPubToHash: {},
proposalIdToContractTermsHash: {},
proposalNoncePrivToPub: {},
reservePrivToPub: {},
};
for (const backupExchange of backupContent.exchanges) {
for (const backupDenom of backupExchange.denominations) {
for (const backupCoin of backupDenom.coins) {
const coinPub = encodeCrock(
eddsaGetPublic(decodeCrock(backupCoin.coin_priv)),
);
const blindedCoin = rsaBlind(
hash(decodeCrock(backupCoin.coin_priv)),
decodeCrock(backupCoin.blinding_key),
decodeCrock(backupDenom.denom_pub),
);
cryptoData.coinPrivToCompletedCoin[backupCoin.coin_priv] = {
coinEvHash: encodeCrock(hash(blindedCoin)),
coinPub,
}
}
cryptoData.denomPubToHash[backupDenom.denom_pub] = encodeCrock(
hash(decodeCrock(backupDenom.denom_pub)),
);
}
for (const backupReserve of backupExchange.reserves) {
cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock(
eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)),
);
}
}
for (const prop of backupContent.proposals) {
const contractTermsHash = await cryptoApi.hashString(
canonicalJson(prop.contract_terms_raw),
);
const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv)));
cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub;
cryptoData.proposalIdToContractTermsHash[
prop.proposal_id
] = contractTermsHash;
}
for (const purch of backupContent.purchases) {
const contractTermsHash = await cryptoApi.hashString(
canonicalJson(purch.contract_terms_raw),
);
const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(purch.nonce_priv)));
cryptoData.proposalNoncePrivToPub[purch.nonce_priv] = noncePub;
cryptoData.proposalIdToContractTermsHash[
purch.proposal_id
] = contractTermsHash;
}
return cryptoData;
}
function checkBackupInvariant(b: boolean, m?: string): asserts b {
if (!b) {
if (m) {
@ -622,6 +695,88 @@ function checkBackupInvariant(b: boolean, m?: string): asserts b {
}
}
/**
* Re-compute information about the coin selection for a payment.
*/
async function recoverPayCoinSelection(
tx: TransactionHandle<
typeof Stores.exchanges | typeof Stores.coins | typeof Stores.denominations
>,
contractData: WalletContractData,
backupPurchase: BackupPurchase,
): Promise<PayCoinSelection> {
const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
);
const coveredExchanges: Set<string> = new Set();
let totalWireFee: AmountJson = Amounts.getZero(contractData.amount.currency);
let totalDepositFees: AmountJson = Amounts.getZero(
contractData.amount.currency,
);
for (const coinPub of coinPubs) {
const coinRecord = await tx.get(Stores.coins, coinPub);
checkBackupInvariant(!!coinRecord);
const denom = await tx.get(Stores.denominations, [
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
]);
checkBackupInvariant(!!denom);
totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount;
if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) {
const exchange = await tx.get(
Stores.exchanges,
coinRecord.exchangeBaseUrl,
);
checkBackupInvariant(!!exchange);
let wireFee: AmountJson | undefined;
const feesForType = exchange.wireInfo?.feesForType;
checkBackupInvariant(!!feesForType);
for (const fee of feesForType[contractData.wireMethod] || []) {
if (
fee.startStamp <= contractData.timestamp &&
fee.endStamp >= contractData.timestamp
) {
wireFee = fee.wireFee;
break;
}
}
if (wireFee) {
totalWireFee = Amounts.add(totalWireFee, wireFee).amount;
}
}
}
let customerWireFee: AmountJson;
const amortizedWireFee = Amounts.divide(
totalWireFee,
contractData.wireFeeAmortization,
);
if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
customerWireFee = amortizedWireFee;
} else {
customerWireFee = Amounts.getZero(contractData.amount.currency);
}
const customerDepositFees = Amounts.sub(
totalDepositFees,
contractData.maxDepositFee,
).amount;
return {
coinPubs,
coinContributions,
paymentAmount: contractData.amount,
customerWireFees: customerWireFee,
customerDepositFees,
};
}
function getDenomSelStateFromBackup(
tx: TransactionHandle<typeof Stores.denominations>,
sel: BackupDenomSel,
@ -959,9 +1114,7 @@ export async function importBackup(
orderId: backupProposal.order_id,
noncePriv: backupProposal.nonce_priv,
noncePub:
cryptoComp.proposalNoncePrivToProposalPub[
backupProposal.nonce_priv
],
cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
proposalId: backupProposal.proposal_id,
repurchaseProposalId: backupProposal.repurchase_proposal_id,
retryInfo: initRetryInfo(false),
@ -977,7 +1130,138 @@ export async function importBackup(
backupPurchase.proposal_id,
);
if (!existingPurchase) {
await tx.put(Stores.purchases, {});
const refunds: { [refundKey: string]: WalletRefundItem } = {};
for (const backupRefund of backupPurchase.refunds) {
const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`;
const coin = await tx.get(Stores.coins, backupRefund.coin_pub);
checkBackupInvariant(!!coin);
const denom = await tx.get(Stores.denominations, [
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
checkBackupInvariant(!!denom);
const common = {
coinPub: backupRefund.coin_pub,
executionTime: backupRefund.execution_time,
obtainedTime: backupRefund.obtained_time,
refundAmount: Amounts.parseOrThrow(backupRefund.refund_amount),
refundFee: denom.feeRefund,
rtransactionId: backupRefund.rtransaction_id,
totalRefreshCostBound: Amounts.parseOrThrow(
backupRefund.total_refresh_cost_bound,
),
};
switch (backupRefund.type) {
case BackupRefundState.Applied:
refunds[key] = {
type: RefundState.Applied,
...common,
};
break;
case BackupRefundState.Failed:
refunds[key] = {
type: RefundState.Failed,
...common,
};
break;
case BackupRefundState.Pending:
refunds[key] = {
type: RefundState.Pending,
...common,
};
break;
}
}
let abortStatus: AbortStatus;
switch (backupPurchase.abort_status) {
case "abort-finished":
abortStatus = AbortStatus.AbortFinished;
break;
case "abort-refund":
abortStatus = AbortStatus.AbortRefund;
break;
default:
throw Error("not reachable");
}
const parsedContractTerms = codecForContractTerms().decode(
backupPurchase.contract_terms_raw,
);
const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
const contractTermsHash =
cryptoComp.proposalIdToContractTermsHash[
backupPurchase.proposal_id
];
let maxWireFee: AmountJson;
if (parsedContractTerms.max_wire_fee) {
maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
} else {
maxWireFee = Amounts.getZero(amount.currency);
}
const download: ProposalDownload = {
contractData: {
amount,
contractTermsHash: contractTermsHash,
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
merchantBaseUrl: parsedContractTerms.merchant_base_url,
merchantPub: parsedContractTerms.merchant_pub,
merchantSig: backupPurchase.merchant_sig,
orderId: parsedContractTerms.order_id,
summary: parsedContractTerms.summary,
autoRefund: parsedContractTerms.auto_refund,
maxWireFee,
payDeadline: parsedContractTerms.pay_deadline,
refundDeadline: parsedContractTerms.refund_deadline,
wireFeeAmortization:
parsedContractTerms.wire_fee_amortization || 1,
allowedAuditors: parsedContractTerms.auditors.map((x) => ({
auditorBaseUrl: x.url,
auditorPub: x.master_pub,
})),
allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
exchangeBaseUrl: x.url,
exchangePub: x.master_pub,
})),
timestamp: parsedContractTerms.timestamp,
wireMethod: parsedContractTerms.wire_method,
wireInfoHash: parsedContractTerms.h_wire,
maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
merchant: parsedContractTerms.merchant,
products: parsedContractTerms.products,
summaryI18n: parsedContractTerms.summary_i18n,
},
contractTermsRaw: backupPurchase.contract_terms_raw,
};
await tx.put(Stores.purchases, {
proposalId: backupPurchase.proposal_id,
noncePriv: backupPurchase.nonce_priv,
noncePub:
cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
lastPayError: undefined,
autoRefundDeadline: { t_ms: "never" },
refundStatusRetryInfo: initRetryInfo(false),
lastRefundStatusError: undefined,
timestampAccept: backupPurchase.timestamp_accept,
timestampFirstSuccessfulPay:
backupPurchase.timestamp_first_successful_pay,
timestampLastRefundStatus:
backupPurchase.timestamp_last_refund_status,
merchantPaySig: backupPurchase.merchant_pay_sig,
lastSessionId: undefined,
abortStatus,
// FIXME!
payRetryInfo: initRetryInfo(false),
download,
paymentSubmitPending: !backupPurchase.timestamp_first_successful_pay,
refundQueryRequested: false,
payCoinSelection: await recoverPayCoinSelection(
tx,
download.contractData,
backupPurchase,
),
coinDepositPermissions: undefined,
totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost),
refunds,
});
}
}

View File

@ -941,8 +941,21 @@ async function submitPay(
purchase.download.contractData.merchantBaseUrl,
).href;
let depositPermissions: CoinDepositPermission[];
if (purchase.coinDepositPermissions) {
depositPermissions = purchase.coinDepositPermissions;
} else {
// FIXME: also cache!
depositPermissions = await generateDepositPermissions(
ws,
purchase.payCoinSelection,
purchase.download.contractData,
);
}
const reqBody = {
coins: purchase.coinDepositPermissions,
coins: depositPermissions,
session_id: purchase.lastSessionId,
};
@ -1192,6 +1205,50 @@ export async function preparePayForUri(
}
}
/**
* Generate deposit permissions for a purchase.
*
* Accesses the database and the crypto worker.
*/
async function generateDepositPermissions(
ws: InternalWalletState,
payCoinSel: PayCoinSelection,
contractData: WalletContractData,
): Promise<CoinDepositPermission[]> {
const depositPermissions: CoinDepositPermission[] = [];
for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
const coin = await ws.db.get(Stores.coins, payCoinSel.coinPubs[i]);
if (!coin) {
throw Error("can't pay, allocated coin not found anymore");
}
const denom = await ws.db.get(Stores.denominations, [
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error(
"can't pay, denomination of allocated coin not found anymore",
);
}
const dp = await ws.cryptoApi.signDepositPermission({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contractTermsHash: contractData.contractTermsHash,
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
exchangeBaseUrl: coin.exchangeBaseUrl,
feeDeposit: denom.feeDeposit,
merchantPub: contractData.merchantPub,
refundDeadline: contractData.refundDeadline,
spendAmount: payCoinSel.coinContributions[i],
timestamp: contractData.timestamp,
wireInfoHash: contractData.wireInfoHash,
});
depositPermissions.push(dp);
}
return depositPermissions;
}
/**
* Add a contract to the wallet and sign coins, and send them.
*/
@ -1248,37 +1305,11 @@ export async function confirmPay(
throw Error("insufficient balance");
}
const depositPermissions: CoinDepositPermission[] = [];
for (let i = 0; i < res.coinPubs.length; i++) {
const coin = await ws.db.get(Stores.coins, res.coinPubs[i]);
if (!coin) {
throw Error("can't pay, allocated coin not found anymore");
}
const denom = await ws.db.get(Stores.denominations, [
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error(
"can't pay, denomination of allocated coin not found anymore",
const depositPermissions = await generateDepositPermissions(
ws,
res,
d.contractData,
);
}
const dp = await ws.cryptoApi.signDepositPermission({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contractTermsHash: d.contractData.contractTermsHash,
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
exchangeBaseUrl: coin.exchangeBaseUrl,
feeDeposit: denom.feeDeposit,
merchantPub: d.contractData.merchantPub,
refundDeadline: d.contractData.refundDeadline,
spendAmount: res.coinContributions[i],
timestamp: d.contractData.timestamp,
wireInfoHash: d.contractData.wireInfoHash,
});
depositPermissions.push(dp);
}
purchase = await recordConfirmPay(
ws,
proposal,

View File

@ -501,9 +501,9 @@ export async function applyRefund(
const p = purchase;
let amountRefundGranted = Amounts.getZero(
purchase.contractData.amount.currency,
purchase.download.contractData.amount.currency,
);
let amountRefundGone = Amounts.getZero(purchase.contractData.amount.currency);
let amountRefundGone = Amounts.getZero(purchase.download.contractData.amount.currency);
let pendingAtExchange = false;
@ -531,21 +531,21 @@ export async function applyRefund(
});
return {
contractTermsHash: purchase.contractData.contractTermsHash,
contractTermsHash: purchase.download.contractData.contractTermsHash,
proposalId: purchase.proposalId,
amountEffectivePaid: Amounts.stringify(purchase.totalPayCost),
amountRefundGone: Amounts.stringify(amountRefundGone),
amountRefundGranted: Amounts.stringify(amountRefundGranted),
pendingAtExchange,
info: {
contractTermsHash: purchase.contractData.contractTermsHash,
merchant: purchase.contractData.merchant,
orderId: purchase.contractData.orderId,
products: purchase.contractData.products,
summary: purchase.contractData.summary,
fulfillmentMessage: purchase.contractData.fulfillmentMessage,
summary_i18n: purchase.contractData.summaryI18n,
fulfillmentMessage_i18n: purchase.contractData.fulfillmentMessageI18n,
contractTermsHash: purchase.download.contractData.contractTermsHash,
merchant: purchase.download.contractData.merchant,
orderId: purchase.download.contractData.orderId,
products: purchase.download.contractData.products,
summary: purchase.download.contractData.summary,
fulfillmentMessage: purchase.download.contractData.fulfillmentMessage,
summary_i18n: purchase.download.contractData.summaryI18n,
fulfillmentMessage_i18n: purchase.download.contractData.fulfillmentMessageI18n,
},
};
}
@ -594,14 +594,14 @@ async function processPurchaseQueryRefundImpl(
if (purchase.timestampFirstSuccessfulPay) {
const requestUrl = new URL(
`orders/${purchase.contractData.orderId}/refund`,
purchase.contractData.merchantBaseUrl,
`orders/${purchase.download.contractData.orderId}/refund`,
purchase.download.contractData.merchantBaseUrl,
);
logger.trace(`making refund request to ${requestUrl.href}`);
const request = await ws.http.postJson(requestUrl.href, {
h_contract: purchase.contractData.contractTermsHash,
h_contract: purchase.download.contractData.contractTermsHash,
});
logger.trace(
@ -622,8 +622,8 @@ async function processPurchaseQueryRefundImpl(
);
} else if (purchase.abortStatus === AbortStatus.AbortRefund) {
const requestUrl = new URL(
`orders/${purchase.contractData.orderId}/abort`,
purchase.contractData.merchantBaseUrl,
`orders/${purchase.download.contractData.orderId}/abort`,
purchase.download.contractData.merchantBaseUrl,
);
const abortingCoins: AbortingCoin[] = [];
@ -641,7 +641,7 @@ async function processPurchaseQueryRefundImpl(
}
const abortReq: AbortRequest = {
h_contract: purchase.contractData.contractTermsHash,
h_contract: purchase.download.contractData.contractTermsHash,
coins: abortingCoins,
};
@ -669,7 +669,7 @@ async function processPurchaseQueryRefundImpl(
purchase.payCoinSelection.coinContributions[i],
),
rtransaction_id: 0,
execution_time: timestampAddDuration(purchase.contractData.timestamp, {
execution_time: timestampAddDuration(purchase.download.contractData.timestamp, {
d_ms: 1000,
}),
});

View File

@ -207,12 +207,13 @@ export async function getTransactions(
if (
shouldSkipCurrency(
transactionsRequest,
pr.contractData.amount.currency,
pr.download.contractData.amount.currency,
)
) {
return;
}
if (shouldSkipSearch(transactionsRequest, [pr.contractData.summary])) {
const contractData = pr.download.contractData;
if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
return;
}
const proposal = await tx.get(Stores.proposals, pr.proposalId);
@ -220,15 +221,15 @@ export async function getTransactions(
return;
}
const info: OrderShortInfo = {
merchant: pr.contractData.merchant,
orderId: pr.contractData.orderId,
products: pr.contractData.products,
summary: pr.contractData.summary,
summary_i18n: pr.contractData.summaryI18n,
contractTermsHash: pr.contractData.contractTermsHash,
merchant: contractData.merchant,
orderId: contractData.orderId,
products: contractData.products,
summary: contractData.summary,
summary_i18n: contractData.summaryI18n,
contractTermsHash: contractData.contractTermsHash,
};
if (pr.contractData.fulfillmentUrl !== "") {
info.fulfillmentUrl = pr.contractData.fulfillmentUrl;
if (contractData.fulfillmentUrl !== "") {
info.fulfillmentUrl = contractData.fulfillmentUrl;
}
const paymentTransactionId = makeEventId(
TransactionType.Payment,
@ -237,7 +238,7 @@ export async function getTransactions(
const err = pr.lastPayError ?? pr.lastRefundStatusError;
transactions.push({
type: TransactionType.Payment,
amountRaw: Amounts.stringify(pr.contractData.amount),
amountRaw: Amounts.stringify(contractData.amount),
amountEffective: Amounts.stringify(pr.totalPayCost),
status: pr.timestampFirstSuccessfulPay
? PaymentStatus.Paid
@ -267,9 +268,9 @@ export async function getTransactions(
groupKey,
);
let r0: WalletRefundItem | undefined;
let amountRaw = Amounts.getZero(pr.contractData.amount.currency);
let amountRaw = Amounts.getZero(contractData.amount.currency);
let amountEffective = Amounts.getZero(
pr.contractData.amount.currency,
contractData.amount.currency,
);
for (const rk of Object.keys(pr.refunds)) {
const refund = pr.refunds[rk];

View File

@ -34,6 +34,11 @@
* 6. Returning money to own bank account isn't supported/exported yet.
* 7. Peer-to-peer payments aren't supported yet.
* 8. Next update time / next refresh time isn't backed up yet.
* 9. Coin/denom selections should be forgettable once that information
* becomes irrelevant.
* 10. Re-denominated payments/refreshes are not shown properly in the total
* payment cost.
* 11. Failed refunds do not have any information about why they failed.
*
* Questions:
* 1. What happens when two backups are merged that have
@ -42,6 +47,10 @@
* 2. Should we make more information forgettable? I.e. is
* the coin selection still relevant for a purchase after the coins
* are legally expired?
* => Yes, still needs to be implemented
* 3. What about re-denominations / re-selection of payment coins?
* Is it enough to store a clock value for the selection?
* => Coin derivation should also consider denom pub hash
*
* General considerations / decisions:
* 1. Information about previously occurring errors and
@ -78,6 +87,9 @@ type DeviceIdString = string;
*/
type ClockValue = number;
/**
* Contract terms JSON.
*/
type RawContractTerms = any;
/**
@ -751,10 +763,8 @@ export interface BackupPurchase {
/**
* Signature on the contract terms.
*
* Must be present if contract_terms_raw is present.
*/
merchant_sig?: string;
merchant_sig: string;
/**
* Private key for the nonce. Might eventually be used
@ -774,6 +784,19 @@ export interface BackupPurchase {
contribution: BackupAmountString;
}[];
/**
* Total cost initially shown to the user.
*
* This includes the amount taken by the merchant, fees (wire/deposit) contributed
* by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
* of coins that are too small to spend.
*
* Note that in rare situations, this cost might not be accurate (e.g.
* when the payment or refresh gets re-denominated).
* We might show adjustments to this later, but currently we don't do so.
*/
total_pay_cost: BackupAmountString;
/**
* Timestamp of the first time that sending a payment to the merchant
* for this purchase was successful.

View File

@ -1206,8 +1206,10 @@ export interface PurchaseRecord {
/**
* Deposit permissions, available once the user has accepted the payment.
*
* This value is cached and derived from payCoinSelection.
*/
coinDepositPermissions: CoinDepositPermission[];
coinDepositPermissions: CoinDepositPermission[] | undefined;
payCoinSelection: PayCoinSelection;

View File

@ -398,4 +398,5 @@ export const Amounts = {
fromFloat: fromFloat,
copy: copy,
fractionalBase: fractionalBase,
divide: divide,
};

View File

@ -840,7 +840,7 @@ export class Wallet {
]).amount;
const totalFees = totalRefundFees;
return {
contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTerms: JSON.parse(purchase.download.contractTermsRaw),
hasRefund: purchase.timestampLastRefundStatus !== undefined,
totalRefundAmount: totalRefundAmount,
totalRefundAndRefreshFees: totalFees,