wallet-core: Clean up merchant payments DB schema
This commit is contained in:
parent
eace0e0e7a
commit
526f4eba95
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@ -20,6 +20,12 @@
|
|||||||
"typescript.format.placeOpenBraceOnNewLineForFunctions": false,
|
"typescript.format.placeOpenBraceOnNewLineForFunctions": false,
|
||||||
// Defines whether an open brace is put onto a new line for control blocks or not
|
// Defines whether an open brace is put onto a new line for control blocks or not
|
||||||
"typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
|
"typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
|
||||||
|
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||||
|
"index.js",
|
||||||
|
"index.*.js",
|
||||||
|
"index.ts",
|
||||||
|
"index.*.ts"
|
||||||
|
],
|
||||||
// Files hidden in the explorer
|
// Files hidden in the explorer
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
// include the defaults from VS Code
|
// include the defaults from VS Code
|
||||||
|
@ -378,9 +378,9 @@ export class MemoryBackend implements Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private makeObjectStoreMap(
|
private makeObjectStoreMap(database: Database): {
|
||||||
database: Database,
|
[currentName: string]: ObjectStoreMapEntry;
|
||||||
): { [currentName: string]: ObjectStoreMapEntry } {
|
} {
|
||||||
let map: { [currentName: string]: ObjectStoreMapEntry } = {};
|
let map: { [currentName: string]: ObjectStoreMapEntry } = {};
|
||||||
for (let objectStoreName in database.committedObjectStores) {
|
for (let objectStoreName in database.committedObjectStores) {
|
||||||
const store = database.committedObjectStores[objectStoreName];
|
const store = database.committedObjectStores[objectStoreName];
|
||||||
@ -1088,9 +1088,8 @@ export class MemoryBackend implements Backend {
|
|||||||
if (!existingIndexRecord) {
|
if (!existingIndexRecord) {
|
||||||
throw Error("db inconsistent: expected index entry missing");
|
throw Error("db inconsistent: expected index entry missing");
|
||||||
}
|
}
|
||||||
const newPrimaryKeys = existingIndexRecord.primaryKeys.without(
|
const newPrimaryKeys =
|
||||||
primaryKey,
|
existingIndexRecord.primaryKeys.without(primaryKey);
|
||||||
);
|
|
||||||
if (newPrimaryKeys.size === 0) {
|
if (newPrimaryKeys.size === 0) {
|
||||||
index.modifiedData = indexData.without(indexKey);
|
index.modifiedData = indexData.without(indexKey);
|
||||||
} else {
|
} else {
|
||||||
@ -1357,7 +1356,20 @@ export class MemoryBackend implements Backend {
|
|||||||
|
|
||||||
// Remove old index entry first!
|
// Remove old index entry first!
|
||||||
if (oldStoreRecord) {
|
if (oldStoreRecord) {
|
||||||
this.deleteFromIndex(index, key, oldStoreRecord.value, indexProperties);
|
try {
|
||||||
|
this.deleteFromIndex(
|
||||||
|
index,
|
||||||
|
key,
|
||||||
|
oldStoreRecord.value,
|
||||||
|
indexProperties,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DataError) {
|
||||||
|
// Do nothing
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.insertIntoIndex(index, key, value, indexProperties);
|
this.insertIntoIndex(index, key, value, indexProperties);
|
||||||
|
@ -180,15 +180,6 @@ export interface WalletBackupContentV1 {
|
|||||||
*/
|
*/
|
||||||
tips: BackupTip[];
|
tips: BackupTip[];
|
||||||
|
|
||||||
/**
|
|
||||||
* Proposals from merchants. The proposal may
|
|
||||||
* be deleted as soon as it has been accepted (and thus
|
|
||||||
* turned into a purchase).
|
|
||||||
*
|
|
||||||
* Sorted by the proposal ID.
|
|
||||||
*/
|
|
||||||
proposals: BackupProposal[];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accepted purchases.
|
* Accepted purchases.
|
||||||
*
|
*
|
||||||
@ -838,29 +829,10 @@ export type BackupRefundItem =
|
|||||||
| BackupRefundPendingItem
|
| BackupRefundPendingItem
|
||||||
| BackupRefundAppliedItem;
|
| BackupRefundAppliedItem;
|
||||||
|
|
||||||
export interface BackupPurchase {
|
/**
|
||||||
/**
|
* Data we store when the payment was accepted.
|
||||||
* Proposal ID for this purchase. Uniquely identifies the
|
|
||||||
* purchase and the proposal.
|
|
||||||
*/
|
*/
|
||||||
proposal_id: string;
|
export interface BackupPayInfo {
|
||||||
|
|
||||||
/**
|
|
||||||
* Contract terms we got from the merchant.
|
|
||||||
*/
|
|
||||||
contract_terms_raw: RawContractTerms;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signature on the contract terms.
|
|
||||||
*/
|
|
||||||
merchant_sig: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private key for the nonce. Might eventually be used
|
|
||||||
* to prove ownership of the contract.
|
|
||||||
*/
|
|
||||||
nonce_priv: string;
|
|
||||||
|
|
||||||
pay_coins: {
|
pay_coins: {
|
||||||
/**
|
/**
|
||||||
* Public keys of the coins that were selected.
|
* Public keys of the coins that were selected.
|
||||||
@ -890,6 +862,63 @@ export interface BackupPurchase {
|
|||||||
* We might show adjustments to this later, but currently we don't do so.
|
* We might show adjustments to this later, but currently we don't do so.
|
||||||
*/
|
*/
|
||||||
total_pay_cost: BackupAmountString;
|
total_pay_cost: BackupAmountString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupPurchase {
|
||||||
|
/**
|
||||||
|
* Proposal ID for this purchase. Uniquely identifies the
|
||||||
|
* purchase and the proposal.
|
||||||
|
*/
|
||||||
|
proposal_id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of the proposal.
|
||||||
|
*/
|
||||||
|
proposal_status: BackupProposalStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposal that this one got "redirected" to as part of
|
||||||
|
* the repurchase detection.
|
||||||
|
*/
|
||||||
|
repurchase_proposal_id: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session ID we got when downloading the contract.
|
||||||
|
*/
|
||||||
|
download_session_id?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merchant-assigned order ID of the proposal.
|
||||||
|
*/
|
||||||
|
order_id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base URL of the merchant that proposed the purchase.
|
||||||
|
*/
|
||||||
|
merchant_base_url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim token initially given by the merchant.
|
||||||
|
*/
|
||||||
|
claim_token: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract terms we got from the merchant.
|
||||||
|
*/
|
||||||
|
contract_terms_raw?: RawContractTerms;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signature on the contract terms.
|
||||||
|
*/
|
||||||
|
merchant_sig?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private key for the nonce. Might eventually be used
|
||||||
|
* to prove ownership of the contract.
|
||||||
|
*/
|
||||||
|
nonce_priv: string;
|
||||||
|
|
||||||
|
pay_info: BackupPayInfo | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp of the first time that sending a payment to the merchant
|
* Timestamp of the first time that sending a payment to the merchant
|
||||||
@ -902,11 +931,13 @@ export interface BackupPurchase {
|
|||||||
*/
|
*/
|
||||||
merchant_pay_sig: string | undefined;
|
merchant_pay_sig: string | undefined;
|
||||||
|
|
||||||
|
timestamp_proposed: TalerProtocolTimestamp;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When was the purchase made?
|
* When was the purchase made?
|
||||||
* Refers to the time that the user accepted.
|
* Refers to the time that the user accepted.
|
||||||
*/
|
*/
|
||||||
timestamp_accept: TalerProtocolTimestamp;
|
timestamp_accepted: TalerProtocolTimestamp | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pending refunds for the purchase. A refund is pending
|
* Pending refunds for the purchase. A refund is pending
|
||||||
@ -914,11 +945,6 @@ export interface BackupPurchase {
|
|||||||
*/
|
*/
|
||||||
refunds: BackupRefundItem[];
|
refunds: BackupRefundItem[];
|
||||||
|
|
||||||
/**
|
|
||||||
* Abort status of the payment.
|
|
||||||
*/
|
|
||||||
abort_status?: "abort-refund" | "abort-finished";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Continue querying the refund status until this deadline has expired.
|
* Continue querying the refund status until this deadline has expired.
|
||||||
*/
|
*/
|
||||||
@ -1218,70 +1244,8 @@ export enum BackupProposalStatus {
|
|||||||
* Downloaded proposal was detected as a re-purchase.
|
* Downloaded proposal was detected as a re-purchase.
|
||||||
*/
|
*/
|
||||||
Repurchase = "repurchase",
|
Repurchase = "repurchase",
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
Paid = "paid",
|
||||||
* Proposal by a merchant.
|
|
||||||
*/
|
|
||||||
export interface BackupProposal {
|
|
||||||
/**
|
|
||||||
* Base URL of the merchant that proposed the purchase.
|
|
||||||
*/
|
|
||||||
merchant_base_url: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloaded data from the merchant.
|
|
||||||
*/
|
|
||||||
contract_terms_raw?: RawContractTerms;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signature on the contract terms.
|
|
||||||
*
|
|
||||||
* Must be present if contract_terms_raw is present.
|
|
||||||
*/
|
|
||||||
merchant_sig?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unique ID when the order is stored in the wallet DB.
|
|
||||||
*/
|
|
||||||
proposal_id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merchant-assigned order ID of the proposal.
|
|
||||||
*/
|
|
||||||
order_id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Timestamp of when the record
|
|
||||||
* was created.
|
|
||||||
*/
|
|
||||||
timestamp: TalerProtocolTimestamp;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private key for the nonce.
|
|
||||||
*/
|
|
||||||
nonce_priv: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim token initially given by the merchant.
|
|
||||||
*/
|
|
||||||
claim_token: string | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status of the proposal.
|
|
||||||
*/
|
|
||||||
proposal_status: BackupProposalStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proposal that this one got "redirected" to as part of
|
|
||||||
* the repurchase detection.
|
|
||||||
*/
|
|
||||||
repurchase_proposal_id: string | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session ID we got when downloading the contract.
|
|
||||||
*/
|
|
||||||
download_session_id?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupRecovery {
|
export interface BackupRecovery {
|
||||||
|
@ -17,13 +17,8 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import {
|
import { PreparePayResultType, TalerErrorCode } from "@gnu-taler/taler-util";
|
||||||
PreparePayResultType,
|
|
||||||
TalerErrorCode,
|
|
||||||
TalerErrorDetail,
|
|
||||||
} from "@gnu-taler/taler-util";
|
|
||||||
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
|
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
|
||||||
import { makeEventId } from "@gnu-taler/taler-wallet-core";
|
|
||||||
import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
|
import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
|
||||||
import {
|
import {
|
||||||
createSimpleTestkudosEnvironment,
|
createSimpleTestkudosEnvironment,
|
||||||
|
@ -18,7 +18,10 @@
|
|||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
|
import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
|
||||||
import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
|
import {
|
||||||
|
createSimpleTestkudosEnvironment,
|
||||||
|
withdrawViaBank,
|
||||||
|
} from "../harness/helpers.js";
|
||||||
import { PreparePayResultType } from "@gnu-taler/taler-util";
|
import { PreparePayResultType } from "@gnu-taler/taler-util";
|
||||||
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
|
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
|
||||||
|
|
||||||
@ -29,12 +32,8 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
|
|||||||
export async function runPaymentIdempotencyTest(t: GlobalTestState) {
|
export async function runPaymentIdempotencyTest(t: GlobalTestState) {
|
||||||
// Set up test environment
|
// Set up test environment
|
||||||
|
|
||||||
const {
|
const { wallet, bank, exchange, merchant } =
|
||||||
wallet,
|
await createSimpleTestkudosEnvironment(t);
|
||||||
bank,
|
|
||||||
exchange,
|
|
||||||
merchant,
|
|
||||||
} = await createSimpleTestkudosEnvironment(t);
|
|
||||||
|
|
||||||
// Withdraw digital cash into the wallet.
|
// Withdraw digital cash into the wallet.
|
||||||
|
|
||||||
@ -83,10 +82,16 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) {
|
|||||||
|
|
||||||
const proposalId = preparePayResult.proposalId;
|
const proposalId = preparePayResult.proposalId;
|
||||||
|
|
||||||
await wallet.client.call(WalletApiOperation.ConfirmPay, {
|
const confirmPayResult = await wallet.client.call(
|
||||||
// FIXME: should be validated, don't cast!
|
WalletApiOperation.ConfirmPay,
|
||||||
|
{
|
||||||
proposalId: proposalId,
|
proposalId: proposalId,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("confirm pay result", confirmPayResult);
|
||||||
|
|
||||||
|
await wallet.runUntilDone();
|
||||||
|
|
||||||
// Check if payment was successful.
|
// Check if payment was successful.
|
||||||
|
|
||||||
@ -103,6 +108,8 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("result after:", preparePayResultAfter);
|
||||||
|
|
||||||
t.assertTrue(
|
t.assertTrue(
|
||||||
preparePayResultAfter.status === PreparePayResultType.AlreadyConfirmed,
|
preparePayResultAfter.status === PreparePayResultType.AlreadyConfirmed,
|
||||||
);
|
);
|
||||||
|
@ -34,11 +34,7 @@ import {
|
|||||||
TalerErrorCode,
|
TalerErrorCode,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import { TalerError } from "./errors.js";
|
import { TalerError } from "./errors.js";
|
||||||
import {
|
import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from "./util/http.js";
|
||||||
HttpRequestLibrary,
|
|
||||||
readSuccessResponseJsonOrErrorCode,
|
|
||||||
readSuccessResponseJsonOrThrow,
|
|
||||||
} from "./index.browser.js";
|
|
||||||
|
|
||||||
const logger = new Logger("bank-api-client.ts");
|
const logger = new Logger("bank-api-client.ts");
|
||||||
|
|
||||||
|
@ -98,11 +98,11 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
|
|||||||
*/
|
*/
|
||||||
export const WALLET_DB_MINOR_VERSION = 2;
|
export const WALLET_DB_MINOR_VERSION = 2;
|
||||||
|
|
||||||
export namespace OperationStatusRange {
|
export enum OperationStatusRange {
|
||||||
export const ACTIVE_START = 10;
|
ACTIVE_START = 10,
|
||||||
export const ACTIVE_END = 29;
|
ACTIVE_END = 29,
|
||||||
export const DORMANT_START = 50;
|
DORMANT_START = 50,
|
||||||
export const DORMANT_END = 69;
|
DORMANT_END = 69,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -741,93 +741,6 @@ export interface CoinAllocation {
|
|||||||
amount: AmountString;
|
amount: AmountString;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ProposalStatus {
|
|
||||||
/**
|
|
||||||
* Not downloaded yet.
|
|
||||||
*/
|
|
||||||
Downloading = "downloading",
|
|
||||||
/**
|
|
||||||
* Proposal downloaded, but the user needs to accept/reject it.
|
|
||||||
*/
|
|
||||||
Proposed = "proposed",
|
|
||||||
/**
|
|
||||||
* The user has accepted the proposal.
|
|
||||||
*/
|
|
||||||
Accepted = "accepted",
|
|
||||||
/**
|
|
||||||
* The user has rejected the proposal.
|
|
||||||
*/
|
|
||||||
Refused = "refused",
|
|
||||||
/**
|
|
||||||
* Downloading or processing the proposal has failed permanently.
|
|
||||||
*/
|
|
||||||
PermanentlyFailed = "permanently-failed",
|
|
||||||
/**
|
|
||||||
* Downloaded proposal was detected as a re-purchase.
|
|
||||||
*/
|
|
||||||
Repurchase = "repurchase",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProposalDownload {
|
|
||||||
/**
|
|
||||||
* The contract that was offered by the merchant.
|
|
||||||
*/
|
|
||||||
contractTermsRaw: any;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracted / parsed data from the contract terms.
|
|
||||||
*
|
|
||||||
* FIXME: Do we need to store *all* that data in duplicate?
|
|
||||||
*/
|
|
||||||
contractData: WalletContractData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record for a downloaded order, stored in the wallet's database.
|
|
||||||
*/
|
|
||||||
export interface ProposalRecord {
|
|
||||||
orderId: string;
|
|
||||||
|
|
||||||
merchantBaseUrl: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloaded data from the merchant.
|
|
||||||
*/
|
|
||||||
download: ProposalDownload | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unique ID when the order is stored in the wallet DB.
|
|
||||||
*/
|
|
||||||
proposalId: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Timestamp (in ms) of when the record
|
|
||||||
* was created.
|
|
||||||
*/
|
|
||||||
timestamp: TalerProtocolTimestamp;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private key for the nonce.
|
|
||||||
*/
|
|
||||||
noncePriv: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public key for the nonce.
|
|
||||||
*/
|
|
||||||
noncePub: string;
|
|
||||||
|
|
||||||
claimToken: string | undefined;
|
|
||||||
|
|
||||||
proposalStatus: ProposalStatus;
|
|
||||||
|
|
||||||
repurchaseProposalId: string | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session ID we got when downloading the contract.
|
|
||||||
*/
|
|
||||||
downloadSessionId: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status of a tip we got from a merchant.
|
* Status of a tip we got from a merchant.
|
||||||
*/
|
*/
|
||||||
@ -1113,23 +1026,132 @@ export interface WalletContractData {
|
|||||||
deliveryLocation: Location | undefined;
|
deliveryLocation: Location | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AbortStatus {
|
export enum ProposalStatus {
|
||||||
None = "none",
|
/**
|
||||||
AbortRefund = "abort-refund",
|
* Not downloaded yet.
|
||||||
AbortFinished = "abort-finished",
|
*/
|
||||||
|
DownloadingProposal = OperationStatusRange.ACTIVE_START,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user has accepted the proposal.
|
||||||
|
*/
|
||||||
|
Paying = OperationStatusRange.ACTIVE_START + 1,
|
||||||
|
|
||||||
|
AbortingWithRefund = OperationStatusRange.ACTIVE_START + 2,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paying a second time, likely with different session ID
|
||||||
|
*/
|
||||||
|
PayingReplay = OperationStatusRange.ACTIVE_START + 3,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query for refunds (until query succeeds).
|
||||||
|
*/
|
||||||
|
QueryingRefund = OperationStatusRange.ACTIVE_START + 4,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query for refund (until auto-refund deadline is reached).
|
||||||
|
*/
|
||||||
|
QueryingAutoRefund = OperationStatusRange.ACTIVE_START + 5,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposal downloaded, but the user needs to accept/reject it.
|
||||||
|
*/
|
||||||
|
Proposed = OperationStatusRange.DORMANT_START,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user has rejected the proposal.
|
||||||
|
*/
|
||||||
|
ProposalRefused = OperationStatusRange.DORMANT_START + 1,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloading or processing the proposal has failed permanently.
|
||||||
|
*/
|
||||||
|
ProposalDownloadFailed = OperationStatusRange.DORMANT_START + 2,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloaded proposal was detected as a re-purchase.
|
||||||
|
*/
|
||||||
|
RepurchaseDetected = OperationStatusRange.DORMANT_START + 3,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The payment has been aborted.
|
||||||
|
*/
|
||||||
|
PaymentAbortFinished = OperationStatusRange.DORMANT_START + 4,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment was successful.
|
||||||
|
*/
|
||||||
|
Paid = OperationStatusRange.DORMANT_START + 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProposalDownload {
|
||||||
|
/**
|
||||||
|
* The contract that was offered by the merchant.
|
||||||
|
*/
|
||||||
|
contractTermsRaw: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracted / parsed data from the contract terms.
|
||||||
|
*
|
||||||
|
* FIXME: Do we need to store *all* that data in duplicate?
|
||||||
|
*/
|
||||||
|
contractData: WalletContractData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchasePayInfo {
|
||||||
|
payCoinSelection: PayCoinSelection;
|
||||||
|
totalPayCost: AmountJson;
|
||||||
|
payCoinSelectionUid: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deposit permissions, available once the user has accepted the payment.
|
||||||
|
*
|
||||||
|
* This value is cached and derived from payCoinSelection.
|
||||||
|
*
|
||||||
|
* FIXME: Should probably be cached somewhere else, maybe not even in DB!
|
||||||
|
*/
|
||||||
|
coinDepositPermissions: CoinDepositPermission[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record that stores status information about one purchase, starting from when
|
* Record that stores status information about one purchase, starting from when
|
||||||
* the customer accepts a proposal. Includes refund status if applicable.
|
* the customer accepts a proposal. Includes refund status if applicable.
|
||||||
|
*
|
||||||
|
* FIXME: Should have a single "status" field.
|
||||||
*/
|
*/
|
||||||
export interface PurchaseRecord {
|
export interface PurchaseRecord {
|
||||||
/**
|
/**
|
||||||
* Proposal ID for this purchase. Uniquely identifies the
|
* Proposal ID for this purchase. Uniquely identifies the
|
||||||
* purchase and the proposal.
|
* purchase and the proposal.
|
||||||
|
* Assigned by the wallet.
|
||||||
*/
|
*/
|
||||||
proposalId: string;
|
proposalId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order ID, assigned by the merchant.
|
||||||
|
*/
|
||||||
|
orderId: string;
|
||||||
|
|
||||||
|
merchantBaseUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim token used when downloading the contract terms.
|
||||||
|
*/
|
||||||
|
claimToken: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session ID we got when downloading the contract.
|
||||||
|
*/
|
||||||
|
downloadSessionId: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If this purchase is a repurchase, this field identifies the original purchase.
|
||||||
|
*/
|
||||||
|
repurchaseProposalId: string | undefined;
|
||||||
|
|
||||||
|
status: ProposalStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private key for the nonce.
|
* Private key for the nonce.
|
||||||
*/
|
*/
|
||||||
@ -1146,18 +1168,9 @@ export interface PurchaseRecord {
|
|||||||
* FIXME: Move this into another object store,
|
* FIXME: Move this into another object store,
|
||||||
* to improve read/write perf on purchases.
|
* to improve read/write perf on purchases.
|
||||||
*/
|
*/
|
||||||
download: ProposalDownload;
|
download: ProposalDownload | undefined;
|
||||||
|
|
||||||
/**
|
payInfo: PurchasePayInfo | undefined;
|
||||||
* Deposit permissions, available once the user has accepted the payment.
|
|
||||||
*
|
|
||||||
* This value is cached and derived from payCoinSelection.
|
|
||||||
*/
|
|
||||||
coinDepositPermissions: CoinDepositPermission[] | undefined;
|
|
||||||
|
|
||||||
payCoinSelection: PayCoinSelection;
|
|
||||||
|
|
||||||
payCoinSelectionUid: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pending removals from pay coin selection.
|
* Pending removals from pay coin selection.
|
||||||
@ -1169,8 +1182,6 @@ export interface PurchaseRecord {
|
|||||||
*/
|
*/
|
||||||
pendingRemovedCoinPubs?: string[];
|
pendingRemovedCoinPubs?: string[];
|
||||||
|
|
||||||
totalPayCost: AmountJson;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@ -1181,11 +1192,16 @@ export interface PurchaseRecord {
|
|||||||
|
|
||||||
merchantPaySig: string | undefined;
|
merchantPaySig: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When was the purchase record created?
|
||||||
|
*/
|
||||||
|
timestamp: TalerProtocolTimestamp;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When was the purchase made?
|
* When was the purchase made?
|
||||||
* Refers to the time that the user accepted.
|
* Refers to the time that the user accepted.
|
||||||
*/
|
*/
|
||||||
timestampAccept: TalerProtocolTimestamp;
|
timestampAccept: TalerProtocolTimestamp | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pending refunds for the purchase. A refund is pending
|
* Pending refunds for the purchase. A refund is pending
|
||||||
@ -1206,18 +1222,6 @@ export interface PurchaseRecord {
|
|||||||
*/
|
*/
|
||||||
lastSessionId: string | undefined;
|
lastSessionId: string | undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* Do we still need to post the deposit permissions to the merchant?
|
|
||||||
* Set for the first payment, or on re-plays.
|
|
||||||
*/
|
|
||||||
paymentSubmitPending: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do we need to query the merchant for the refund status
|
|
||||||
* of the payment?
|
|
||||||
*/
|
|
||||||
refundQueryRequested: boolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Continue querying the refund status until this deadline has expired.
|
* Continue querying the refund status until this deadline has expired.
|
||||||
*/
|
*/
|
||||||
@ -1227,18 +1231,7 @@ export interface PurchaseRecord {
|
|||||||
* How much merchant has refund to be taken but the wallet
|
* How much merchant has refund to be taken but the wallet
|
||||||
* did not picked up yet
|
* did not picked up yet
|
||||||
*/
|
*/
|
||||||
refundAwaiting: AmountJson | undefined;
|
refundAmountAwaiting: AmountJson | undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the payment frozen? I.e. did we encounter
|
|
||||||
* an error where it doesn't make sense to retry.
|
|
||||||
*/
|
|
||||||
payFrozen?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FIXME: How does this interact with payFrozen?
|
|
||||||
*/
|
|
||||||
abortStatus: AbortStatus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
|
export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
|
||||||
@ -1923,16 +1916,6 @@ export const WalletStoresV1 = {
|
|||||||
}),
|
}),
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
proposals: describeStore(
|
|
||||||
"proposals",
|
|
||||||
describeContents<ProposalRecord>({ keyPath: "proposalId" }),
|
|
||||||
{
|
|
||||||
byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
|
|
||||||
"merchantBaseUrl",
|
|
||||||
"orderId",
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
refreshGroups: describeStore(
|
refreshGroups: describeStore(
|
||||||
"refreshGroups",
|
"refreshGroups",
|
||||||
describeContents<RefreshGroupRecord>({
|
describeContents<RefreshGroupRecord>({
|
||||||
@ -1953,14 +1936,20 @@ export const WalletStoresV1 = {
|
|||||||
"purchases",
|
"purchases",
|
||||||
describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
|
describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
|
||||||
{
|
{
|
||||||
|
byStatus: describeIndex("byStatus", "operationStatus"),
|
||||||
byFulfillmentUrl: describeIndex(
|
byFulfillmentUrl: describeIndex(
|
||||||
"byFulfillmentUrl",
|
"byFulfillmentUrl",
|
||||||
"download.contractData.fulfillmentUrl",
|
"download.contractData.fulfillmentUrl",
|
||||||
),
|
),
|
||||||
|
// FIXME: Deduplicate!
|
||||||
byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [
|
byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [
|
||||||
"download.contractData.merchantBaseUrl",
|
"download.contractData.merchantBaseUrl",
|
||||||
"download.contractData.orderId",
|
"download.contractData.orderId",
|
||||||
]),
|
]),
|
||||||
|
byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
|
||||||
|
"merchantBaseUrl",
|
||||||
|
"orderId",
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
tips: describeStore(
|
tips: describeStore(
|
||||||
|
@ -26,6 +26,9 @@
|
|||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
AbsoluteTime,
|
||||||
|
AgeRestriction,
|
||||||
|
AmountJson,
|
||||||
Amounts,
|
Amounts,
|
||||||
AmountString,
|
AmountString,
|
||||||
codecForAny,
|
codecForAny,
|
||||||
@ -35,7 +38,6 @@ import {
|
|||||||
codecForExchangeRevealResponse,
|
codecForExchangeRevealResponse,
|
||||||
codecForWithdrawResponse,
|
codecForWithdrawResponse,
|
||||||
DenominationPubKey,
|
DenominationPubKey,
|
||||||
eddsaGetPublic,
|
|
||||||
encodeCrock,
|
encodeCrock,
|
||||||
ExchangeMeltRequest,
|
ExchangeMeltRequest,
|
||||||
ExchangeProtocolVersion,
|
ExchangeProtocolVersion,
|
||||||
@ -44,29 +46,15 @@ import {
|
|||||||
hashWire,
|
hashWire,
|
||||||
Logger,
|
Logger,
|
||||||
parsePaytoUri,
|
parsePaytoUri,
|
||||||
AbsoluteTime,
|
|
||||||
UnblindedSignature,
|
UnblindedSignature,
|
||||||
BankWithdrawDetails,
|
|
||||||
parseWithdrawUri,
|
|
||||||
AmountJson,
|
|
||||||
AgeRestriction,
|
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
|
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
|
||||||
import { DenominationRecord } from "./db.js";
|
import { DenominationRecord } from "./db.js";
|
||||||
import {
|
import { BankAccessApi, BankApi, BankServiceHandle } from "./bank-api-client.js";
|
||||||
assembleRefreshRevealRequest,
|
import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from "./util/http.js";
|
||||||
ExchangeInfo,
|
import { getBankStatusUrl, getBankWithdrawalInfo, isWithdrawableDenom } from "./operations/withdraw.js";
|
||||||
getBankWithdrawalInfo,
|
import { ExchangeInfo } from "./operations/exchanges.js";
|
||||||
HttpRequestLibrary,
|
import { assembleRefreshRevealRequest } from "./operations/refresh.js";
|
||||||
isWithdrawableDenom,
|
|
||||||
readSuccessResponseJsonOrThrow,
|
|
||||||
} from "./index.browser.js";
|
|
||||||
import {
|
|
||||||
BankAccessApi,
|
|
||||||
BankApi,
|
|
||||||
BankServiceHandle,
|
|
||||||
getBankStatusUrl,
|
|
||||||
} from "./index.js";
|
|
||||||
|
|
||||||
const logger = new Logger("dbless.ts");
|
const logger = new Logger("dbless.ts");
|
||||||
|
|
||||||
|
@ -47,7 +47,6 @@ export * from "./wallet-api-types.js";
|
|||||||
export * from "./wallet.js";
|
export * from "./wallet.js";
|
||||||
|
|
||||||
export * from "./operations/backup/index.js";
|
export * from "./operations/backup/index.js";
|
||||||
export { makeEventId } from "./operations/transactions.js";
|
|
||||||
|
|
||||||
export * from "./operations/exchanges.js";
|
export * from "./operations/exchanges.js";
|
||||||
|
|
||||||
|
@ -37,6 +37,9 @@ import {
|
|||||||
TalerProtocolTimestamp,
|
TalerProtocolTimestamp,
|
||||||
CancellationToken,
|
CancellationToken,
|
||||||
DenominationInfo,
|
DenominationInfo,
|
||||||
|
RefreshGroupId,
|
||||||
|
CoinPublicKey,
|
||||||
|
RefreshReason,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js";
|
import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js";
|
||||||
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
|
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
|
||||||
@ -74,6 +77,20 @@ export interface MerchantOperations {
|
|||||||
): Promise<MerchantInfo>;
|
): Promise<MerchantInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RefreshOperations {
|
||||||
|
createRefreshGroup(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
tx: GetReadWriteAccess<{
|
||||||
|
denominations: typeof WalletStoresV1.denominations;
|
||||||
|
coins: typeof WalletStoresV1.coins;
|
||||||
|
refreshGroups: typeof WalletStoresV1.refreshGroups;
|
||||||
|
coinAvailability: typeof WalletStoresV1.coinAvailability;
|
||||||
|
}>,
|
||||||
|
oldCoinPubs: CoinPublicKey[],
|
||||||
|
reason: RefreshReason,
|
||||||
|
): Promise<RefreshGroupId>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for exchange-related operations.
|
* Interface for exchange-related operations.
|
||||||
*/
|
*/
|
||||||
@ -172,6 +189,7 @@ export interface InternalWalletState {
|
|||||||
exchangeOps: ExchangeOperations;
|
exchangeOps: ExchangeOperations;
|
||||||
recoupOps: RecoupOperations;
|
recoupOps: RecoupOperations;
|
||||||
merchantOps: MerchantOperations;
|
merchantOps: MerchantOperations;
|
||||||
|
refreshOps: RefreshOperations;
|
||||||
|
|
||||||
getDenomInfo(
|
getDenomInfo(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
|
@ -37,7 +37,7 @@ import {
|
|||||||
BackupExchangeDetails,
|
BackupExchangeDetails,
|
||||||
BackupExchangeWireFee,
|
BackupExchangeWireFee,
|
||||||
BackupOperationStatus,
|
BackupOperationStatus,
|
||||||
BackupProposal,
|
BackupPayInfo,
|
||||||
BackupProposalStatus,
|
BackupProposalStatus,
|
||||||
BackupPurchase,
|
BackupPurchase,
|
||||||
BackupRecoupGroup,
|
BackupRecoupGroup,
|
||||||
@ -62,11 +62,9 @@ import {
|
|||||||
WalletBackupContentV1,
|
WalletBackupContentV1,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import {
|
import {
|
||||||
AbortStatus,
|
|
||||||
CoinSourceType,
|
CoinSourceType,
|
||||||
CoinStatus,
|
CoinStatus,
|
||||||
DenominationRecord,
|
DenominationRecord,
|
||||||
OperationStatus,
|
|
||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
RefreshCoinStatus,
|
RefreshCoinStatus,
|
||||||
RefundState,
|
RefundState,
|
||||||
@ -92,7 +90,6 @@ export async function exportBackup(
|
|||||||
x.coins,
|
x.coins,
|
||||||
x.denominations,
|
x.denominations,
|
||||||
x.purchases,
|
x.purchases,
|
||||||
x.proposals,
|
|
||||||
x.refreshGroups,
|
x.refreshGroups,
|
||||||
x.backupProviders,
|
x.backupProviders,
|
||||||
x.tips,
|
x.tips,
|
||||||
@ -109,7 +106,6 @@ export async function exportBackup(
|
|||||||
[url: string]: BackupDenomination[];
|
[url: string]: BackupDenomination[];
|
||||||
} = {};
|
} = {};
|
||||||
const backupPurchases: BackupPurchase[] = [];
|
const backupPurchases: BackupPurchase[] = [];
|
||||||
const backupProposals: BackupProposal[] = [];
|
|
||||||
const backupRefreshGroups: BackupRefreshGroup[] = [];
|
const backupRefreshGroups: BackupRefreshGroup[] = [];
|
||||||
const backupBackupProviders: BackupBackupProvider[] = [];
|
const backupBackupProviders: BackupBackupProvider[] = [];
|
||||||
const backupTips: BackupTip[] = [];
|
const backupTips: BackupTip[] = [];
|
||||||
@ -385,65 +381,61 @@ export async function exportBackup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
backupPurchases.push({
|
|
||||||
contract_terms_raw: purch.download.contractTermsRaw,
|
|
||||||
auto_refund_deadline: purch.autoRefundDeadline,
|
|
||||||
merchant_pay_sig: purch.merchantPaySig,
|
|
||||||
pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({
|
|
||||||
coin_pub: x,
|
|
||||||
contribution: Amounts.stringify(
|
|
||||||
purch.payCoinSelection.coinContributions[i],
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
proposal_id: purch.proposalId,
|
|
||||||
refunds,
|
|
||||||
timestamp_accept: purch.timestampAccept,
|
|
||||||
timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
|
|
||||||
abort_status:
|
|
||||||
purch.abortStatus === AbortStatus.None
|
|
||||||
? undefined
|
|
||||||
: purch.abortStatus,
|
|
||||||
nonce_priv: purch.noncePriv,
|
|
||||||
merchant_sig: purch.download.contractData.merchantSig,
|
|
||||||
total_pay_cost: Amounts.stringify(purch.totalPayCost),
|
|
||||||
pay_coins_uid: purch.payCoinSelectionUid,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.proposals.iter().forEach((prop) => {
|
|
||||||
if (purchaseProposalIdSet.has(prop.proposalId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let propStatus: BackupProposalStatus;
|
let propStatus: BackupProposalStatus;
|
||||||
switch (prop.proposalStatus) {
|
switch (purch.status) {
|
||||||
case ProposalStatus.Accepted:
|
case ProposalStatus.Paid:
|
||||||
|
propStatus = BackupProposalStatus.Paid;
|
||||||
return;
|
return;
|
||||||
case ProposalStatus.Downloading:
|
case ProposalStatus.DownloadingProposal:
|
||||||
case ProposalStatus.Proposed:
|
case ProposalStatus.Proposed:
|
||||||
propStatus = BackupProposalStatus.Proposed;
|
propStatus = BackupProposalStatus.Proposed;
|
||||||
break;
|
break;
|
||||||
case ProposalStatus.PermanentlyFailed:
|
case ProposalStatus.ProposalDownloadFailed:
|
||||||
propStatus = BackupProposalStatus.PermanentlyFailed;
|
propStatus = BackupProposalStatus.PermanentlyFailed;
|
||||||
break;
|
break;
|
||||||
case ProposalStatus.Refused:
|
case ProposalStatus.ProposalRefused:
|
||||||
propStatus = BackupProposalStatus.Refused;
|
propStatus = BackupProposalStatus.Refused;
|
||||||
break;
|
break;
|
||||||
case ProposalStatus.Repurchase:
|
case ProposalStatus.RepurchaseDetected:
|
||||||
propStatus = BackupProposalStatus.Repurchase;
|
propStatus = BackupProposalStatus.Repurchase;
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
throw Error();
|
||||||
}
|
}
|
||||||
backupProposals.push({
|
|
||||||
claim_token: prop.claimToken,
|
const payInfo = purch.payInfo;
|
||||||
nonce_priv: prop.noncePriv,
|
let backupPayInfo: BackupPayInfo | undefined = undefined;
|
||||||
proposal_id: prop.noncePriv,
|
if (payInfo) {
|
||||||
|
backupPayInfo = {
|
||||||
|
pay_coins: payInfo.payCoinSelection.coinPubs.map((x, i) => ({
|
||||||
|
coin_pub: x,
|
||||||
|
contribution: Amounts.stringify(
|
||||||
|
payInfo.payCoinSelection.coinContributions[i],
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
total_pay_cost: Amounts.stringify(payInfo.totalPayCost),
|
||||||
|
pay_coins_uid: payInfo.payCoinSelectionUid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
backupPurchases.push({
|
||||||
|
contract_terms_raw: purch.download?.contractTermsRaw,
|
||||||
|
auto_refund_deadline: purch.autoRefundDeadline,
|
||||||
|
merchant_pay_sig: purch.merchantPaySig,
|
||||||
|
pay_info: backupPayInfo,
|
||||||
|
proposal_id: purch.proposalId,
|
||||||
|
refunds,
|
||||||
|
timestamp_accepted: purch.timestampAccept,
|
||||||
|
timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
|
||||||
|
nonce_priv: purch.noncePriv,
|
||||||
|
merchant_sig: purch.download?.contractData.merchantSig,
|
||||||
|
claim_token: purch.claimToken,
|
||||||
|
merchant_base_url: purch.merchantBaseUrl,
|
||||||
|
order_id: purch.orderId,
|
||||||
proposal_status: propStatus,
|
proposal_status: propStatus,
|
||||||
repurchase_proposal_id: prop.repurchaseProposalId,
|
repurchase_proposal_id: purch.repurchaseProposalId,
|
||||||
timestamp: prop.timestamp,
|
download_session_id: purch.downloadSessionId,
|
||||||
contract_terms_raw: prop.download?.contractTermsRaw,
|
timestamp_proposed: purch.timestamp,
|
||||||
download_session_id: prop.downloadSessionId,
|
|
||||||
merchant_base_url: prop.merchantBaseUrl,
|
|
||||||
order_id: prop.orderId,
|
|
||||||
merchant_sig: prop.download?.contractData.merchantSig,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -498,7 +490,6 @@ export async function exportBackup(
|
|||||||
wallet_root_pub: bs.walletRootPub,
|
wallet_root_pub: bs.walletRootPub,
|
||||||
backup_providers: backupBackupProviders,
|
backup_providers: backupBackupProviders,
|
||||||
current_device_id: bs.deviceId,
|
current_device_id: bs.deviceId,
|
||||||
proposals: backupProposals,
|
|
||||||
purchases: backupPurchases,
|
purchases: backupPurchases,
|
||||||
recoup_groups: backupRecoupGroups,
|
recoup_groups: backupRecoupGroups,
|
||||||
refresh_groups: backupRefreshGroups,
|
refresh_groups: backupRefreshGroups,
|
||||||
|
@ -21,8 +21,8 @@ import {
|
|||||||
BackupCoin,
|
BackupCoin,
|
||||||
BackupCoinSourceType,
|
BackupCoinSourceType,
|
||||||
BackupDenomSel,
|
BackupDenomSel,
|
||||||
|
BackupPayInfo,
|
||||||
BackupProposalStatus,
|
BackupProposalStatus,
|
||||||
BackupPurchase,
|
|
||||||
BackupRefreshReason,
|
BackupRefreshReason,
|
||||||
BackupRefundState,
|
BackupRefundState,
|
||||||
BackupWgType,
|
BackupWgType,
|
||||||
@ -37,7 +37,6 @@ import {
|
|||||||
WireInfo,
|
WireInfo,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import {
|
import {
|
||||||
AbortStatus,
|
|
||||||
CoinRecord,
|
CoinRecord,
|
||||||
CoinSource,
|
CoinSource,
|
||||||
CoinSourceType,
|
CoinSourceType,
|
||||||
@ -48,28 +47,23 @@ import {
|
|||||||
OperationStatus,
|
OperationStatus,
|
||||||
ProposalDownload,
|
ProposalDownload,
|
||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
|
PurchasePayInfo,
|
||||||
RefreshCoinStatus,
|
RefreshCoinStatus,
|
||||||
RefreshSessionRecord,
|
RefreshSessionRecord,
|
||||||
RefundState,
|
RefundState,
|
||||||
ReserveBankInfo,
|
|
||||||
WithdrawalGroupStatus,
|
|
||||||
WalletContractData,
|
WalletContractData,
|
||||||
WalletRefundItem,
|
WalletRefundItem,
|
||||||
WalletStoresV1,
|
WalletStoresV1,
|
||||||
WgInfo,
|
WgInfo,
|
||||||
|
WithdrawalGroupStatus,
|
||||||
WithdrawalRecordType,
|
WithdrawalRecordType,
|
||||||
} from "../../db.js";
|
} from "../../db.js";
|
||||||
import { InternalWalletState } from "../../internal-wallet-state.js";
|
import { InternalWalletState } from "../../internal-wallet-state.js";
|
||||||
import { assertUnreachable } from "../../util/assertUnreachable.js";
|
import { assertUnreachable } from "../../util/assertUnreachable.js";
|
||||||
import {
|
import { checkLogicInvariant } from "../../util/invariants.js";
|
||||||
checkDbInvariant,
|
|
||||||
checkLogicInvariant,
|
|
||||||
} from "../../util/invariants.js";
|
|
||||||
import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
|
import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
|
||||||
import { RetryInfo } from "../../util/retries.js";
|
import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js";
|
||||||
import { makeCoinAvailable } from "../../wallet.js";
|
|
||||||
import { getExchangeDetails } from "../exchanges.js";
|
import { getExchangeDetails } from "../exchanges.js";
|
||||||
import { makeEventId, TombstoneTag } from "../transactions.js";
|
|
||||||
import { provideBackupState } from "./state.js";
|
import { provideBackupState } from "./state.js";
|
||||||
|
|
||||||
const logger = new Logger("operations/backup/import.ts");
|
const logger = new Logger("operations/backup/import.ts");
|
||||||
@ -95,10 +89,10 @@ async function recoverPayCoinSelection(
|
|||||||
denominations: typeof WalletStoresV1.denominations;
|
denominations: typeof WalletStoresV1.denominations;
|
||||||
}>,
|
}>,
|
||||||
contractData: WalletContractData,
|
contractData: WalletContractData,
|
||||||
backupPurchase: BackupPurchase,
|
payInfo: BackupPayInfo,
|
||||||
): Promise<PayCoinSelection> {
|
): Promise<PayCoinSelection> {
|
||||||
const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
|
const coinPubs: string[] = payInfo.pay_coins.map((x) => x.coin_pub);
|
||||||
const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
|
const coinContributions: AmountJson[] = payInfo.pay_coins.map((x) =>
|
||||||
Amounts.parseOrThrow(x.contribution),
|
Amounts.parseOrThrow(x.contribution),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -316,7 +310,6 @@ export async function importBackup(
|
|||||||
x.coinAvailability,
|
x.coinAvailability,
|
||||||
x.denominations,
|
x.denominations,
|
||||||
x.purchases,
|
x.purchases,
|
||||||
x.proposals,
|
|
||||||
x.refreshGroups,
|
x.refreshGroups,
|
||||||
x.backupProviders,
|
x.backupProviders,
|
||||||
x.tips,
|
x.tips,
|
||||||
@ -560,113 +553,6 @@ export async function importBackup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const backupProposal of backupBlob.proposals) {
|
|
||||||
const ts = makeEventId(
|
|
||||||
TombstoneTag.DeletePayment,
|
|
||||||
backupProposal.proposal_id,
|
|
||||||
);
|
|
||||||
if (tombstoneSet.has(ts)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const existingProposal = await tx.proposals.get(
|
|
||||||
backupProposal.proposal_id,
|
|
||||||
);
|
|
||||||
if (!existingProposal) {
|
|
||||||
let download: ProposalDownload | undefined;
|
|
||||||
let proposalStatus: ProposalStatus;
|
|
||||||
switch (backupProposal.proposal_status) {
|
|
||||||
case BackupProposalStatus.Proposed:
|
|
||||||
if (backupProposal.contract_terms_raw) {
|
|
||||||
proposalStatus = ProposalStatus.Proposed;
|
|
||||||
} else {
|
|
||||||
proposalStatus = ProposalStatus.Downloading;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case BackupProposalStatus.Refused:
|
|
||||||
proposalStatus = ProposalStatus.Refused;
|
|
||||||
break;
|
|
||||||
case BackupProposalStatus.Repurchase:
|
|
||||||
proposalStatus = ProposalStatus.Repurchase;
|
|
||||||
break;
|
|
||||||
case BackupProposalStatus.PermanentlyFailed:
|
|
||||||
proposalStatus = ProposalStatus.PermanentlyFailed;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (backupProposal.contract_terms_raw) {
|
|
||||||
checkDbInvariant(!!backupProposal.merchant_sig);
|
|
||||||
const parsedContractTerms = codecForContractTerms().decode(
|
|
||||||
backupProposal.contract_terms_raw,
|
|
||||||
);
|
|
||||||
const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
|
|
||||||
const contractTermsHash =
|
|
||||||
cryptoComp.proposalIdToContractTermsHash[
|
|
||||||
backupProposal.proposal_id
|
|
||||||
];
|
|
||||||
let maxWireFee: AmountJson;
|
|
||||||
if (parsedContractTerms.max_wire_fee) {
|
|
||||||
maxWireFee = Amounts.parseOrThrow(
|
|
||||||
parsedContractTerms.max_wire_fee,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
maxWireFee = Amounts.getZero(amount.currency);
|
|
||||||
}
|
|
||||||
download = {
|
|
||||||
contractData: {
|
|
||||||
amount,
|
|
||||||
contractTermsHash: contractTermsHash,
|
|
||||||
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
|
|
||||||
merchantBaseUrl: parsedContractTerms.merchant_base_url,
|
|
||||||
merchantPub: parsedContractTerms.merchant_pub,
|
|
||||||
merchantSig: backupProposal.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.auditor_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,
|
|
||||||
deliveryDate: parsedContractTerms.delivery_date,
|
|
||||||
deliveryLocation: parsedContractTerms.delivery_location,
|
|
||||||
},
|
|
||||||
contractTermsRaw: backupProposal.contract_terms_raw,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
await tx.proposals.put({
|
|
||||||
claimToken: backupProposal.claim_token,
|
|
||||||
merchantBaseUrl: backupProposal.merchant_base_url,
|
|
||||||
timestamp: backupProposal.timestamp,
|
|
||||||
orderId: backupProposal.order_id,
|
|
||||||
noncePriv: backupProposal.nonce_priv,
|
|
||||||
noncePub:
|
|
||||||
cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
|
|
||||||
proposalId: backupProposal.proposal_id,
|
|
||||||
repurchaseProposalId: backupProposal.repurchase_proposal_id,
|
|
||||||
download,
|
|
||||||
proposalStatus,
|
|
||||||
// FIXME!
|
|
||||||
downloadSessionId: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const backupPurchase of backupBlob.purchases) {
|
for (const backupPurchase of backupBlob.purchases) {
|
||||||
const ts = makeEventId(
|
const ts = makeEventId(
|
||||||
TombstoneTag.DeletePayment,
|
TombstoneTag.DeletePayment,
|
||||||
@ -678,6 +564,14 @@ export async function importBackup(
|
|||||||
const existingPurchase = await tx.purchases.get(
|
const existingPurchase = await tx.purchases.get(
|
||||||
backupPurchase.proposal_id,
|
backupPurchase.proposal_id,
|
||||||
);
|
);
|
||||||
|
let proposalStatus: ProposalStatus;
|
||||||
|
switch (backupPurchase.proposal_status) {
|
||||||
|
case BackupProposalStatus.Paid:
|
||||||
|
proposalStatus = ProposalStatus.Paid;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Error();
|
||||||
|
}
|
||||||
if (!existingPurchase) {
|
if (!existingPurchase) {
|
||||||
const refunds: { [refundKey: string]: WalletRefundItem } = {};
|
const refunds: { [refundKey: string]: WalletRefundItem } = {};
|
||||||
for (const backupRefund of backupPurchase.refunds) {
|
for (const backupRefund of backupPurchase.refunds) {
|
||||||
@ -721,25 +615,6 @@ export async function importBackup(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let abortStatus: AbortStatus;
|
|
||||||
switch (backupPurchase.abort_status) {
|
|
||||||
case "abort-finished":
|
|
||||||
abortStatus = AbortStatus.AbortFinished;
|
|
||||||
break;
|
|
||||||
case "abort-refund":
|
|
||||||
abortStatus = AbortStatus.AbortRefund;
|
|
||||||
break;
|
|
||||||
case undefined:
|
|
||||||
abortStatus = AbortStatus.None;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
logger.warn(
|
|
||||||
`got backup purchase abort_status ${j2s(
|
|
||||||
backupPurchase.abort_status,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
throw Error("not reachable");
|
|
||||||
}
|
|
||||||
const parsedContractTerms = codecForContractTerms().decode(
|
const parsedContractTerms = codecForContractTerms().decode(
|
||||||
backupPurchase.contract_terms_raw,
|
backupPurchase.contract_terms_raw,
|
||||||
);
|
);
|
||||||
@ -761,7 +636,7 @@ export async function importBackup(
|
|||||||
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
|
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
|
||||||
merchantBaseUrl: parsedContractTerms.merchant_base_url,
|
merchantBaseUrl: parsedContractTerms.merchant_base_url,
|
||||||
merchantPub: parsedContractTerms.merchant_pub,
|
merchantPub: parsedContractTerms.merchant_pub,
|
||||||
merchantSig: backupPurchase.merchant_sig,
|
merchantSig: backupPurchase.merchant_sig!,
|
||||||
orderId: parsedContractTerms.order_id,
|
orderId: parsedContractTerms.order_id,
|
||||||
summary: parsedContractTerms.summary,
|
summary: parsedContractTerms.summary,
|
||||||
autoRefund: parsedContractTerms.auto_refund,
|
autoRefund: parsedContractTerms.auto_refund,
|
||||||
@ -790,33 +665,46 @@ export async function importBackup(
|
|||||||
},
|
},
|
||||||
contractTermsRaw: backupPurchase.contract_terms_raw,
|
contractTermsRaw: backupPurchase.contract_terms_raw,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let payInfo: PurchasePayInfo | undefined = undefined;
|
||||||
|
if (backupPurchase.pay_info) {
|
||||||
|
payInfo = {
|
||||||
|
coinDepositPermissions: undefined,
|
||||||
|
payCoinSelection: await recoverPayCoinSelection(
|
||||||
|
tx,
|
||||||
|
download.contractData,
|
||||||
|
backupPurchase.pay_info,
|
||||||
|
),
|
||||||
|
payCoinSelectionUid: backupPurchase.pay_info.pay_coins_uid,
|
||||||
|
totalPayCost: Amounts.parseOrThrow(
|
||||||
|
backupPurchase.pay_info.total_pay_cost,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await tx.purchases.put({
|
await tx.purchases.put({
|
||||||
proposalId: backupPurchase.proposal_id,
|
proposalId: backupPurchase.proposal_id,
|
||||||
noncePriv: backupPurchase.nonce_priv,
|
noncePriv: backupPurchase.nonce_priv,
|
||||||
noncePub:
|
noncePub:
|
||||||
cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
|
cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
|
||||||
autoRefundDeadline: TalerProtocolTimestamp.never(),
|
autoRefundDeadline: TalerProtocolTimestamp.never(),
|
||||||
refundAwaiting: undefined,
|
timestampAccept: backupPurchase.timestamp_accepted,
|
||||||
timestampAccept: backupPurchase.timestamp_accept,
|
|
||||||
timestampFirstSuccessfulPay:
|
timestampFirstSuccessfulPay:
|
||||||
backupPurchase.timestamp_first_successful_pay,
|
backupPurchase.timestamp_first_successful_pay,
|
||||||
timestampLastRefundStatus: undefined,
|
timestampLastRefundStatus: undefined,
|
||||||
merchantPaySig: backupPurchase.merchant_pay_sig,
|
merchantPaySig: backupPurchase.merchant_pay_sig,
|
||||||
lastSessionId: undefined,
|
lastSessionId: undefined,
|
||||||
abortStatus,
|
|
||||||
download,
|
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,
|
refunds,
|
||||||
payCoinSelectionUid: backupPurchase.pay_coins_uid,
|
claimToken: backupPurchase.claim_token,
|
||||||
|
downloadSessionId: backupPurchase.download_session_id,
|
||||||
|
merchantBaseUrl: backupPurchase.merchant_base_url,
|
||||||
|
orderId: backupPurchase.order_id,
|
||||||
|
payInfo,
|
||||||
|
refundAmountAwaiting: undefined,
|
||||||
|
repurchaseProposalId: backupPurchase.repurchase_proposal_id,
|
||||||
|
status: proposalStatus,
|
||||||
|
timestamp: backupPurchase.timestamp_proposed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -948,7 +836,6 @@ export async function importBackup(
|
|||||||
await tx.depositGroups.delete(rest[0]);
|
await tx.depositGroups.delete(rest[0]);
|
||||||
} else if (type === TombstoneTag.DeletePayment) {
|
} else if (type === TombstoneTag.DeletePayment) {
|
||||||
await tx.purchases.delete(rest[0]);
|
await tx.purchases.delete(rest[0]);
|
||||||
await tx.proposals.delete(rest[0]);
|
|
||||||
} else if (type === TombstoneTag.DeleteRefreshGroup) {
|
} else if (type === TombstoneTag.DeleteRefreshGroup) {
|
||||||
await tx.refreshGroups.delete(rest[0]);
|
await tx.refreshGroups.delete(rest[0]);
|
||||||
} else if (type === TombstoneTag.DeleteRefund) {
|
} else if (type === TombstoneTag.DeleteRefund) {
|
||||||
|
@ -96,7 +96,7 @@ import {
|
|||||||
checkPaymentByProposalId,
|
checkPaymentByProposalId,
|
||||||
confirmPay,
|
confirmPay,
|
||||||
preparePayForUri,
|
preparePayForUri,
|
||||||
} from "../pay.js";
|
} from "../pay-merchant.js";
|
||||||
import { exportBackup } from "./export.js";
|
import { exportBackup } from "./export.js";
|
||||||
import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
|
import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
|
||||||
import { getWalletBackupState, provideBackupState } from "./state.js";
|
import { getWalletBackupState, provideBackupState } from "./state.js";
|
||||||
@ -193,15 +193,6 @@ async function computeBackupCryptoData(
|
|||||||
eddsaGetPublic(decodeCrock(backupWg.reserve_priv)),
|
eddsaGetPublic(decodeCrock(backupWg.reserve_priv)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const prop of backupContent.proposals) {
|
|
||||||
const { h: contractTermsHash } = await cryptoApi.hashString({
|
|
||||||
str: 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) {
|
for (const purch of backupContent.purchases) {
|
||||||
const { h: contractTermsHash } = await cryptoApi.hashString({
|
const { h: contractTermsHash } = await cryptoApi.hashString({
|
||||||
str: canonicalJson(purch.contract_terms_raw),
|
str: canonicalJson(purch.contract_terms_raw),
|
||||||
|
@ -17,38 +17,272 @@
|
|||||||
/**
|
/**
|
||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import { TalerErrorDetail, TalerErrorCode } from "@gnu-taler/taler-util";
|
import {
|
||||||
import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js";
|
AmountJson,
|
||||||
import { TalerError, getErrorDetailFromException } from "../errors.js";
|
Amounts,
|
||||||
|
j2s,
|
||||||
|
Logger,
|
||||||
|
RefreshReason,
|
||||||
|
TalerErrorCode,
|
||||||
|
TalerErrorDetail,
|
||||||
|
TransactionType,
|
||||||
|
} from "@gnu-taler/taler-util";
|
||||||
|
import { WalletStoresV1, CoinStatus, CoinRecord } from "../db.js";
|
||||||
|
import { makeErrorDetail, TalerError } from "../errors.js";
|
||||||
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||||
|
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
|
||||||
|
import { GetReadWriteAccess } from "../util/query.js";
|
||||||
|
import {
|
||||||
|
OperationAttemptResult,
|
||||||
|
OperationAttemptResultType,
|
||||||
|
RetryInfo,
|
||||||
|
} from "../util/retries.js";
|
||||||
|
import { createRefreshGroup } from "./refresh.js";
|
||||||
|
|
||||||
/**
|
const logger = new Logger("operations/common.ts");
|
||||||
* Run an operation and call the onOpError callback
|
|
||||||
* when there was an exception or operation error that must be reported.
|
export interface CoinsSpendInfo {
|
||||||
* The cause will be re-thrown to the caller.
|
coinPubs: string[];
|
||||||
|
contributions: AmountJson[];
|
||||||
|
refreshReason: RefreshReason;
|
||||||
|
/**
|
||||||
|
* Identifier for what the coin has been spent for.
|
||||||
*/
|
*/
|
||||||
export async function guardOperationException<T>(
|
allocationId: string;
|
||||||
op: () => Promise<T>,
|
}
|
||||||
onOpError: (e: TalerErrorDetail) => Promise<void>,
|
|
||||||
): Promise<T> {
|
export async function makeCoinAvailable(
|
||||||
try {
|
ws: InternalWalletState,
|
||||||
return await op();
|
tx: GetReadWriteAccess<{
|
||||||
} catch (e: any) {
|
coins: typeof WalletStoresV1.coins;
|
||||||
if (e instanceof CryptoApiStoppedError) {
|
coinAvailability: typeof WalletStoresV1.coinAvailability;
|
||||||
throw e;
|
denominations: typeof WalletStoresV1.denominations;
|
||||||
|
}>,
|
||||||
|
coinRecord: CoinRecord,
|
||||||
|
): Promise<void> {
|
||||||
|
checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
|
||||||
|
const existingCoin = await tx.coins.get(coinRecord.coinPub);
|
||||||
|
if (existingCoin) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (
|
const denom = await tx.denominations.get([
|
||||||
e instanceof TalerError &&
|
coinRecord.exchangeBaseUrl,
|
||||||
e.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED)
|
coinRecord.denomPubHash,
|
||||||
) {
|
]);
|
||||||
throw e;
|
checkDbInvariant(!!denom);
|
||||||
|
const ageRestriction = coinRecord.maxAge;
|
||||||
|
let car = await tx.coinAvailability.get([
|
||||||
|
coinRecord.exchangeBaseUrl,
|
||||||
|
coinRecord.denomPubHash,
|
||||||
|
ageRestriction,
|
||||||
|
]);
|
||||||
|
if (!car) {
|
||||||
|
car = {
|
||||||
|
maxAge: ageRestriction,
|
||||||
|
amountFrac: denom.amountFrac,
|
||||||
|
amountVal: denom.amountVal,
|
||||||
|
currency: denom.currency,
|
||||||
|
denomPubHash: denom.denomPubHash,
|
||||||
|
exchangeBaseUrl: denom.exchangeBaseUrl,
|
||||||
|
freshCoinCount: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const opErr = getErrorDetailFromException(e);
|
car.freshCoinCount++;
|
||||||
await onOpError(opErr);
|
await tx.coins.put(coinRecord);
|
||||||
throw TalerError.fromDetail(
|
await tx.coinAvailability.put(car);
|
||||||
TalerErrorCode.WALLET_PENDING_OPERATION_FAILED,
|
}
|
||||||
{
|
|
||||||
innerError: opErr,
|
export async function spendCoins(
|
||||||
},
|
ws: InternalWalletState,
|
||||||
|
tx: GetReadWriteAccess<{
|
||||||
|
coins: typeof WalletStoresV1.coins;
|
||||||
|
coinAvailability: typeof WalletStoresV1.coinAvailability;
|
||||||
|
refreshGroups: typeof WalletStoresV1.refreshGroups;
|
||||||
|
denominations: typeof WalletStoresV1.denominations;
|
||||||
|
}>,
|
||||||
|
csi: CoinsSpendInfo,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < csi.coinPubs.length; i++) {
|
||||||
|
const coin = await tx.coins.get(csi.coinPubs[i]);
|
||||||
|
if (!coin) {
|
||||||
|
throw Error("coin allocated for payment doesn't exist anymore");
|
||||||
|
}
|
||||||
|
const coinAvailability = await tx.coinAvailability.get([
|
||||||
|
coin.exchangeBaseUrl,
|
||||||
|
coin.denomPubHash,
|
||||||
|
coin.maxAge,
|
||||||
|
]);
|
||||||
|
checkDbInvariant(!!coinAvailability);
|
||||||
|
const contrib = csi.contributions[i];
|
||||||
|
if (coin.status !== CoinStatus.Fresh) {
|
||||||
|
const alloc = coin.allocation;
|
||||||
|
if (!alloc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (alloc.id !== csi.allocationId) {
|
||||||
|
// FIXME: assign error code
|
||||||
|
throw Error("conflicting coin allocation (id)");
|
||||||
|
}
|
||||||
|
if (0 !== Amounts.cmp(alloc.amount, contrib)) {
|
||||||
|
// FIXME: assign error code
|
||||||
|
throw Error("conflicting coin allocation (contrib)");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coin.status = CoinStatus.Dormant;
|
||||||
|
coin.allocation = {
|
||||||
|
id: csi.allocationId,
|
||||||
|
amount: Amounts.stringify(contrib),
|
||||||
|
};
|
||||||
|
const remaining = Amounts.sub(coin.currentAmount, contrib);
|
||||||
|
if (remaining.saturated) {
|
||||||
|
throw Error("not enough remaining balance on coin for payment");
|
||||||
|
}
|
||||||
|
coin.currentAmount = remaining.amount;
|
||||||
|
checkDbInvariant(!!coinAvailability);
|
||||||
|
if (coinAvailability.freshCoinCount === 0) {
|
||||||
|
throw Error(
|
||||||
|
`invalid coin count ${coinAvailability.freshCoinCount} in DB`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
coinAvailability.freshCoinCount--;
|
||||||
|
await tx.coins.put(coin);
|
||||||
|
await tx.coinAvailability.put(coinAvailability);
|
||||||
|
}
|
||||||
|
const refreshCoinPubs = csi.coinPubs.map((x) => ({
|
||||||
|
coinPub: x,
|
||||||
|
}));
|
||||||
|
await ws.refreshOps.createRefreshGroup(
|
||||||
|
ws,
|
||||||
|
tx,
|
||||||
|
refreshCoinPubs,
|
||||||
|
RefreshReason.PayMerchant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeOperationError(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
pendingTaskId: string,
|
||||||
|
e: TalerErrorDetail,
|
||||||
|
): Promise<void> {
|
||||||
|
await ws.db
|
||||||
|
.mktx((x) => [x.operationRetries])
|
||||||
|
.runReadWrite(async (tx) => {
|
||||||
|
let retryRecord = await tx.operationRetries.get(pendingTaskId);
|
||||||
|
if (!retryRecord) {
|
||||||
|
retryRecord = {
|
||||||
|
id: pendingTaskId,
|
||||||
|
lastError: e,
|
||||||
|
retryInfo: RetryInfo.reset(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
retryRecord.lastError = e;
|
||||||
|
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
|
||||||
|
}
|
||||||
|
await tx.operationRetries.put(retryRecord);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeOperationPending(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
pendingTaskId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await ws.db
|
||||||
|
.mktx((x) => [x.operationRetries])
|
||||||
|
.runReadWrite(async (tx) => {
|
||||||
|
let retryRecord = await tx.operationRetries.get(pendingTaskId);
|
||||||
|
if (!retryRecord) {
|
||||||
|
retryRecord = {
|
||||||
|
id: pendingTaskId,
|
||||||
|
retryInfo: RetryInfo.reset(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
delete retryRecord.lastError;
|
||||||
|
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
|
||||||
|
}
|
||||||
|
await tx.operationRetries.put(retryRecord);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOperationWithErrorReporting(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
opId: string,
|
||||||
|
f: () => Promise<OperationAttemptResult>,
|
||||||
|
): Promise<void> {
|
||||||
|
let maybeError: TalerErrorDetail | undefined;
|
||||||
|
try {
|
||||||
|
const resp = await f();
|
||||||
|
switch (resp.type) {
|
||||||
|
case OperationAttemptResultType.Error:
|
||||||
|
return await storeOperationError(ws, opId, resp.errorDetail);
|
||||||
|
case OperationAttemptResultType.Finished:
|
||||||
|
return await storeOperationFinished(ws, opId);
|
||||||
|
case OperationAttemptResultType.Pending:
|
||||||
|
return await storeOperationPending(ws, opId);
|
||||||
|
case OperationAttemptResultType.Longpoll:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TalerError) {
|
||||||
|
logger.warn("operation processed resulted in error");
|
||||||
|
logger.warn(`error was: ${j2s(e.errorDetail)}`);
|
||||||
|
maybeError = e.errorDetail;
|
||||||
|
return await storeOperationError(ws, opId, maybeError!);
|
||||||
|
} else if (e instanceof Error) {
|
||||||
|
// This is a bug, as we expect pending operations to always
|
||||||
|
// do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED
|
||||||
|
// or return something.
|
||||||
|
logger.error(`Uncaught exception: ${e.message}`);
|
||||||
|
logger.error(`Stack: ${e.stack}`);
|
||||||
|
maybeError = makeErrorDetail(
|
||||||
|
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
|
||||||
|
{
|
||||||
|
stack: e.stack,
|
||||||
|
},
|
||||||
|
`unexpected exception (message: ${e.message})`,
|
||||||
|
);
|
||||||
|
return await storeOperationError(ws, opId, maybeError);
|
||||||
|
} else {
|
||||||
|
logger.error("Uncaught exception, value is not even an error.");
|
||||||
|
maybeError = makeErrorDetail(
|
||||||
|
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
|
||||||
|
{},
|
||||||
|
`unexpected exception (not even an error)`,
|
||||||
|
);
|
||||||
|
return await storeOperationError(ws, opId, maybeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeOperationFinished(
|
||||||
|
ws: InternalWalletState,
|
||||||
|
pendingTaskId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await ws.db
|
||||||
|
.mktx((x) => [x.operationRetries])
|
||||||
|
.runReadWrite(async (tx) => {
|
||||||
|
await tx.operationRetries.delete(pendingTaskId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TombstoneTag {
|
||||||
|
DeleteWithdrawalGroup = "delete-withdrawal-group",
|
||||||
|
DeleteReserve = "delete-reserve",
|
||||||
|
DeletePayment = "delete-payment",
|
||||||
|
DeleteTip = "delete-tip",
|
||||||
|
DeleteRefreshGroup = "delete-refresh-group",
|
||||||
|
DeleteDepositGroup = "delete-deposit-group",
|
||||||
|
DeleteRefund = "delete-refund",
|
||||||
|
DeletePeerPullDebit = "delete-peer-pull-debit",
|
||||||
|
DeletePeerPushDebit = "delete-peer-push-debit",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an event ID from the type and the primary key for the event.
|
||||||
|
*/
|
||||||
|
export function makeEventId(
|
||||||
|
type: TransactionType | TombstoneTag,
|
||||||
|
...args: string[]
|
||||||
|
): string {
|
||||||
|
return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
|
||||||
}
|
}
|
||||||
|
@ -53,16 +53,15 @@ import {
|
|||||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||||
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
||||||
import { OperationAttemptResult } from "../util/retries.js";
|
import { OperationAttemptResult } from "../util/retries.js";
|
||||||
import { spendCoins } from "../wallet.js";
|
import { makeEventId, spendCoins } from "./common.js";
|
||||||
import { getExchangeDetails } from "./exchanges.js";
|
import { getExchangeDetails } from "./exchanges.js";
|
||||||
import {
|
import {
|
||||||
extractContractData,
|
extractContractData,
|
||||||
generateDepositPermissions,
|
generateDepositPermissions,
|
||||||
getTotalPaymentCost,
|
getTotalPaymentCost,
|
||||||
selectPayCoinsNew,
|
selectPayCoinsNew,
|
||||||
} from "./pay.js";
|
} from "./pay-merchant.js";
|
||||||
import { getTotalRefreshCost } from "./refresh.js";
|
import { getTotalRefreshCost } from "./refresh.js";
|
||||||
import { makeEventId } from "./transactions.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logger.
|
* Logger.
|
||||||
|
@ -40,7 +40,6 @@ import {
|
|||||||
parsePaytoUri,
|
parsePaytoUri,
|
||||||
Recoup,
|
Recoup,
|
||||||
TalerErrorCode,
|
TalerErrorCode,
|
||||||
TalerErrorDetail,
|
|
||||||
TalerProtocolDuration,
|
TalerProtocolDuration,
|
||||||
TalerProtocolTimestamp,
|
TalerProtocolTimestamp,
|
||||||
URL,
|
URL,
|
||||||
@ -71,11 +70,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
OperationAttemptResult,
|
OperationAttemptResult,
|
||||||
OperationAttemptResultType,
|
OperationAttemptResultType,
|
||||||
RetryInfo,
|
|
||||||
runOperationHandlerForResult,
|
runOperationHandlerForResult,
|
||||||
} from "../util/retries.js";
|
} from "../util/retries.js";
|
||||||
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
|
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
|
||||||
import { guardOperationException } from "./common.js";
|
|
||||||
|
|
||||||
const logger = new Logger("exchanges.ts");
|
const logger = new Logger("exchanges.ts");
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ import {
|
|||||||
LibtoolVersion,
|
LibtoolVersion,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js";
|
import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js";
|
||||||
import { readSuccessResponseJsonOrThrow } from "../index.js";
|
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
||||||
|
|
||||||
const logger = new Logger("taler-wallet-core:merchants.ts");
|
const logger = new Logger("taler-wallet-core:merchants.ts");
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ export async function getMerchantInfo(
|
|||||||
return existingInfo;
|
return existingInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configUrl = new URL("config", canonBaseUrl);
|
const configUrl = new URL("config", canonBaseUrl);
|
||||||
const resp = await ws.http.get(configUrl.href);
|
const resp = await ws.http.get(configUrl.href);
|
||||||
|
|
||||||
const configResp = await readSuccessResponseJsonOrThrow(
|
const configResp = await readSuccessResponseJsonOrThrow(
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -73,9 +73,8 @@ import { InternalWalletState } from "../internal-wallet-state.js";
|
|||||||
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
||||||
import { checkDbInvariant } from "../util/invariants.js";
|
import { checkDbInvariant } from "../util/invariants.js";
|
||||||
import { GetReadOnlyAccess } from "../util/query.js";
|
import { GetReadOnlyAccess } from "../util/query.js";
|
||||||
import { spendCoins } from "../wallet.js";
|
import { spendCoins, makeEventId } from "../operations/common.js";
|
||||||
import { updateExchangeFromUrl } from "./exchanges.js";
|
import { updateExchangeFromUrl } from "./exchanges.js";
|
||||||
import { makeEventId } from "./transactions.js";
|
|
||||||
import { internalCreateWithdrawalGroup } from "./withdraw.js";
|
import { internalCreateWithdrawalGroup } from "./withdraw.js";
|
||||||
|
|
||||||
const logger = new Logger("operations/peer-to-peer.ts");
|
const logger = new Logger("operations/peer-to-peer.ts");
|
@ -23,7 +23,6 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
AbortStatus,
|
|
||||||
WalletStoresV1,
|
WalletStoresV1,
|
||||||
BackupProviderStateTag,
|
BackupProviderStateTag,
|
||||||
RefreshCoinStatus,
|
RefreshCoinStatus,
|
||||||
@ -38,7 +37,6 @@ import { AbsoluteTime } from "@gnu-taler/taler-util";
|
|||||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||||
import { GetReadOnlyAccess } from "../util/query.js";
|
import { GetReadOnlyAccess } from "../util/query.js";
|
||||||
import { RetryTags } from "../util/retries.js";
|
import { RetryTags } from "../util/retries.js";
|
||||||
import { Wallet } from "../wallet.js";
|
|
||||||
import { GlobalIDB } from "@gnu-taler/idb-bridge";
|
import { GlobalIDB } from "@gnu-taler/idb-bridge";
|
||||||
|
|
||||||
function getPendingCommon(
|
function getPendingCommon(
|
||||||
@ -184,38 +182,6 @@ async function gatherWithdrawalPending(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gatherProposalPending(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
tx: GetReadOnlyAccess<{
|
|
||||||
proposals: typeof WalletStoresV1.proposals;
|
|
||||||
operationRetries: typeof WalletStoresV1.operationRetries;
|
|
||||||
}>,
|
|
||||||
now: AbsoluteTime,
|
|
||||||
resp: PendingOperationsResponse,
|
|
||||||
): Promise<void> {
|
|
||||||
await tx.proposals.iter().forEachAsync(async (proposal) => {
|
|
||||||
if (proposal.proposalStatus == ProposalStatus.Proposed) {
|
|
||||||
// Nothing to do, user needs to choose.
|
|
||||||
} else if (proposal.proposalStatus == ProposalStatus.Downloading) {
|
|
||||||
const opId = RetryTags.forProposalClaim(proposal);
|
|
||||||
const retryRecord = await tx.operationRetries.get(opId);
|
|
||||||
const timestampDue =
|
|
||||||
retryRecord?.retryInfo?.nextRetry ?? AbsoluteTime.now();
|
|
||||||
resp.pendingOperations.push({
|
|
||||||
type: PendingTaskType.ProposalDownload,
|
|
||||||
...getPendingCommon(ws, opId, timestampDue),
|
|
||||||
givesLifeness: true,
|
|
||||||
merchantBaseUrl: proposal.merchantBaseUrl,
|
|
||||||
orderId: proposal.orderId,
|
|
||||||
proposalId: proposal.proposalId,
|
|
||||||
proposalTimestamp: proposal.timestamp,
|
|
||||||
lastError: retryRecord?.lastError,
|
|
||||||
retryInfo: retryRecord?.retryInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function gatherDepositPending(
|
async function gatherDepositPending(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
tx: GetReadOnlyAccess<{
|
tx: GetReadOnlyAccess<{
|
||||||
@ -287,43 +253,26 @@ async function gatherPurchasePending(
|
|||||||
resp: PendingOperationsResponse,
|
resp: PendingOperationsResponse,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// FIXME: Only iter purchases with some "active" flag!
|
// FIXME: Only iter purchases with some "active" flag!
|
||||||
await tx.purchases.iter().forEachAsync(async (pr) => {
|
const keyRange = GlobalIDB.KeyRange.bound(
|
||||||
if (
|
OperationStatusRange.ACTIVE_START,
|
||||||
pr.paymentSubmitPending &&
|
OperationStatusRange.ACTIVE_END,
|
||||||
pr.abortStatus === AbortStatus.None &&
|
|
||||||
!pr.payFrozen
|
|
||||||
) {
|
|
||||||
const payOpId = RetryTags.forPay(pr);
|
|
||||||
const payRetryRecord = await tx.operationRetries.get(payOpId);
|
|
||||||
|
|
||||||
const timestampDue =
|
|
||||||
payRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
|
|
||||||
resp.pendingOperations.push({
|
|
||||||
type: PendingTaskType.Pay,
|
|
||||||
...getPendingCommon(ws, payOpId, timestampDue),
|
|
||||||
givesLifeness: true,
|
|
||||||
isReplay: false,
|
|
||||||
proposalId: pr.proposalId,
|
|
||||||
retryInfo: payRetryRecord?.retryInfo,
|
|
||||||
lastError: payRetryRecord?.lastError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (pr.refundQueryRequested) {
|
|
||||||
const refundQueryOpId = RetryTags.forRefundQuery(pr);
|
|
||||||
const refundQueryRetryRecord = await tx.operationRetries.get(
|
|
||||||
refundQueryOpId,
|
|
||||||
);
|
);
|
||||||
|
await tx.purchases.indexes.byStatus
|
||||||
|
.iter(keyRange)
|
||||||
|
.forEachAsync(async (pr) => {
|
||||||
|
const opId = RetryTags.forPay(pr);
|
||||||
|
const retryRecord = await tx.operationRetries.get(opId);
|
||||||
const timestampDue =
|
const timestampDue =
|
||||||
refundQueryRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
|
retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
|
||||||
resp.pendingOperations.push({
|
resp.pendingOperations.push({
|
||||||
type: PendingTaskType.RefundQuery,
|
type: PendingTaskType.Purchase,
|
||||||
...getPendingCommon(ws, refundQueryOpId, timestampDue),
|
...getPendingCommon(ws, opId, timestampDue),
|
||||||
givesLifeness: true,
|
givesLifeness: true,
|
||||||
|
statusStr: ProposalStatus[pr.status],
|
||||||
proposalId: pr.proposalId,
|
proposalId: pr.proposalId,
|
||||||
retryInfo: refundQueryRetryRecord?.retryInfo,
|
retryInfo: retryRecord?.retryInfo,
|
||||||
lastError: refundQueryRetryRecord?.lastError,
|
lastError: retryRecord?.lastError,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -404,7 +353,6 @@ export async function getPendingOperations(
|
|||||||
x.refreshGroups,
|
x.refreshGroups,
|
||||||
x.coins,
|
x.coins,
|
||||||
x.withdrawalGroups,
|
x.withdrawalGroups,
|
||||||
x.proposals,
|
|
||||||
x.tips,
|
x.tips,
|
||||||
x.purchases,
|
x.purchases,
|
||||||
x.planchets,
|
x.planchets,
|
||||||
@ -419,7 +367,6 @@ export async function getPendingOperations(
|
|||||||
await gatherExchangePending(ws, tx, now, resp);
|
await gatherExchangePending(ws, tx, now, resp);
|
||||||
await gatherRefreshPending(ws, tx, now, resp);
|
await gatherRefreshPending(ws, tx, now, resp);
|
||||||
await gatherWithdrawalPending(ws, tx, now, resp);
|
await gatherWithdrawalPending(ws, tx, now, resp);
|
||||||
await gatherProposalPending(ws, tx, now, resp);
|
|
||||||
await gatherDepositPending(ws, tx, now, resp);
|
await gatherDepositPending(ws, tx, now, resp);
|
||||||
await gatherTipPending(ws, tx, now, resp);
|
await gatherTipPending(ws, tx, now, resp);
|
||||||
await gatherPurchasePending(ws, tx, now, resp);
|
await gatherPurchasePending(ws, tx, now, resp);
|
||||||
|
@ -27,16 +27,15 @@
|
|||||||
import {
|
import {
|
||||||
Amounts,
|
Amounts,
|
||||||
codecForRecoupConfirmation,
|
codecForRecoupConfirmation,
|
||||||
|
codecForReserveStatus,
|
||||||
encodeCrock,
|
encodeCrock,
|
||||||
getRandomBytes,
|
getRandomBytes,
|
||||||
j2s,
|
j2s,
|
||||||
Logger,
|
Logger,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
RefreshReason,
|
RefreshReason,
|
||||||
TalerErrorDetail,
|
|
||||||
TalerProtocolTimestamp,
|
TalerProtocolTimestamp,
|
||||||
URL,
|
URL,
|
||||||
codecForReserveStatus,
|
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import {
|
import {
|
||||||
CoinRecord,
|
CoinRecord,
|
||||||
@ -44,8 +43,8 @@ import {
|
|||||||
CoinStatus,
|
CoinStatus,
|
||||||
RecoupGroupRecord,
|
RecoupGroupRecord,
|
||||||
RefreshCoinSource,
|
RefreshCoinSource,
|
||||||
WithdrawalGroupStatus,
|
|
||||||
WalletStoresV1,
|
WalletStoresV1,
|
||||||
|
WithdrawalGroupStatus,
|
||||||
WithdrawalRecordType,
|
WithdrawalRecordType,
|
||||||
WithdrawCoinSource,
|
WithdrawCoinSource,
|
||||||
} from "../db.js";
|
} from "../db.js";
|
||||||
@ -54,10 +53,8 @@ import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
|||||||
import { GetReadWriteAccess } from "../util/query.js";
|
import { GetReadWriteAccess } from "../util/query.js";
|
||||||
import {
|
import {
|
||||||
OperationAttemptResult,
|
OperationAttemptResult,
|
||||||
RetryInfo,
|
|
||||||
runOperationHandlerForResult,
|
runOperationHandlerForResult,
|
||||||
} from "../util/retries.js";
|
} from "../util/retries.js";
|
||||||
import { guardOperationException } from "./common.js";
|
|
||||||
import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
|
import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
|
||||||
import { internalCreateWithdrawalGroup } from "./withdraw.js";
|
import { internalCreateWithdrawalGroup } from "./withdraw.js";
|
||||||
|
|
||||||
|
@ -78,7 +78,7 @@ import {
|
|||||||
OperationAttemptResult,
|
OperationAttemptResult,
|
||||||
OperationAttemptResultType,
|
OperationAttemptResultType,
|
||||||
} from "../util/retries.js";
|
} from "../util/retries.js";
|
||||||
import { makeCoinAvailable } from "../wallet.js";
|
import { makeCoinAvailable } from "./common.js";
|
||||||
import { updateExchangeFromUrl } from "./exchanges.js";
|
import { updateExchangeFromUrl } from "./exchanges.js";
|
||||||
import {
|
import {
|
||||||
isWithdrawableDenom,
|
isWithdrawableDenom,
|
||||||
|
@ -1,815 +0,0 @@
|
|||||||
/*
|
|
||||||
This file is part of GNU Taler
|
|
||||||
(C) 2019-2019 Taler Systems S.A.
|
|
||||||
|
|
||||||
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
|
|
||||||
Foundation; either version 3, or (at your option) any later version.
|
|
||||||
|
|
||||||
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
||||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
||||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along with
|
|
||||||
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Implementation of the refund operation.
|
|
||||||
*
|
|
||||||
* @author Florian Dold
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
AbortingCoin,
|
|
||||||
AbortRequest,
|
|
||||||
AbsoluteTime,
|
|
||||||
AmountJson,
|
|
||||||
Amounts,
|
|
||||||
ApplyRefundResponse,
|
|
||||||
codecForAbortResponse,
|
|
||||||
codecForMerchantOrderRefundPickupResponse,
|
|
||||||
codecForMerchantOrderStatusPaid,
|
|
||||||
CoinPublicKey,
|
|
||||||
Duration,
|
|
||||||
Logger,
|
|
||||||
MerchantCoinRefundFailureStatus,
|
|
||||||
MerchantCoinRefundStatus,
|
|
||||||
MerchantCoinRefundSuccessStatus,
|
|
||||||
NotificationType,
|
|
||||||
parseRefundUri,
|
|
||||||
PrepareRefundResult,
|
|
||||||
RefreshReason,
|
|
||||||
TalerErrorCode,
|
|
||||||
TalerProtocolTimestamp,
|
|
||||||
TransactionType,
|
|
||||||
URL,
|
|
||||||
} from "@gnu-taler/taler-util";
|
|
||||||
import {
|
|
||||||
AbortStatus,
|
|
||||||
CoinStatus,
|
|
||||||
DenominationRecord,
|
|
||||||
PurchaseRecord,
|
|
||||||
RefundReason,
|
|
||||||
RefundState,
|
|
||||||
WalletStoresV1,
|
|
||||||
} from "../db.js";
|
|
||||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
|
||||||
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
|
||||||
import { checkDbInvariant } from "../util/invariants.js";
|
|
||||||
import { GetReadWriteAccess } from "../util/query.js";
|
|
||||||
import { OperationAttemptResult } from "../util/retries.js";
|
|
||||||
import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
|
|
||||||
import { makeEventId } from "./transactions.js";
|
|
||||||
|
|
||||||
const logger = new Logger("refund.ts");
|
|
||||||
|
|
||||||
export async function prepareRefund(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
talerRefundUri: string,
|
|
||||||
): Promise<PrepareRefundResult> {
|
|
||||||
const parseResult = parseRefundUri(talerRefundUri);
|
|
||||||
|
|
||||||
logger.trace("preparing refund offer", parseResult);
|
|
||||||
|
|
||||||
if (!parseResult) {
|
|
||||||
throw Error("invalid refund URI");
|
|
||||||
}
|
|
||||||
|
|
||||||
const purchase = await ws.db
|
|
||||||
.mktx((x) => [x.purchases])
|
|
||||||
.runReadOnly(async (tx) => {
|
|
||||||
return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
|
|
||||||
parseResult.merchantBaseUrl,
|
|
||||||
parseResult.orderId,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!purchase) {
|
|
||||||
throw Error(
|
|
||||||
`no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const awaiting = await queryAndSaveAwaitingRefund(ws, purchase);
|
|
||||||
const summary = calculateRefundSummary(purchase);
|
|
||||||
const proposalId = purchase.proposalId;
|
|
||||||
|
|
||||||
const { contractData: c } = purchase.download;
|
|
||||||
|
|
||||||
return {
|
|
||||||
proposalId,
|
|
||||||
effectivePaid: Amounts.stringify(summary.amountEffectivePaid),
|
|
||||||
gone: Amounts.stringify(summary.amountRefundGone),
|
|
||||||
granted: Amounts.stringify(summary.amountRefundGranted),
|
|
||||||
pending: summary.pendingAtExchange,
|
|
||||||
awaiting: Amounts.stringify(awaiting),
|
|
||||||
info: {
|
|
||||||
contractTermsHash: c.contractTermsHash,
|
|
||||||
merchant: c.merchant,
|
|
||||||
orderId: c.orderId,
|
|
||||||
products: c.products,
|
|
||||||
summary: c.summary,
|
|
||||||
fulfillmentMessage: c.fulfillmentMessage,
|
|
||||||
summary_i18n: c.summaryI18n,
|
|
||||||
fulfillmentMessage_i18n: c.fulfillmentMessageI18n,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRefundKey(d: MerchantCoinRefundStatus): string {
|
|
||||||
return `${d.coin_pub}-${d.rtransaction_id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applySuccessfulRefund(
|
|
||||||
tx: GetReadWriteAccess<{
|
|
||||||
coins: typeof WalletStoresV1.coins;
|
|
||||||
denominations: typeof WalletStoresV1.denominations;
|
|
||||||
}>,
|
|
||||||
p: PurchaseRecord,
|
|
||||||
refreshCoinsMap: Record<string, { coinPub: string }>,
|
|
||||||
r: MerchantCoinRefundSuccessStatus,
|
|
||||||
): Promise<void> {
|
|
||||||
// FIXME: check signature before storing it as valid!
|
|
||||||
|
|
||||||
const refundKey = getRefundKey(r);
|
|
||||||
const coin = await tx.coins.get(r.coin_pub);
|
|
||||||
if (!coin) {
|
|
||||||
logger.warn("coin not found, can't apply refund");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const denom = await tx.denominations.get([
|
|
||||||
coin.exchangeBaseUrl,
|
|
||||||
coin.denomPubHash,
|
|
||||||
]);
|
|
||||||
if (!denom) {
|
|
||||||
throw Error("inconsistent database");
|
|
||||||
}
|
|
||||||
refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
|
|
||||||
const refundAmount = Amounts.parseOrThrow(r.refund_amount);
|
|
||||||
const refundFee = denom.fees.feeRefund;
|
|
||||||
coin.status = CoinStatus.Dormant;
|
|
||||||
coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
|
|
||||||
coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
|
|
||||||
logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
|
|
||||||
await tx.coins.put(coin);
|
|
||||||
|
|
||||||
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
|
|
||||||
.iter(coin.exchangeBaseUrl)
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
const amountLeft = Amounts.sub(
|
|
||||||
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
|
|
||||||
.amount,
|
|
||||||
denom.fees.feeRefund,
|
|
||||||
).amount;
|
|
||||||
|
|
||||||
const totalRefreshCostBound = getTotalRefreshCost(
|
|
||||||
allDenoms,
|
|
||||||
DenominationRecord.toDenomInfo(denom),
|
|
||||||
amountLeft,
|
|
||||||
);
|
|
||||||
|
|
||||||
p.refunds[refundKey] = {
|
|
||||||
type: RefundState.Applied,
|
|
||||||
obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
|
|
||||||
executionTime: r.execution_time,
|
|
||||||
refundAmount: Amounts.parseOrThrow(r.refund_amount),
|
|
||||||
refundFee: denom.fees.feeRefund,
|
|
||||||
totalRefreshCostBound,
|
|
||||||
coinPub: r.coin_pub,
|
|
||||||
rtransactionId: r.rtransaction_id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storePendingRefund(
|
|
||||||
tx: GetReadWriteAccess<{
|
|
||||||
denominations: typeof WalletStoresV1.denominations;
|
|
||||||
coins: typeof WalletStoresV1.coins;
|
|
||||||
}>,
|
|
||||||
p: PurchaseRecord,
|
|
||||||
r: MerchantCoinRefundFailureStatus,
|
|
||||||
): Promise<void> {
|
|
||||||
const refundKey = getRefundKey(r);
|
|
||||||
|
|
||||||
const coin = await tx.coins.get(r.coin_pub);
|
|
||||||
if (!coin) {
|
|
||||||
logger.warn("coin not found, can't apply refund");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const denom = await tx.denominations.get([
|
|
||||||
coin.exchangeBaseUrl,
|
|
||||||
coin.denomPubHash,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!denom) {
|
|
||||||
throw Error("inconsistent database");
|
|
||||||
}
|
|
||||||
|
|
||||||
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
|
|
||||||
.iter(coin.exchangeBaseUrl)
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
const amountLeft = Amounts.sub(
|
|
||||||
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
|
|
||||||
.amount,
|
|
||||||
denom.fees.feeRefund,
|
|
||||||
).amount;
|
|
||||||
|
|
||||||
const totalRefreshCostBound = getTotalRefreshCost(
|
|
||||||
allDenoms,
|
|
||||||
DenominationRecord.toDenomInfo(denom),
|
|
||||||
amountLeft,
|
|
||||||
);
|
|
||||||
|
|
||||||
p.refunds[refundKey] = {
|
|
||||||
type: RefundState.Pending,
|
|
||||||
obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
|
|
||||||
executionTime: r.execution_time,
|
|
||||||
refundAmount: Amounts.parseOrThrow(r.refund_amount),
|
|
||||||
refundFee: denom.fees.feeRefund,
|
|
||||||
totalRefreshCostBound,
|
|
||||||
coinPub: r.coin_pub,
|
|
||||||
rtransactionId: r.rtransaction_id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storeFailedRefund(
|
|
||||||
tx: GetReadWriteAccess<{
|
|
||||||
coins: typeof WalletStoresV1.coins;
|
|
||||||
denominations: typeof WalletStoresV1.denominations;
|
|
||||||
}>,
|
|
||||||
p: PurchaseRecord,
|
|
||||||
refreshCoinsMap: Record<string, { coinPub: string }>,
|
|
||||||
r: MerchantCoinRefundFailureStatus,
|
|
||||||
): Promise<void> {
|
|
||||||
const refundKey = getRefundKey(r);
|
|
||||||
|
|
||||||
const coin = await tx.coins.get(r.coin_pub);
|
|
||||||
if (!coin) {
|
|
||||||
logger.warn("coin not found, can't apply refund");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const denom = await tx.denominations.get([
|
|
||||||
coin.exchangeBaseUrl,
|
|
||||||
coin.denomPubHash,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!denom) {
|
|
||||||
throw Error("inconsistent database");
|
|
||||||
}
|
|
||||||
|
|
||||||
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
|
|
||||||
.iter(coin.exchangeBaseUrl)
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
const amountLeft = Amounts.sub(
|
|
||||||
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
|
|
||||||
.amount,
|
|
||||||
denom.fees.feeRefund,
|
|
||||||
).amount;
|
|
||||||
|
|
||||||
const totalRefreshCostBound = getTotalRefreshCost(
|
|
||||||
allDenoms,
|
|
||||||
DenominationRecord.toDenomInfo(denom),
|
|
||||||
amountLeft,
|
|
||||||
);
|
|
||||||
|
|
||||||
p.refunds[refundKey] = {
|
|
||||||
type: RefundState.Failed,
|
|
||||||
obtainedTime: TalerProtocolTimestamp.now(),
|
|
||||||
executionTime: r.execution_time,
|
|
||||||
refundAmount: Amounts.parseOrThrow(r.refund_amount),
|
|
||||||
refundFee: denom.fees.feeRefund,
|
|
||||||
totalRefreshCostBound,
|
|
||||||
coinPub: r.coin_pub,
|
|
||||||
rtransactionId: r.rtransaction_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (p.abortStatus === AbortStatus.AbortRefund) {
|
|
||||||
// Refund failed because the merchant didn't even try to deposit
|
|
||||||
// the coin yet, so we try to refresh.
|
|
||||||
if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
|
|
||||||
const coin = await tx.coins.get(r.coin_pub);
|
|
||||||
if (!coin) {
|
|
||||||
logger.warn("coin not found, can't apply refund");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const denom = await tx.denominations.get([
|
|
||||||
coin.exchangeBaseUrl,
|
|
||||||
coin.denomPubHash,
|
|
||||||
]);
|
|
||||||
if (!denom) {
|
|
||||||
logger.warn("denomination for coin missing");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let contrib: AmountJson | undefined;
|
|
||||||
for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) {
|
|
||||||
if (p.payCoinSelection.coinPubs[i] === r.coin_pub) {
|
|
||||||
contrib = p.payCoinSelection.coinContributions[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (contrib) {
|
|
||||||
coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
|
|
||||||
coin.currentAmount = Amounts.sub(
|
|
||||||
coin.currentAmount,
|
|
||||||
denom.fees.feeRefund,
|
|
||||||
).amount;
|
|
||||||
}
|
|
||||||
refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
|
|
||||||
await tx.coins.put(coin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acceptRefunds(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
refunds: MerchantCoinRefundStatus[],
|
|
||||||
reason: RefundReason,
|
|
||||||
): Promise<void> {
|
|
||||||
logger.trace("handling refunds", refunds);
|
|
||||||
const now = TalerProtocolTimestamp.now();
|
|
||||||
|
|
||||||
await ws.db
|
|
||||||
.mktx((x) => [
|
|
||||||
x.purchases,
|
|
||||||
x.coins,
|
|
||||||
x.coinAvailability,
|
|
||||||
x.denominations,
|
|
||||||
x.refreshGroups,
|
|
||||||
])
|
|
||||||
.runReadWrite(async (tx) => {
|
|
||||||
const p = await tx.purchases.get(proposalId);
|
|
||||||
if (!p) {
|
|
||||||
logger.error("purchase not found, not adding refunds");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshCoinsMap: Record<string, CoinPublicKey> = {};
|
|
||||||
|
|
||||||
for (const refundStatus of refunds) {
|
|
||||||
const refundKey = getRefundKey(refundStatus);
|
|
||||||
const existingRefundInfo = p.refunds[refundKey];
|
|
||||||
|
|
||||||
const isPermanentFailure =
|
|
||||||
refundStatus.type === "failure" &&
|
|
||||||
refundStatus.exchange_status >= 400 &&
|
|
||||||
refundStatus.exchange_status < 500;
|
|
||||||
|
|
||||||
// Already failed.
|
|
||||||
if (existingRefundInfo?.type === RefundState.Failed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already applied.
|
|
||||||
if (existingRefundInfo?.type === RefundState.Applied) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still pending.
|
|
||||||
if (
|
|
||||||
refundStatus.type === "failure" &&
|
|
||||||
!isPermanentFailure &&
|
|
||||||
existingRefundInfo?.type === RefundState.Pending
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
|
|
||||||
|
|
||||||
if (refundStatus.type === "success") {
|
|
||||||
await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
|
|
||||||
} else if (isPermanentFailure) {
|
|
||||||
await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
|
|
||||||
} else {
|
|
||||||
await storePendingRefund(tx, p, refundStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshCoinsPubs = Object.values(refreshCoinsMap);
|
|
||||||
if (refreshCoinsPubs.length > 0) {
|
|
||||||
await createRefreshGroup(
|
|
||||||
ws,
|
|
||||||
tx,
|
|
||||||
refreshCoinsPubs,
|
|
||||||
RefreshReason.Refund,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Are we done with querying yet, or do we need to do another round
|
|
||||||
// after a retry delay?
|
|
||||||
let queryDone = true;
|
|
||||||
|
|
||||||
if (
|
|
||||||
p.timestampFirstSuccessfulPay &&
|
|
||||||
p.autoRefundDeadline &&
|
|
||||||
AbsoluteTime.cmp(
|
|
||||||
AbsoluteTime.fromTimestamp(p.autoRefundDeadline),
|
|
||||||
AbsoluteTime.fromTimestamp(now),
|
|
||||||
) > 0
|
|
||||||
) {
|
|
||||||
queryDone = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let numPendingRefunds = 0;
|
|
||||||
for (const ri of Object.values(p.refunds)) {
|
|
||||||
switch (ri.type) {
|
|
||||||
case RefundState.Pending:
|
|
||||||
numPendingRefunds++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numPendingRefunds > 0) {
|
|
||||||
queryDone = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryDone) {
|
|
||||||
p.timestampLastRefundStatus = now;
|
|
||||||
p.refundQueryRequested = false;
|
|
||||||
if (p.abortStatus === AbortStatus.AbortRefund) {
|
|
||||||
p.abortStatus = AbortStatus.AbortFinished;
|
|
||||||
}
|
|
||||||
logger.trace("refund query done");
|
|
||||||
} else {
|
|
||||||
// No error, but we need to try again!
|
|
||||||
p.timestampLastRefundStatus = now;
|
|
||||||
logger.trace("refund query not done");
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.purchases.put(p);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.notify({
|
|
||||||
type: NotificationType.RefundQueried,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateRefundSummary(p: PurchaseRecord): RefundSummary {
|
|
||||||
let amountRefundGranted = Amounts.getZero(
|
|
||||||
p.download.contractData.amount.currency,
|
|
||||||
);
|
|
||||||
let amountRefundGone = Amounts.getZero(
|
|
||||||
p.download.contractData.amount.currency,
|
|
||||||
);
|
|
||||||
|
|
||||||
let pendingAtExchange = false;
|
|
||||||
|
|
||||||
Object.keys(p.refunds).forEach((rk) => {
|
|
||||||
const refund = p.refunds[rk];
|
|
||||||
if (refund.type === RefundState.Pending) {
|
|
||||||
pendingAtExchange = true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
refund.type === RefundState.Applied ||
|
|
||||||
refund.type === RefundState.Pending
|
|
||||||
) {
|
|
||||||
amountRefundGranted = Amounts.add(
|
|
||||||
amountRefundGranted,
|
|
||||||
Amounts.sub(
|
|
||||||
refund.refundAmount,
|
|
||||||
refund.refundFee,
|
|
||||||
refund.totalRefreshCostBound,
|
|
||||||
).amount,
|
|
||||||
).amount;
|
|
||||||
} else {
|
|
||||||
amountRefundGone = Amounts.add(
|
|
||||||
amountRefundGone,
|
|
||||||
refund.refundAmount,
|
|
||||||
).amount;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
amountEffectivePaid: p.totalPayCost,
|
|
||||||
amountRefundGone,
|
|
||||||
amountRefundGranted,
|
|
||||||
pendingAtExchange,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Summary of the refund status of a purchase.
|
|
||||||
*/
|
|
||||||
export interface RefundSummary {
|
|
||||||
pendingAtExchange: boolean;
|
|
||||||
amountEffectivePaid: AmountJson;
|
|
||||||
amountRefundGranted: AmountJson;
|
|
||||||
amountRefundGone: AmountJson;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accept a refund, return the contract hash for the contract
|
|
||||||
* that was involved in the refund.
|
|
||||||
*/
|
|
||||||
export async function applyRefund(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
talerRefundUri: string,
|
|
||||||
): Promise<ApplyRefundResponse> {
|
|
||||||
const parseResult = parseRefundUri(talerRefundUri);
|
|
||||||
|
|
||||||
logger.trace("applying refund", parseResult);
|
|
||||||
|
|
||||||
if (!parseResult) {
|
|
||||||
throw Error("invalid refund URI");
|
|
||||||
}
|
|
||||||
|
|
||||||
const purchase = await ws.db
|
|
||||||
.mktx((x) => [x.purchases])
|
|
||||||
.runReadOnly(async (tx) => {
|
|
||||||
return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
|
|
||||||
parseResult.merchantBaseUrl,
|
|
||||||
parseResult.orderId,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!purchase) {
|
|
||||||
throw Error(
|
|
||||||
`no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return applyRefundFromPurchaseId(ws, purchase.proposalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyRefundFromPurchaseId(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
): Promise<ApplyRefundResponse> {
|
|
||||||
logger.trace("applying refund for purchase", proposalId);
|
|
||||||
|
|
||||||
logger.info("processing purchase for refund");
|
|
||||||
const success = await ws.db
|
|
||||||
.mktx((x) => [x.purchases])
|
|
||||||
.runReadWrite(async (tx) => {
|
|
||||||
const p = await tx.purchases.get(proposalId);
|
|
||||||
if (!p) {
|
|
||||||
logger.error("no purchase found for refund URL");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
p.refundQueryRequested = true;
|
|
||||||
await tx.purchases.put(p);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
ws.notify({
|
|
||||||
type: NotificationType.RefundStarted,
|
|
||||||
});
|
|
||||||
await processPurchaseQueryRefund(ws, proposalId, {
|
|
||||||
forceNow: true,
|
|
||||||
waitForAutoRefund: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const purchase = await ws.db
|
|
||||||
.mktx((x) => [x.purchases])
|
|
||||||
.runReadOnly(async (tx) => {
|
|
||||||
return tx.purchases.get(proposalId);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!purchase) {
|
|
||||||
throw Error("purchase no longer exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = calculateRefundSummary(purchase);
|
|
||||||
|
|
||||||
return {
|
|
||||||
contractTermsHash: purchase.download.contractData.contractTermsHash,
|
|
||||||
proposalId: purchase.proposalId,
|
|
||||||
transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: can we have the tx id of the refund
|
|
||||||
amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid),
|
|
||||||
amountRefundGone: Amounts.stringify(summary.amountRefundGone),
|
|
||||||
amountRefundGranted: Amounts.stringify(summary.amountRefundGranted),
|
|
||||||
pendingAtExchange: summary.pendingAtExchange,
|
|
||||||
info: {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queryAndSaveAwaitingRefund(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
purchase: PurchaseRecord,
|
|
||||||
waitForAutoRefund?: boolean,
|
|
||||||
): Promise<AmountJson> {
|
|
||||||
const requestUrl = new URL(
|
|
||||||
`orders/${purchase.download.contractData.orderId}`,
|
|
||||||
purchase.download.contractData.merchantBaseUrl,
|
|
||||||
);
|
|
||||||
requestUrl.searchParams.set(
|
|
||||||
"h_contract",
|
|
||||||
purchase.download.contractData.contractTermsHash,
|
|
||||||
);
|
|
||||||
// Long-poll for one second
|
|
||||||
if (waitForAutoRefund) {
|
|
||||||
requestUrl.searchParams.set("timeout_ms", "1000");
|
|
||||||
requestUrl.searchParams.set("await_refund_obtained", "yes");
|
|
||||||
logger.trace("making long-polling request for auto-refund");
|
|
||||||
}
|
|
||||||
const resp = await ws.http.get(requestUrl.href);
|
|
||||||
const orderStatus = await readSuccessResponseJsonOrThrow(
|
|
||||||
resp,
|
|
||||||
codecForMerchantOrderStatusPaid(),
|
|
||||||
);
|
|
||||||
if (!orderStatus.refunded) {
|
|
||||||
// Wait for retry ...
|
|
||||||
return Amounts.getZero(purchase.totalPayCost.currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
const refundAwaiting = Amounts.sub(
|
|
||||||
Amounts.parseOrThrow(orderStatus.refund_amount),
|
|
||||||
Amounts.parseOrThrow(orderStatus.refund_taken),
|
|
||||||
).amount;
|
|
||||||
|
|
||||||
if (
|
|
||||||
purchase.refundAwaiting === undefined ||
|
|
||||||
Amounts.cmp(refundAwaiting, purchase.refundAwaiting) !== 0
|
|
||||||
) {
|
|
||||||
await ws.db
|
|
||||||
.mktx((x) => [x.purchases])
|
|
||||||
.runReadWrite(async (tx) => {
|
|
||||||
const p = await tx.purchases.get(purchase.proposalId);
|
|
||||||
if (!p) {
|
|
||||||
logger.warn("purchase does not exist anymore");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
p.refundAwaiting = refundAwaiting;
|
|
||||||
await tx.purchases.put(p);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return refundAwaiting;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processPurchaseQueryRefund(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
options: {
|
|
||||||
forceNow?: boolean;
|
|
||||||
waitForAutoRefund?: boolean;
|
|
||||||
} = {},
|
|
||||||
): Promise<OperationAttemptResult> {
|
|
||||||
const waitForAutoRefund = options.waitForAutoRefund ?? false;
|
|
||||||
const purchase = await ws.db
|
|
||||||
.mktx((x) => [x.purchases])
|
|
||||||
.runReadOnly(async (tx) => {
|
|
||||||
return tx.purchases.get(proposalId);
|
|
||||||
});
|
|
||||||
if (!purchase) {
|
|
||||||
return OperationAttemptResult.finishedEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!purchase.refundQueryRequested) {
|
|
||||||
return OperationAttemptResult.finishedEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (purchase.timestampFirstSuccessfulPay) {
|
|
||||||
if (
|
|
||||||
!purchase.autoRefundDeadline ||
|
|
||||||
!AbsoluteTime.isExpired(
|
|
||||||
AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
const awaitingAmount = await queryAndSaveAwaitingRefund(
|
|
||||||
ws,
|
|
||||||
purchase,
|
|
||||||
waitForAutoRefund,
|
|
||||||
);
|
|
||||||
if (Amounts.isZero(awaitingAmount)) {
|
|
||||||
return OperationAttemptResult.finishedEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestUrl = new URL(
|
|
||||||
`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.download.contractData.contractTermsHash,
|
|
||||||
});
|
|
||||||
|
|
||||||
const refundResponse = await readSuccessResponseJsonOrThrow(
|
|
||||||
request,
|
|
||||||
codecForMerchantOrderRefundPickupResponse(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await acceptRefunds(
|
|
||||||
ws,
|
|
||||||
proposalId,
|
|
||||||
refundResponse.refunds,
|
|
||||||
RefundReason.NormalRefund,
|
|
||||||
);
|
|
||||||
} else if (purchase.abortStatus === AbortStatus.AbortRefund) {
|
|
||||||
const requestUrl = new URL(
|
|
||||||
`orders/${purchase.download.contractData.orderId}/abort`,
|
|
||||||
purchase.download.contractData.merchantBaseUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
const abortingCoins: AbortingCoin[] = [];
|
|
||||||
|
|
||||||
await ws.db
|
|
||||||
.mktx((x) => [x.coins])
|
|
||||||
.runReadOnly(async (tx) => {
|
|
||||||
for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) {
|
|
||||||
const coinPub = purchase.payCoinSelection.coinPubs[i];
|
|
||||||
const coin = await tx.coins.get(coinPub);
|
|
||||||
checkDbInvariant(!!coin, "expected coin to be present");
|
|
||||||
abortingCoins.push({
|
|
||||||
coin_pub: coinPub,
|
|
||||||
contribution: Amounts.stringify(
|
|
||||||
purchase.payCoinSelection.coinContributions[i],
|
|
||||||
),
|
|
||||||
exchange_url: coin.exchangeBaseUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const abortReq: AbortRequest = {
|
|
||||||
h_contract: purchase.download.contractData.contractTermsHash,
|
|
||||||
coins: abortingCoins,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.trace(`making order abort request to ${requestUrl.href}`);
|
|
||||||
|
|
||||||
const request = await ws.http.postJson(requestUrl.href, abortReq);
|
|
||||||
const abortResp = await readSuccessResponseJsonOrThrow(
|
|
||||||
request,
|
|
||||||
codecForAbortResponse(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const refunds: MerchantCoinRefundStatus[] = [];
|
|
||||||
|
|
||||||
if (abortResp.refunds.length != abortingCoins.length) {
|
|
||||||
// FIXME: define error code!
|
|
||||||
throw Error("invalid order abort response");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < abortResp.refunds.length; i++) {
|
|
||||||
const r = abortResp.refunds[i];
|
|
||||||
refunds.push({
|
|
||||||
...r,
|
|
||||||
coin_pub: purchase.payCoinSelection.coinPubs[i],
|
|
||||||
refund_amount: Amounts.stringify(
|
|
||||||
purchase.payCoinSelection.coinContributions[i],
|
|
||||||
),
|
|
||||||
rtransaction_id: 0,
|
|
||||||
execution_time: AbsoluteTime.toTimestamp(
|
|
||||||
AbsoluteTime.addDuration(
|
|
||||||
AbsoluteTime.fromTimestamp(
|
|
||||||
purchase.download.contractData.timestamp,
|
|
||||||
),
|
|
||||||
Duration.fromSpec({ seconds: 1 }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
|
|
||||||
}
|
|
||||||
return OperationAttemptResult.finishedEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function abortFailedPayWithRefund(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await ws.db
|
|
||||||
.mktx((x) => [x.purchases])
|
|
||||||
.runReadWrite(async (tx) => {
|
|
||||||
const purchase = await tx.purchases.get(proposalId);
|
|
||||||
if (!purchase) {
|
|
||||||
throw Error("purchase not found");
|
|
||||||
}
|
|
||||||
if (purchase.timestampFirstSuccessfulPay) {
|
|
||||||
// No point in aborting it. We don't even report an error.
|
|
||||||
logger.warn(`tried to abort successful payment`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (purchase.abortStatus !== AbortStatus.None) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
purchase.refundQueryRequested = true;
|
|
||||||
purchase.paymentSubmitPending = false;
|
|
||||||
purchase.abortStatus = AbortStatus.AbortRefund;
|
|
||||||
await tx.purchases.put(purchase);
|
|
||||||
});
|
|
||||||
processPurchaseQueryRefund(ws, proposalId, {
|
|
||||||
forceNow: true,
|
|
||||||
}).catch((e) => {
|
|
||||||
logger.trace(`error during refund processing after abort pay: ${e}`);
|
|
||||||
});
|
|
||||||
}
|
|
@ -40,9 +40,8 @@ import {
|
|||||||
PreparePayResultType,
|
PreparePayResultType,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||||
import { confirmPay, preparePayForUri } from "./pay.js";
|
import { applyRefund, confirmPay, preparePayForUri } from "./pay-merchant.js";
|
||||||
import { getBalances } from "./balance.js";
|
import { getBalances } from "./balance.js";
|
||||||
import { applyRefund } from "./refund.js";
|
|
||||||
import { checkLogicInvariant } from "../util/invariants.js";
|
import { checkLogicInvariant } from "../util/invariants.js";
|
||||||
import { acceptWithdrawalFromUri } from "./withdraw.js";
|
import { acceptWithdrawalFromUri } from "./withdraw.js";
|
||||||
|
|
||||||
@ -471,6 +470,6 @@ export async function testPay(
|
|||||||
});
|
});
|
||||||
checkLogicInvariant(!!purchase);
|
checkLogicInvariant(!!purchase);
|
||||||
return {
|
return {
|
||||||
payCoinSelection: purchase.payCoinSelection,
|
payCoinSelection: purchase.payInfo?.payCoinSelection!,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -18,8 +18,8 @@
|
|||||||
* Imports.
|
* Imports.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
AgeRestriction,
|
|
||||||
AcceptTipResponse,
|
AcceptTipResponse,
|
||||||
|
AgeRestriction,
|
||||||
Amounts,
|
Amounts,
|
||||||
BlindedDenominationSignature,
|
BlindedDenominationSignature,
|
||||||
codecForMerchantTipResponseV2,
|
codecForMerchantTipResponseV2,
|
||||||
@ -56,9 +56,8 @@ import {
|
|||||||
OperationAttemptResult,
|
OperationAttemptResult,
|
||||||
OperationAttemptResultType,
|
OperationAttemptResultType,
|
||||||
} from "../util/retries.js";
|
} from "../util/retries.js";
|
||||||
import { makeCoinAvailable } from "../wallet.js";
|
import { makeCoinAvailable, makeEventId } from "./common.js";
|
||||||
import { updateExchangeFromUrl } from "./exchanges.js";
|
import { updateExchangeFromUrl } from "./exchanges.js";
|
||||||
import { makeEventId } from "./transactions.js";
|
|
||||||
import {
|
import {
|
||||||
getCandidateWithdrawalDenoms,
|
getCandidateWithdrawalDenoms,
|
||||||
getExchangeWithdrawalInfo,
|
getExchangeWithdrawalInfo,
|
||||||
|
@ -36,12 +36,12 @@ import {
|
|||||||
WithdrawalType,
|
WithdrawalType,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import {
|
import {
|
||||||
AbortStatus,
|
|
||||||
DepositGroupRecord,
|
DepositGroupRecord,
|
||||||
ExchangeDetailsRecord,
|
ExchangeDetailsRecord,
|
||||||
OperationRetryRecord,
|
OperationRetryRecord,
|
||||||
PeerPullPaymentIncomingRecord,
|
PeerPullPaymentIncomingRecord,
|
||||||
PeerPushPaymentInitiationRecord,
|
PeerPushPaymentInitiationRecord,
|
||||||
|
ProposalStatus,
|
||||||
PurchaseRecord,
|
PurchaseRecord,
|
||||||
RefundState,
|
RefundState,
|
||||||
TipRecord,
|
TipRecord,
|
||||||
@ -50,10 +50,12 @@ import {
|
|||||||
WithdrawalRecordType,
|
WithdrawalRecordType,
|
||||||
} from "../db.js";
|
} from "../db.js";
|
||||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||||
|
import { checkDbInvariant } from "../util/invariants.js";
|
||||||
import { RetryTags } from "../util/retries.js";
|
import { RetryTags } from "../util/retries.js";
|
||||||
|
import { makeEventId, TombstoneTag } from "./common.js";
|
||||||
import { processDepositGroup } from "./deposits.js";
|
import { processDepositGroup } from "./deposits.js";
|
||||||
import { getExchangeDetails } from "./exchanges.js";
|
import { getExchangeDetails } from "./exchanges.js";
|
||||||
import { processPurchasePay } from "./pay.js";
|
import { expectProposalDownload, processPurchasePay } from "./pay-merchant.js";
|
||||||
import { processRefreshGroup } from "./refresh.js";
|
import { processRefreshGroup } from "./refresh.js";
|
||||||
import { processTip } from "./tip.js";
|
import { processTip } from "./tip.js";
|
||||||
import {
|
import {
|
||||||
@ -63,28 +65,6 @@ import {
|
|||||||
|
|
||||||
const logger = new Logger("taler-wallet-core:transactions.ts");
|
const logger = new Logger("taler-wallet-core:transactions.ts");
|
||||||
|
|
||||||
export enum TombstoneTag {
|
|
||||||
DeleteWithdrawalGroup = "delete-withdrawal-group",
|
|
||||||
DeleteReserve = "delete-reserve",
|
|
||||||
DeletePayment = "delete-payment",
|
|
||||||
DeleteTip = "delete-tip",
|
|
||||||
DeleteRefreshGroup = "delete-refresh-group",
|
|
||||||
DeleteDepositGroup = "delete-deposit-group",
|
|
||||||
DeleteRefund = "delete-refund",
|
|
||||||
DeletePeerPullDebit = "delete-peer-pull-debit",
|
|
||||||
DeletePeerPushDebit = "delete-peer-push-debit",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an event ID from the type and the primary key for the event.
|
|
||||||
*/
|
|
||||||
export function makeEventId(
|
|
||||||
type: TransactionType | TombstoneTag,
|
|
||||||
...args: string[]
|
|
||||||
): string {
|
|
||||||
return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldSkipCurrency(
|
function shouldSkipCurrency(
|
||||||
transactionsRequest: TransactionsRequest | undefined,
|
transactionsRequest: TransactionsRequest | undefined,
|
||||||
currency: string,
|
currency: string,
|
||||||
@ -219,29 +199,22 @@ export async function getTransactionById(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const download = await expectProposalDownload(purchase);
|
||||||
|
|
||||||
const cleanRefunds = filteredRefunds.filter(
|
const cleanRefunds = filteredRefunds.filter(
|
||||||
(x): x is WalletRefundItem => !!x,
|
(x): x is WalletRefundItem => !!x,
|
||||||
);
|
);
|
||||||
|
|
||||||
const contractData = purchase.download.contractData;
|
const contractData = download.contractData;
|
||||||
const refunds = mergeRefundByExecutionTime(
|
const refunds = mergeRefundByExecutionTime(
|
||||||
cleanRefunds,
|
cleanRefunds,
|
||||||
Amounts.getZero(contractData.amount.currency),
|
Amounts.getZero(contractData.amount.currency),
|
||||||
);
|
);
|
||||||
|
|
||||||
const payOpId = RetryTags.forPay(purchase);
|
const payOpId = RetryTags.forPay(purchase);
|
||||||
const refundQueryOpId = RetryTags.forRefundQuery(purchase);
|
|
||||||
const payRetryRecord = await tx.operationRetries.get(payOpId);
|
const payRetryRecord = await tx.operationRetries.get(payOpId);
|
||||||
const refundQueryRetryRecord = await tx.operationRetries.get(
|
|
||||||
refundQueryOpId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const err =
|
return buildTransactionForPurchase(purchase, refunds, payRetryRecord);
|
||||||
payRetryRecord !== undefined
|
|
||||||
? payRetryRecord
|
|
||||||
: refundQueryRetryRecord;
|
|
||||||
|
|
||||||
return buildTransactionForPurchase(purchase, refunds, err);
|
|
||||||
});
|
});
|
||||||
} else if (type === TransactionType.Refresh) {
|
} else if (type === TransactionType.Refresh) {
|
||||||
const refreshGroupId = rest[0];
|
const refreshGroupId = rest[0];
|
||||||
@ -295,23 +268,14 @@ export async function getTransactionById(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (t) throw Error("deleted");
|
if (t) throw Error("deleted");
|
||||||
|
const download = await expectProposalDownload(purchase);
|
||||||
const contractData = purchase.download.contractData;
|
const contractData = download.contractData;
|
||||||
const refunds = mergeRefundByExecutionTime(
|
const refunds = mergeRefundByExecutionTime(
|
||||||
[theRefund],
|
[theRefund],
|
||||||
Amounts.getZero(contractData.amount.currency),
|
Amounts.getZero(contractData.amount.currency),
|
||||||
);
|
);
|
||||||
|
|
||||||
const refundQueryOpId = RetryTags.forRefundQuery(purchase);
|
return buildTransactionForRefund(purchase, refunds[0], undefined);
|
||||||
const refundQueryRetryRecord = await tx.operationRetries.get(
|
|
||||||
refundQueryOpId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return buildTransactionForRefund(
|
|
||||||
purchase,
|
|
||||||
refunds[0],
|
|
||||||
refundQueryRetryRecord,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} else if (type === TransactionType.PeerPullDebit) {
|
} else if (type === TransactionType.PeerPullDebit) {
|
||||||
const peerPullPaymentIncomingId = rest[0];
|
const peerPullPaymentIncomingId = rest[0];
|
||||||
@ -606,12 +570,13 @@ function mergeRefundByExecutionTime(
|
|||||||
return Array.from(refundByExecTime.values());
|
return Array.from(refundByExecTime.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTransactionForRefund(
|
async function buildTransactionForRefund(
|
||||||
purchaseRecord: PurchaseRecord,
|
purchaseRecord: PurchaseRecord,
|
||||||
refundInfo: MergedRefundInfo,
|
refundInfo: MergedRefundInfo,
|
||||||
ort?: OperationRetryRecord,
|
ort?: OperationRetryRecord,
|
||||||
): Transaction {
|
): Promise<Transaction> {
|
||||||
const contractData = purchaseRecord.download.contractData;
|
const download = await expectProposalDownload(purchaseRecord);
|
||||||
|
const contractData = download.contractData;
|
||||||
|
|
||||||
const info: OrderShortInfo = {
|
const info: OrderShortInfo = {
|
||||||
merchant: contractData.merchant,
|
merchant: contractData.merchant,
|
||||||
@ -641,21 +606,22 @@ function buildTransactionForRefund(
|
|||||||
amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective),
|
amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective),
|
||||||
amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw),
|
amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw),
|
||||||
refundPending:
|
refundPending:
|
||||||
purchaseRecord.refundAwaiting === undefined
|
purchaseRecord.refundAmountAwaiting === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: Amounts.stringify(purchaseRecord.refundAwaiting),
|
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
|
||||||
pending: false,
|
pending: false,
|
||||||
frozen: false,
|
frozen: false,
|
||||||
...(ort?.lastError ? { error: ort.lastError } : {}),
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTransactionForPurchase(
|
async function buildTransactionForPurchase(
|
||||||
purchaseRecord: PurchaseRecord,
|
purchaseRecord: PurchaseRecord,
|
||||||
refundsInfo: MergedRefundInfo[],
|
refundsInfo: MergedRefundInfo[],
|
||||||
ort?: OperationRetryRecord,
|
ort?: OperationRetryRecord,
|
||||||
): Transaction {
|
): Promise<Transaction> {
|
||||||
const contractData = purchaseRecord.download.contractData;
|
const download = await expectProposalDownload(purchaseRecord);
|
||||||
|
const contractData = download.contractData;
|
||||||
const zero = Amounts.getZero(contractData.amount.currency);
|
const zero = Amounts.getZero(contractData.amount.currency);
|
||||||
|
|
||||||
const info: OrderShortInfo = {
|
const info: OrderShortInfo = {
|
||||||
@ -696,31 +662,34 @@ function buildTransactionForPurchase(
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const timestamp = purchaseRecord.timestampAccept;
|
||||||
|
checkDbInvariant(!!timestamp);
|
||||||
|
checkDbInvariant(!!purchaseRecord.payInfo);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: TransactionType.Payment,
|
type: TransactionType.Payment,
|
||||||
amountRaw: Amounts.stringify(contractData.amount),
|
amountRaw: Amounts.stringify(contractData.amount),
|
||||||
amountEffective: Amounts.stringify(purchaseRecord.totalPayCost),
|
amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
|
||||||
totalRefundRaw: Amounts.stringify(totalRefund.raw),
|
totalRefundRaw: Amounts.stringify(totalRefund.raw),
|
||||||
totalRefundEffective: Amounts.stringify(totalRefund.effective),
|
totalRefundEffective: Amounts.stringify(totalRefund.effective),
|
||||||
refundPending:
|
refundPending:
|
||||||
purchaseRecord.refundAwaiting === undefined
|
purchaseRecord.refundAmountAwaiting === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: Amounts.stringify(purchaseRecord.refundAwaiting),
|
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
|
||||||
status: purchaseRecord.timestampFirstSuccessfulPay
|
status: purchaseRecord.timestampFirstSuccessfulPay
|
||||||
? PaymentStatus.Paid
|
? PaymentStatus.Paid
|
||||||
: PaymentStatus.Accepted,
|
: PaymentStatus.Accepted,
|
||||||
pending:
|
pending: purchaseRecord.status === ProposalStatus.Paying,
|
||||||
!purchaseRecord.timestampFirstSuccessfulPay &&
|
|
||||||
purchaseRecord.abortStatus === AbortStatus.None,
|
|
||||||
refunds,
|
refunds,
|
||||||
timestamp: purchaseRecord.timestampAccept,
|
timestamp,
|
||||||
transactionId: makeEventId(
|
transactionId: makeEventId(
|
||||||
TransactionType.Payment,
|
TransactionType.Payment,
|
||||||
purchaseRecord.proposalId,
|
purchaseRecord.proposalId,
|
||||||
),
|
),
|
||||||
proposalId: purchaseRecord.proposalId,
|
proposalId: purchaseRecord.proposalId,
|
||||||
info,
|
info,
|
||||||
frozen: purchaseRecord.payFrozen ?? false,
|
frozen:
|
||||||
|
purchaseRecord.status === ProposalStatus.PaymentAbortFinished ?? false,
|
||||||
...(ort?.lastError ? { error: ort.lastError } : {}),
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -745,7 +714,6 @@ export async function getTransactions(
|
|||||||
x.peerPullPaymentIncoming,
|
x.peerPullPaymentIncoming,
|
||||||
x.peerPushPaymentInitiations,
|
x.peerPushPaymentInitiations,
|
||||||
x.planchets,
|
x.planchets,
|
||||||
x.proposals,
|
|
||||||
x.purchases,
|
x.purchases,
|
||||||
x.recoupGroups,
|
x.recoupGroups,
|
||||||
x.tips,
|
x.tips,
|
||||||
@ -838,30 +806,33 @@ export async function getTransactions(
|
|||||||
transactions.push(buildTransactionForDeposit(dg, retryRecord));
|
transactions.push(buildTransactionForDeposit(dg, retryRecord));
|
||||||
});
|
});
|
||||||
|
|
||||||
tx.purchases.iter().forEachAsync(async (pr) => {
|
tx.purchases.iter().forEachAsync(async (purchase) => {
|
||||||
|
const download = purchase.download;
|
||||||
|
if (!download) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!purchase.payInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
shouldSkipCurrency(
|
shouldSkipCurrency(
|
||||||
transactionsRequest,
|
transactionsRequest,
|
||||||
pr.download.contractData.amount.currency,
|
download.contractData.amount.currency,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const contractData = pr.download.contractData;
|
const contractData = download.contractData;
|
||||||
if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
|
if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const proposal = await tx.proposals.get(pr.proposalId);
|
|
||||||
if (!proposal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredRefunds = await Promise.all(
|
const filteredRefunds = await Promise.all(
|
||||||
Object.values(pr.refunds).map(async (r) => {
|
Object.values(purchase.refunds).map(async (r) => {
|
||||||
const t = await tx.tombstones.get(
|
const t = await tx.tombstones.get(
|
||||||
makeEventId(
|
makeEventId(
|
||||||
TombstoneTag.DeleteRefund,
|
TombstoneTag.DeleteRefund,
|
||||||
pr.proposalId,
|
purchase.proposalId,
|
||||||
`${r.executionTime.t_s}`,
|
`${r.executionTime.t_s}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -880,29 +851,16 @@ export async function getTransactions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
refunds.forEach(async (refundInfo) => {
|
refunds.forEach(async (refundInfo) => {
|
||||||
const refundQueryOpId = RetryTags.forRefundQuery(pr);
|
|
||||||
const refundQueryRetryRecord = await tx.operationRetries.get(
|
|
||||||
refundQueryOpId,
|
|
||||||
);
|
|
||||||
|
|
||||||
transactions.push(
|
transactions.push(
|
||||||
buildTransactionForRefund(pr, refundInfo, refundQueryRetryRecord),
|
await buildTransactionForRefund(purchase, refundInfo, undefined),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const payOpId = RetryTags.forPay(pr);
|
const payOpId = RetryTags.forPay(purchase);
|
||||||
const refundQueryOpId = RetryTags.forRefundQuery(pr);
|
|
||||||
const payRetryRecord = await tx.operationRetries.get(payOpId);
|
const payRetryRecord = await tx.operationRetries.get(payOpId);
|
||||||
const refundQueryRetryRecord = await tx.operationRetries.get(
|
transactions.push(
|
||||||
refundQueryOpId,
|
await buildTransactionForPurchase(purchase, refunds, payRetryRecord),
|
||||||
);
|
);
|
||||||
|
|
||||||
const err =
|
|
||||||
payRetryRecord !== undefined
|
|
||||||
? payRetryRecord
|
|
||||||
: refundQueryRetryRecord;
|
|
||||||
|
|
||||||
transactions.push(buildTransactionForPurchase(pr, refunds, err));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tx.tips.iter().forEachAsync(async (tipRecord) => {
|
tx.tips.iter().forEachAsync(async (tipRecord) => {
|
||||||
@ -1020,14 +978,9 @@ export async function deleteTransaction(
|
|||||||
} else if (type === TransactionType.Payment) {
|
} else if (type === TransactionType.Payment) {
|
||||||
const proposalId = rest[0];
|
const proposalId = rest[0];
|
||||||
await ws.db
|
await ws.db
|
||||||
.mktx((x) => [x.proposals, x.purchases, x.tombstones])
|
.mktx((x) => [x.purchases, x.tombstones])
|
||||||
.runReadWrite(async (tx) => {
|
.runReadWrite(async (tx) => {
|
||||||
let found = false;
|
let found = false;
|
||||||
const proposal = await tx.proposals.get(proposalId);
|
|
||||||
if (proposal) {
|
|
||||||
found = true;
|
|
||||||
await tx.proposals.delete(proposalId);
|
|
||||||
}
|
|
||||||
const purchase = await tx.purchases.get(proposalId);
|
const purchase = await tx.purchases.get(proposalId);
|
||||||
if (purchase) {
|
if (purchase) {
|
||||||
found = true;
|
found = true;
|
||||||
@ -1083,7 +1036,7 @@ export async function deleteTransaction(
|
|||||||
const executionTimeStr = rest[1];
|
const executionTimeStr = rest[1];
|
||||||
|
|
||||||
await ws.db
|
await ws.db
|
||||||
.mktx((x) => [x.proposals, x.purchases, x.tombstones])
|
.mktx((x) => [x.purchases, x.tombstones])
|
||||||
.runReadWrite(async (tx) => {
|
.runReadWrite(async (tx) => {
|
||||||
const purchase = await tx.purchases.get(proposalId);
|
const purchase = await tx.purchases.get(proposalId);
|
||||||
if (purchase) {
|
if (purchase) {
|
||||||
|
@ -70,12 +70,11 @@ import {
|
|||||||
DenomSelectionState,
|
DenomSelectionState,
|
||||||
ExchangeDetailsRecord,
|
ExchangeDetailsRecord,
|
||||||
ExchangeRecord,
|
ExchangeRecord,
|
||||||
OperationStatus,
|
|
||||||
PlanchetRecord,
|
PlanchetRecord,
|
||||||
WithdrawalGroupStatus,
|
|
||||||
WalletStoresV1,
|
WalletStoresV1,
|
||||||
WgInfo,
|
WgInfo,
|
||||||
WithdrawalGroupRecord,
|
WithdrawalGroupRecord,
|
||||||
|
WithdrawalGroupStatus,
|
||||||
WithdrawalRecordType,
|
WithdrawalRecordType,
|
||||||
} from "../db.js";
|
} from "../db.js";
|
||||||
import {
|
import {
|
||||||
@ -84,7 +83,10 @@ import {
|
|||||||
TalerError,
|
TalerError,
|
||||||
} from "../errors.js";
|
} from "../errors.js";
|
||||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||||
import { assertUnreachable } from "../util/assertUnreachable.js";
|
import {
|
||||||
|
makeCoinAvailable,
|
||||||
|
runOperationWithErrorReporting,
|
||||||
|
} from "../operations/common.js";
|
||||||
import { walletCoreDebugFlags } from "../util/debugFlags.js";
|
import { walletCoreDebugFlags } from "../util/debugFlags.js";
|
||||||
import {
|
import {
|
||||||
HttpRequestLibrary,
|
HttpRequestLibrary,
|
||||||
@ -108,18 +110,16 @@ import {
|
|||||||
WALLET_EXCHANGE_PROTOCOL_VERSION,
|
WALLET_EXCHANGE_PROTOCOL_VERSION,
|
||||||
} from "../versions.js";
|
} from "../versions.js";
|
||||||
import {
|
import {
|
||||||
makeCoinAvailable,
|
makeEventId,
|
||||||
runOperationWithErrorReporting,
|
|
||||||
storeOperationError,
|
storeOperationError,
|
||||||
storeOperationPending,
|
storeOperationPending,
|
||||||
} from "../wallet.js";
|
} from "./common.js";
|
||||||
import {
|
import {
|
||||||
getExchangeDetails,
|
getExchangeDetails,
|
||||||
getExchangePaytoUri,
|
getExchangePaytoUri,
|
||||||
getExchangeTrust,
|
getExchangeTrust,
|
||||||
updateExchangeFromUrl,
|
updateExchangeFromUrl,
|
||||||
} from "./exchanges.js";
|
} from "./exchanges.js";
|
||||||
import { makeEventId } from "./transactions.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logger for this file.
|
* Logger for this file.
|
||||||
|
@ -34,11 +34,9 @@ import { RetryInfo } from "./util/retries.js";
|
|||||||
export enum PendingTaskType {
|
export enum PendingTaskType {
|
||||||
ExchangeUpdate = "exchange-update",
|
ExchangeUpdate = "exchange-update",
|
||||||
ExchangeCheckRefresh = "exchange-check-refresh",
|
ExchangeCheckRefresh = "exchange-check-refresh",
|
||||||
Pay = "pay",
|
Purchase = "purchase",
|
||||||
ProposalDownload = "proposal-download",
|
|
||||||
Refresh = "refresh",
|
Refresh = "refresh",
|
||||||
Recoup = "recoup",
|
Recoup = "recoup",
|
||||||
RefundQuery = "refund-query",
|
|
||||||
TipPickup = "tip-pickup",
|
TipPickup = "tip-pickup",
|
||||||
Withdraw = "withdraw",
|
Withdraw = "withdraw",
|
||||||
Deposit = "deposit",
|
Deposit = "deposit",
|
||||||
@ -52,10 +50,8 @@ export type PendingTaskInfo = PendingTaskInfoCommon &
|
|||||||
(
|
(
|
||||||
| PendingExchangeUpdateTask
|
| PendingExchangeUpdateTask
|
||||||
| PendingExchangeCheckRefreshTask
|
| PendingExchangeCheckRefreshTask
|
||||||
| PendingPayTask
|
| PendingPurchaseTask
|
||||||
| PendingProposalDownloadTask
|
|
||||||
| PendingRefreshTask
|
| PendingRefreshTask
|
||||||
| PendingRefundQueryTask
|
|
||||||
| PendingTipPickupTask
|
| PendingTipPickupTask
|
||||||
| PendingWithdrawTask
|
| PendingWithdrawTask
|
||||||
| PendingRecoupTask
|
| PendingRecoupTask
|
||||||
@ -109,19 +105,6 @@ export interface PendingRefreshTask {
|
|||||||
retryInfo?: RetryInfo;
|
retryInfo?: RetryInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Status of downloading signed contract terms from a merchant.
|
|
||||||
*/
|
|
||||||
export interface PendingProposalDownloadTask {
|
|
||||||
type: PendingTaskType.ProposalDownload;
|
|
||||||
merchantBaseUrl: string;
|
|
||||||
proposalTimestamp: TalerProtocolTimestamp;
|
|
||||||
proposalId: string;
|
|
||||||
orderId: string;
|
|
||||||
lastError?: TalerErrorDetail;
|
|
||||||
retryInfo?: RetryInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The wallet is picking up a tip that the user has accepted.
|
* The wallet is picking up a tip that the user has accepted.
|
||||||
*/
|
*/
|
||||||
@ -133,25 +116,16 @@ export interface PendingTipPickupTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The wallet is signing coins and then sending them to
|
* A purchase needs to be processed (i.e. for download / payment / refund).
|
||||||
* the merchant.
|
|
||||||
*/
|
*/
|
||||||
export interface PendingPayTask {
|
export interface PendingPurchaseTask {
|
||||||
type: PendingTaskType.Pay;
|
type: PendingTaskType.Purchase;
|
||||||
proposalId: string;
|
|
||||||
isReplay: boolean;
|
|
||||||
retryInfo?: RetryInfo;
|
|
||||||
lastError: TalerErrorDetail | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The wallet is querying the merchant about whether any refund
|
|
||||||
* permissions are available for a purchase.
|
|
||||||
*/
|
|
||||||
export interface PendingRefundQueryTask {
|
|
||||||
type: PendingTaskType.RefundQuery;
|
|
||||||
proposalId: string;
|
proposalId: string;
|
||||||
retryInfo?: RetryInfo;
|
retryInfo?: RetryInfo;
|
||||||
|
/**
|
||||||
|
* Status of the payment as string, used only for debugging.
|
||||||
|
*/
|
||||||
|
statusStr: string;
|
||||||
lastError: TalerErrorDetail | undefined;
|
lastError: TalerErrorDetail | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,7 +30,6 @@ import {
|
|||||||
BackupProviderRecord,
|
BackupProviderRecord,
|
||||||
DepositGroupRecord,
|
DepositGroupRecord,
|
||||||
ExchangeRecord,
|
ExchangeRecord,
|
||||||
ProposalRecord,
|
|
||||||
PurchaseRecord,
|
PurchaseRecord,
|
||||||
RecoupGroupRecord,
|
RecoupGroupRecord,
|
||||||
RefreshGroupRecord,
|
RefreshGroupRecord,
|
||||||
@ -181,9 +180,6 @@ export namespace RetryTags {
|
|||||||
export function forExchangeCheckRefresh(exch: ExchangeRecord): string {
|
export function forExchangeCheckRefresh(exch: ExchangeRecord): string {
|
||||||
return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}`;
|
return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}`;
|
||||||
}
|
}
|
||||||
export function forProposalClaim(pr: ProposalRecord): string {
|
|
||||||
return `${PendingTaskType.ProposalDownload}:${pr.proposalId}`;
|
|
||||||
}
|
|
||||||
export function forTipPickup(tipRecord: TipRecord): string {
|
export function forTipPickup(tipRecord: TipRecord): string {
|
||||||
return `${PendingTaskType.TipPickup}:${tipRecord.walletTipId}`;
|
return `${PendingTaskType.TipPickup}:${tipRecord.walletTipId}`;
|
||||||
}
|
}
|
||||||
@ -191,10 +187,7 @@ export namespace RetryTags {
|
|||||||
return `${PendingTaskType.TipPickup}:${refreshGroupRecord.refreshGroupId}`;
|
return `${PendingTaskType.TipPickup}:${refreshGroupRecord.refreshGroupId}`;
|
||||||
}
|
}
|
||||||
export function forPay(purchaseRecord: PurchaseRecord): string {
|
export function forPay(purchaseRecord: PurchaseRecord): string {
|
||||||
return `${PendingTaskType.Pay}:${purchaseRecord.proposalId}`;
|
return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}`;
|
||||||
}
|
|
||||||
export function forRefundQuery(purchaseRecord: PurchaseRecord): string {
|
|
||||||
return `${PendingTaskType.RefundQuery}:${purchaseRecord.proposalId}`;
|
|
||||||
}
|
}
|
||||||
export function forRecoup(recoupRecord: RecoupGroupRecord): string {
|
export function forRecoup(recoupRecord: RecoupGroupRecord): string {
|
||||||
return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}`;
|
return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}`;
|
||||||
@ -206,7 +199,7 @@ export namespace RetryTags {
|
|||||||
return `${PendingTaskType.Backup}:${backupRecord.baseUrl}`;
|
return `${PendingTaskType.Backup}:${backupRecord.baseUrl}`;
|
||||||
}
|
}
|
||||||
export function byPaymentProposalId(proposalId: string): string {
|
export function byPaymentProposalId(proposalId: string): string {
|
||||||
return `${PendingTaskType.Pay}:${proposalId}`;
|
return `${PendingTaskType.Purchase}:${proposalId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,7 +26,6 @@ import {
|
|||||||
AbsoluteTime,
|
AbsoluteTime,
|
||||||
AmountJson,
|
AmountJson,
|
||||||
Amounts,
|
Amounts,
|
||||||
BalancesResponse,
|
|
||||||
codecForAbortPayWithRefundRequest,
|
codecForAbortPayWithRefundRequest,
|
||||||
codecForAcceptBankIntegratedWithdrawalRequest,
|
codecForAcceptBankIntegratedWithdrawalRequest,
|
||||||
codecForAcceptExchangeTosRequest,
|
codecForAcceptExchangeTosRequest,
|
||||||
@ -35,6 +34,7 @@ import {
|
|||||||
codecForAcceptPeerPushPaymentRequest,
|
codecForAcceptPeerPushPaymentRequest,
|
||||||
codecForAcceptTipRequest,
|
codecForAcceptTipRequest,
|
||||||
codecForAddExchangeRequest,
|
codecForAddExchangeRequest,
|
||||||
|
codecForAddKnownBankAccounts,
|
||||||
codecForAny,
|
codecForAny,
|
||||||
codecForApplyRefundFromPurchaseIdRequest,
|
codecForApplyRefundFromPurchaseIdRequest,
|
||||||
codecForApplyRefundRequest,
|
codecForApplyRefundRequest,
|
||||||
@ -44,6 +44,7 @@ import {
|
|||||||
codecForCreateDepositGroupRequest,
|
codecForCreateDepositGroupRequest,
|
||||||
codecForDeleteTransactionRequest,
|
codecForDeleteTransactionRequest,
|
||||||
codecForForceRefreshRequest,
|
codecForForceRefreshRequest,
|
||||||
|
codecForForgetKnownBankAccounts,
|
||||||
codecForGetContractTermsDetails,
|
codecForGetContractTermsDetails,
|
||||||
codecForGetExchangeTosRequest,
|
codecForGetExchangeTosRequest,
|
||||||
codecForGetExchangeWithdrawalInfo,
|
codecForGetExchangeWithdrawalInfo,
|
||||||
@ -81,6 +82,7 @@ import {
|
|||||||
GetExchangeTosResult,
|
GetExchangeTosResult,
|
||||||
j2s,
|
j2s,
|
||||||
KnownBankAccounts,
|
KnownBankAccounts,
|
||||||
|
KnownBankAccountsInfo,
|
||||||
Logger,
|
Logger,
|
||||||
ManualWithdrawalDetails,
|
ManualWithdrawalDetails,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@ -89,9 +91,6 @@ import {
|
|||||||
RefreshReason,
|
RefreshReason,
|
||||||
TalerErrorCode,
|
TalerErrorCode,
|
||||||
TalerErrorDetail,
|
TalerErrorDetail,
|
||||||
KnownBankAccountsInfo,
|
|
||||||
codecForAddKnownBankAccounts,
|
|
||||||
codecForForgetKnownBankAccounts,
|
|
||||||
URL,
|
URL,
|
||||||
WalletCoreVersion,
|
WalletCoreVersion,
|
||||||
WalletNotification,
|
WalletNotification,
|
||||||
@ -125,6 +124,7 @@ import {
|
|||||||
MerchantOperations,
|
MerchantOperations,
|
||||||
NotificationListener,
|
NotificationListener,
|
||||||
RecoupOperations,
|
RecoupOperations,
|
||||||
|
RefreshOperations,
|
||||||
} from "./internal-wallet-state.js";
|
} from "./internal-wallet-state.js";
|
||||||
import { exportBackup } from "./operations/backup/export.js";
|
import { exportBackup } from "./operations/backup/export.js";
|
||||||
import {
|
import {
|
||||||
@ -142,6 +142,11 @@ import {
|
|||||||
} from "./operations/backup/index.js";
|
} from "./operations/backup/index.js";
|
||||||
import { setWalletDeviceId } from "./operations/backup/state.js";
|
import { setWalletDeviceId } from "./operations/backup/state.js";
|
||||||
import { getBalances } from "./operations/balance.js";
|
import { getBalances } from "./operations/balance.js";
|
||||||
|
import {
|
||||||
|
runOperationWithErrorReporting,
|
||||||
|
storeOperationError,
|
||||||
|
storeOperationPending,
|
||||||
|
} from "./operations/common.js";
|
||||||
import {
|
import {
|
||||||
createDepositGroup,
|
createDepositGroup,
|
||||||
getFeeForDeposit,
|
getFeeForDeposit,
|
||||||
@ -162,12 +167,15 @@ import {
|
|||||||
} from "./operations/exchanges.js";
|
} from "./operations/exchanges.js";
|
||||||
import { getMerchantInfo } from "./operations/merchants.js";
|
import { getMerchantInfo } from "./operations/merchants.js";
|
||||||
import {
|
import {
|
||||||
|
abortFailedPayWithRefund,
|
||||||
|
applyRefund,
|
||||||
|
applyRefundFromPurchaseId,
|
||||||
confirmPay,
|
confirmPay,
|
||||||
getContractTermsDetails,
|
getContractTermsDetails,
|
||||||
preparePayForUri,
|
preparePayForUri,
|
||||||
processDownloadProposal,
|
prepareRefund,
|
||||||
processPurchasePay,
|
processPurchase,
|
||||||
} from "./operations/pay.js";
|
} from "./operations/pay-merchant.js";
|
||||||
import {
|
import {
|
||||||
acceptPeerPullPayment,
|
acceptPeerPullPayment,
|
||||||
acceptPeerPushPayment,
|
acceptPeerPushPayment,
|
||||||
@ -175,7 +183,7 @@ import {
|
|||||||
checkPeerPushPayment,
|
checkPeerPushPayment,
|
||||||
initiatePeerRequestForPay,
|
initiatePeerRequestForPay,
|
||||||
initiatePeerToPeerPush,
|
initiatePeerToPeerPush,
|
||||||
} from "./operations/peer-to-peer.js";
|
} from "./operations/pay-peer.js";
|
||||||
import { getPendingOperations } from "./operations/pending.js";
|
import { getPendingOperations } from "./operations/pending.js";
|
||||||
import {
|
import {
|
||||||
createRecoupGroup,
|
createRecoupGroup,
|
||||||
@ -187,13 +195,6 @@ import {
|
|||||||
createRefreshGroup,
|
createRefreshGroup,
|
||||||
processRefreshGroup,
|
processRefreshGroup,
|
||||||
} from "./operations/refresh.js";
|
} from "./operations/refresh.js";
|
||||||
import {
|
|
||||||
abortFailedPayWithRefund,
|
|
||||||
applyRefund,
|
|
||||||
applyRefundFromPurchaseId,
|
|
||||||
prepareRefund,
|
|
||||||
processPurchaseQueryRefund,
|
|
||||||
} from "./operations/refund.js";
|
|
||||||
import {
|
import {
|
||||||
runIntegrationTest,
|
runIntegrationTest,
|
||||||
testPay,
|
testPay,
|
||||||
@ -213,13 +214,8 @@ import {
|
|||||||
getWithdrawalDetailsForUri,
|
getWithdrawalDetailsForUri,
|
||||||
processWithdrawalGroup,
|
processWithdrawalGroup,
|
||||||
} from "./operations/withdraw.js";
|
} from "./operations/withdraw.js";
|
||||||
import {
|
import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
|
||||||
PendingOperationsResponse,
|
|
||||||
PendingTaskInfo,
|
|
||||||
PendingTaskType,
|
|
||||||
} from "./pending-types.js";
|
|
||||||
import { assertUnreachable } from "./util/assertUnreachable.js";
|
import { assertUnreachable } from "./util/assertUnreachable.js";
|
||||||
import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
|
|
||||||
import { createDenominationTimeline } from "./util/denominations.js";
|
import { createDenominationTimeline } from "./util/denominations.js";
|
||||||
import {
|
import {
|
||||||
HttpRequestLibrary,
|
HttpRequestLibrary,
|
||||||
@ -306,18 +302,10 @@ async function callOperationHandler(
|
|||||||
return await processWithdrawalGroup(ws, pending.withdrawalGroupId, {
|
return await processWithdrawalGroup(ws, pending.withdrawalGroupId, {
|
||||||
forceNow,
|
forceNow,
|
||||||
});
|
});
|
||||||
case PendingTaskType.ProposalDownload:
|
|
||||||
return await processDownloadProposal(ws, pending.proposalId, {
|
|
||||||
forceNow,
|
|
||||||
});
|
|
||||||
case PendingTaskType.TipPickup:
|
case PendingTaskType.TipPickup:
|
||||||
return await processTip(ws, pending.tipId, { forceNow });
|
return await processTip(ws, pending.tipId, { forceNow });
|
||||||
case PendingTaskType.Pay:
|
case PendingTaskType.Purchase:
|
||||||
return await processPurchasePay(ws, pending.proposalId, { forceNow });
|
return await processPurchase(ws, pending.proposalId, { forceNow });
|
||||||
case PendingTaskType.RefundQuery:
|
|
||||||
return await processPurchaseQueryRefund(ws, pending.proposalId, {
|
|
||||||
forceNow,
|
|
||||||
});
|
|
||||||
case PendingTaskType.Recoup:
|
case PendingTaskType.Recoup:
|
||||||
return await processRecoupGroupHandler(ws, pending.recoupGroupId, {
|
return await processRecoupGroupHandler(ws, pending.recoupGroupId, {
|
||||||
forceNow,
|
forceNow,
|
||||||
@ -337,111 +325,6 @@ async function callOperationHandler(
|
|||||||
throw Error(`not reached ${pending.type}`);
|
throw Error(`not reached ${pending.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function storeOperationError(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
pendingTaskId: string,
|
|
||||||
e: TalerErrorDetail,
|
|
||||||
): Promise<void> {
|
|
||||||
await ws.db
|
|
||||||
.mktx((x) => [x.operationRetries])
|
|
||||||
.runReadWrite(async (tx) => {
|
|
||||||
let retryRecord = await tx.operationRetries.get(pendingTaskId);
|
|
||||||
if (!retryRecord) {
|
|
||||||
retryRecord = {
|
|
||||||
id: pendingTaskId,
|
|
||||||
lastError: e,
|
|
||||||
retryInfo: RetryInfo.reset(),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
retryRecord.lastError = e;
|
|
||||||
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
|
|
||||||
}
|
|
||||||
await tx.operationRetries.put(retryRecord);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function storeOperationFinished(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
pendingTaskId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await ws.db
|
|
||||||
.mktx((x) => [x.operationRetries])
|
|
||||||
.runReadWrite(async (tx) => {
|
|
||||||
await tx.operationRetries.delete(pendingTaskId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function storeOperationPending(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
pendingTaskId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await ws.db
|
|
||||||
.mktx((x) => [x.operationRetries])
|
|
||||||
.runReadWrite(async (tx) => {
|
|
||||||
let retryRecord = await tx.operationRetries.get(pendingTaskId);
|
|
||||||
if (!retryRecord) {
|
|
||||||
retryRecord = {
|
|
||||||
id: pendingTaskId,
|
|
||||||
retryInfo: RetryInfo.reset(),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
delete retryRecord.lastError;
|
|
||||||
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
|
|
||||||
}
|
|
||||||
await tx.operationRetries.put(retryRecord);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runOperationWithErrorReporting(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
opId: string,
|
|
||||||
f: () => Promise<OperationAttemptResult>,
|
|
||||||
): Promise<void> {
|
|
||||||
let maybeError: TalerErrorDetail | undefined;
|
|
||||||
try {
|
|
||||||
const resp = await f();
|
|
||||||
switch (resp.type) {
|
|
||||||
case OperationAttemptResultType.Error:
|
|
||||||
return await storeOperationError(ws, opId, resp.errorDetail);
|
|
||||||
case OperationAttemptResultType.Finished:
|
|
||||||
return await storeOperationFinished(ws, opId);
|
|
||||||
case OperationAttemptResultType.Pending:
|
|
||||||
return await storeOperationPending(ws, opId);
|
|
||||||
case OperationAttemptResultType.Longpoll:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof TalerError) {
|
|
||||||
logger.warn("operation processed resulted in error");
|
|
||||||
logger.warn(`error was: ${j2s(e.errorDetail)}`);
|
|
||||||
maybeError = e.errorDetail;
|
|
||||||
return await storeOperationError(ws, opId, maybeError!);
|
|
||||||
} else if (e instanceof Error) {
|
|
||||||
// This is a bug, as we expect pending operations to always
|
|
||||||
// do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED
|
|
||||||
// or return something.
|
|
||||||
logger.error(`Uncaught exception: ${e.message}`);
|
|
||||||
logger.error(`Stack: ${e.stack}`);
|
|
||||||
maybeError = makeErrorDetail(
|
|
||||||
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
|
|
||||||
{
|
|
||||||
stack: e.stack,
|
|
||||||
},
|
|
||||||
`unexpected exception (message: ${e.message})`,
|
|
||||||
);
|
|
||||||
return await storeOperationError(ws, opId, maybeError);
|
|
||||||
} else {
|
|
||||||
logger.error("Uncaught exception, value is not even an error.");
|
|
||||||
maybeError = makeErrorDetail(
|
|
||||||
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
|
|
||||||
{},
|
|
||||||
`unexpected exception (not even an error)`,
|
|
||||||
);
|
|
||||||
return await storeOperationError(ws, opId, maybeError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process pending operations.
|
* Process pending operations.
|
||||||
*/
|
*/
|
||||||
@ -857,120 +740,6 @@ async function getExchangeDetailedInfo(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function makeCoinAvailable(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
tx: GetReadWriteAccess<{
|
|
||||||
coins: typeof WalletStoresV1.coins;
|
|
||||||
coinAvailability: typeof WalletStoresV1.coinAvailability;
|
|
||||||
denominations: typeof WalletStoresV1.denominations;
|
|
||||||
}>,
|
|
||||||
coinRecord: CoinRecord,
|
|
||||||
): Promise<void> {
|
|
||||||
checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
|
|
||||||
const existingCoin = await tx.coins.get(coinRecord.coinPub);
|
|
||||||
if (existingCoin) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const denom = await tx.denominations.get([
|
|
||||||
coinRecord.exchangeBaseUrl,
|
|
||||||
coinRecord.denomPubHash,
|
|
||||||
]);
|
|
||||||
checkDbInvariant(!!denom);
|
|
||||||
const ageRestriction = coinRecord.maxAge;
|
|
||||||
let car = await tx.coinAvailability.get([
|
|
||||||
coinRecord.exchangeBaseUrl,
|
|
||||||
coinRecord.denomPubHash,
|
|
||||||
ageRestriction,
|
|
||||||
]);
|
|
||||||
if (!car) {
|
|
||||||
car = {
|
|
||||||
maxAge: ageRestriction,
|
|
||||||
amountFrac: denom.amountFrac,
|
|
||||||
amountVal: denom.amountVal,
|
|
||||||
currency: denom.currency,
|
|
||||||
denomPubHash: denom.denomPubHash,
|
|
||||||
exchangeBaseUrl: denom.exchangeBaseUrl,
|
|
||||||
freshCoinCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
car.freshCoinCount++;
|
|
||||||
await tx.coins.put(coinRecord);
|
|
||||||
await tx.coinAvailability.put(car);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoinsSpendInfo {
|
|
||||||
coinPubs: string[];
|
|
||||||
contributions: AmountJson[];
|
|
||||||
refreshReason: RefreshReason;
|
|
||||||
/**
|
|
||||||
* Identifier for what the coin has been spent for.
|
|
||||||
*/
|
|
||||||
allocationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function spendCoins(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
tx: GetReadWriteAccess<{
|
|
||||||
coins: typeof WalletStoresV1.coins;
|
|
||||||
coinAvailability: typeof WalletStoresV1.coinAvailability;
|
|
||||||
refreshGroups: typeof WalletStoresV1.refreshGroups;
|
|
||||||
denominations: typeof WalletStoresV1.denominations;
|
|
||||||
}>,
|
|
||||||
csi: CoinsSpendInfo,
|
|
||||||
): Promise<void> {
|
|
||||||
for (let i = 0; i < csi.coinPubs.length; i++) {
|
|
||||||
const coin = await tx.coins.get(csi.coinPubs[i]);
|
|
||||||
if (!coin) {
|
|
||||||
throw Error("coin allocated for payment doesn't exist anymore");
|
|
||||||
}
|
|
||||||
const coinAvailability = await tx.coinAvailability.get([
|
|
||||||
coin.exchangeBaseUrl,
|
|
||||||
coin.denomPubHash,
|
|
||||||
coin.maxAge,
|
|
||||||
]);
|
|
||||||
checkDbInvariant(!!coinAvailability);
|
|
||||||
const contrib = csi.contributions[i];
|
|
||||||
if (coin.status !== CoinStatus.Fresh) {
|
|
||||||
const alloc = coin.allocation;
|
|
||||||
if (!alloc) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (alloc.id !== csi.allocationId) {
|
|
||||||
// FIXME: assign error code
|
|
||||||
throw Error("conflicting coin allocation (id)");
|
|
||||||
}
|
|
||||||
if (0 !== Amounts.cmp(alloc.amount, contrib)) {
|
|
||||||
// FIXME: assign error code
|
|
||||||
throw Error("conflicting coin allocation (contrib)");
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
coin.status = CoinStatus.Dormant;
|
|
||||||
coin.allocation = {
|
|
||||||
id: csi.allocationId,
|
|
||||||
amount: Amounts.stringify(contrib),
|
|
||||||
};
|
|
||||||
const remaining = Amounts.sub(coin.currentAmount, contrib);
|
|
||||||
if (remaining.saturated) {
|
|
||||||
throw Error("not enough remaining balance on coin for payment");
|
|
||||||
}
|
|
||||||
coin.currentAmount = remaining.amount;
|
|
||||||
checkDbInvariant(!!coinAvailability);
|
|
||||||
if (coinAvailability.freshCoinCount === 0) {
|
|
||||||
throw Error(
|
|
||||||
`invalid coin count ${coinAvailability.freshCoinCount} in DB`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
coinAvailability.freshCoinCount--;
|
|
||||||
await tx.coins.put(coin);
|
|
||||||
await tx.coinAvailability.put(coinAvailability);
|
|
||||||
}
|
|
||||||
const refreshCoinPubs = csi.coinPubs.map((x) => ({
|
|
||||||
coinPub: x,
|
|
||||||
}));
|
|
||||||
await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.PayMerchant);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setCoinSuspended(
|
async function setCoinSuspended(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
coinPub: string,
|
coinPub: string,
|
||||||
@ -1649,6 +1418,10 @@ class InternalWalletStateImpl implements InternalWalletState {
|
|||||||
getMerchantInfo,
|
getMerchantInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
refreshOps: RefreshOperations = {
|
||||||
|
createRefreshGroup,
|
||||||
|
};
|
||||||
|
|
||||||
// FIXME: Use an LRU cache here.
|
// FIXME: Use an LRU cache here.
|
||||||
private denomCache: Record<string, DenominationInfo> = {};
|
private denomCache: Record<string, DenominationInfo> = {};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user