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,
|
||||
// Defines whether an open brace is put onto a new line for control blocks or not
|
||||
"typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"index.js",
|
||||
"index.*.js",
|
||||
"index.ts",
|
||||
"index.*.ts"
|
||||
],
|
||||
// Files hidden in the explorer
|
||||
"files.exclude": {
|
||||
// include the defaults from VS Code
|
||||
|
@ -378,9 +378,9 @@ export class MemoryBackend implements Backend {
|
||||
}
|
||||
}
|
||||
|
||||
private makeObjectStoreMap(
|
||||
database: Database,
|
||||
): { [currentName: string]: ObjectStoreMapEntry } {
|
||||
private makeObjectStoreMap(database: Database): {
|
||||
[currentName: string]: ObjectStoreMapEntry;
|
||||
} {
|
||||
let map: { [currentName: string]: ObjectStoreMapEntry } = {};
|
||||
for (let objectStoreName in database.committedObjectStores) {
|
||||
const store = database.committedObjectStores[objectStoreName];
|
||||
@ -1088,9 +1088,8 @@ export class MemoryBackend implements Backend {
|
||||
if (!existingIndexRecord) {
|
||||
throw Error("db inconsistent: expected index entry missing");
|
||||
}
|
||||
const newPrimaryKeys = existingIndexRecord.primaryKeys.without(
|
||||
primaryKey,
|
||||
);
|
||||
const newPrimaryKeys =
|
||||
existingIndexRecord.primaryKeys.without(primaryKey);
|
||||
if (newPrimaryKeys.size === 0) {
|
||||
index.modifiedData = indexData.without(indexKey);
|
||||
} else {
|
||||
@ -1357,7 +1356,20 @@ export class MemoryBackend implements Backend {
|
||||
|
||||
// Remove old index entry first!
|
||||
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 {
|
||||
this.insertIntoIndex(index, key, value, indexProperties);
|
||||
|
@ -180,15 +180,6 @@ export interface WalletBackupContentV1 {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@ -838,29 +829,10 @@ export type BackupRefundItem =
|
||||
| BackupRefundPendingItem
|
||||
| BackupRefundAppliedItem;
|
||||
|
||||
export interface BackupPurchase {
|
||||
/**
|
||||
* Proposal ID for this purchase. Uniquely identifies the
|
||||
* purchase and the proposal.
|
||||
/**
|
||||
* Data we store when the payment was accepted.
|
||||
*/
|
||||
proposal_id: string;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
export interface BackupPayInfo {
|
||||
pay_coins: {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
@ -902,11 +931,13 @@ export interface BackupPurchase {
|
||||
*/
|
||||
merchant_pay_sig: string | undefined;
|
||||
|
||||
timestamp_proposed: TalerProtocolTimestamp;
|
||||
|
||||
/**
|
||||
* When was the purchase made?
|
||||
* Refers to the time that the user accepted.
|
||||
*/
|
||||
timestamp_accept: TalerProtocolTimestamp;
|
||||
timestamp_accepted: TalerProtocolTimestamp | undefined;
|
||||
|
||||
/**
|
||||
* Pending refunds for the purchase. A refund is pending
|
||||
@ -914,11 +945,6 @@ export interface BackupPurchase {
|
||||
*/
|
||||
refunds: BackupRefundItem[];
|
||||
|
||||
/**
|
||||
* Abort status of the payment.
|
||||
*/
|
||||
abort_status?: "abort-refund" | "abort-finished";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
Repurchase = "repurchase",
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
Paid = "paid",
|
||||
}
|
||||
|
||||
export interface BackupRecovery {
|
||||
|
@ -17,13 +17,8 @@
|
||||
/**
|
||||
* Imports.
|
||||
*/
|
||||
import {
|
||||
PreparePayResultType,
|
||||
TalerErrorCode,
|
||||
TalerErrorDetail,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { PreparePayResultType, TalerErrorCode } from "@gnu-taler/taler-util";
|
||||
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
|
||||
import { makeEventId } from "@gnu-taler/taler-wallet-core";
|
||||
import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
|
||||
import {
|
||||
createSimpleTestkudosEnvironment,
|
||||
|
@ -18,7 +18,10 @@
|
||||
* Imports.
|
||||
*/
|
||||
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 { 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) {
|
||||
// Set up test environment
|
||||
|
||||
const {
|
||||
wallet,
|
||||
bank,
|
||||
exchange,
|
||||
merchant,
|
||||
} = await createSimpleTestkudosEnvironment(t);
|
||||
const { wallet, bank, exchange, merchant } =
|
||||
await createSimpleTestkudosEnvironment(t);
|
||||
|
||||
// Withdraw digital cash into the wallet.
|
||||
|
||||
@ -83,10 +82,16 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) {
|
||||
|
||||
const proposalId = preparePayResult.proposalId;
|
||||
|
||||
await wallet.client.call(WalletApiOperation.ConfirmPay, {
|
||||
// FIXME: should be validated, don't cast!
|
||||
const confirmPayResult = await wallet.client.call(
|
||||
WalletApiOperation.ConfirmPay,
|
||||
{
|
||||
proposalId: proposalId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
console.log("confirm pay result", confirmPayResult);
|
||||
|
||||
await wallet.runUntilDone();
|
||||
|
||||
// Check if payment was successful.
|
||||
|
||||
@ -103,6 +108,8 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) {
|
||||
},
|
||||
);
|
||||
|
||||
console.log("result after:", preparePayResultAfter);
|
||||
|
||||
t.assertTrue(
|
||||
preparePayResultAfter.status === PreparePayResultType.AlreadyConfirmed,
|
||||
);
|
||||
|
@ -34,11 +34,7 @@ import {
|
||||
TalerErrorCode,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { TalerError } from "./errors.js";
|
||||
import {
|
||||
HttpRequestLibrary,
|
||||
readSuccessResponseJsonOrErrorCode,
|
||||
readSuccessResponseJsonOrThrow,
|
||||
} from "./index.browser.js";
|
||||
import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from "./util/http.js";
|
||||
|
||||
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 namespace OperationStatusRange {
|
||||
export const ACTIVE_START = 10;
|
||||
export const ACTIVE_END = 29;
|
||||
export const DORMANT_START = 50;
|
||||
export const DORMANT_END = 69;
|
||||
export enum OperationStatusRange {
|
||||
ACTIVE_START = 10,
|
||||
ACTIVE_END = 29,
|
||||
DORMANT_START = 50,
|
||||
DORMANT_END = 69,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -741,93 +741,6 @@ export interface CoinAllocation {
|
||||
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.
|
||||
*/
|
||||
@ -1113,23 +1026,132 @@ export interface WalletContractData {
|
||||
deliveryLocation: Location | undefined;
|
||||
}
|
||||
|
||||
export enum AbortStatus {
|
||||
None = "none",
|
||||
AbortRefund = "abort-refund",
|
||||
AbortFinished = "abort-finished",
|
||||
export enum ProposalStatus {
|
||||
/**
|
||||
* Not downloaded yet.
|
||||
*/
|
||||
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
|
||||
* the customer accepts a proposal. Includes refund status if applicable.
|
||||
*
|
||||
* FIXME: Should have a single "status" field.
|
||||
*/
|
||||
export interface PurchaseRecord {
|
||||
/**
|
||||
* Proposal ID for this purchase. Uniquely identifies the
|
||||
* purchase and the proposal.
|
||||
* Assigned by the wallet.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@ -1146,18 +1168,9 @@ export interface PurchaseRecord {
|
||||
* FIXME: Move this into another object store,
|
||||
* to improve read/write perf on purchases.
|
||||
*/
|
||||
download: ProposalDownload;
|
||||
download: ProposalDownload | 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;
|
||||
payInfo: PurchasePayInfo | undefined;
|
||||
|
||||
/**
|
||||
* Pending removals from pay coin selection.
|
||||
@ -1169,8 +1182,6 @@ export interface PurchaseRecord {
|
||||
*/
|
||||
pendingRemovedCoinPubs?: string[];
|
||||
|
||||
totalPayCost: AmountJson;
|
||||
|
||||
/**
|
||||
* Timestamp of the first time that sending a payment to the merchant
|
||||
* for this purchase was successful.
|
||||
@ -1181,11 +1192,16 @@ export interface PurchaseRecord {
|
||||
|
||||
merchantPaySig: string | undefined;
|
||||
|
||||
/**
|
||||
* When was the purchase record created?
|
||||
*/
|
||||
timestamp: TalerProtocolTimestamp;
|
||||
|
||||
/**
|
||||
* When was the purchase made?
|
||||
* Refers to the time that the user accepted.
|
||||
*/
|
||||
timestampAccept: TalerProtocolTimestamp;
|
||||
timestampAccept: TalerProtocolTimestamp | undefined;
|
||||
|
||||
/**
|
||||
* Pending refunds for the purchase. A refund is pending
|
||||
@ -1206,18 +1222,6 @@ export interface PurchaseRecord {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@ -1227,18 +1231,7 @@ export interface PurchaseRecord {
|
||||
* How much merchant has refund to be taken but the wallet
|
||||
* did not picked up yet
|
||||
*/
|
||||
refundAwaiting: 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;
|
||||
refundAmountAwaiting: AmountJson | undefined;
|
||||
}
|
||||
|
||||
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",
|
||||
describeContents<RefreshGroupRecord>({
|
||||
@ -1953,14 +1936,20 @@ export const WalletStoresV1 = {
|
||||
"purchases",
|
||||
describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
|
||||
{
|
||||
byStatus: describeIndex("byStatus", "operationStatus"),
|
||||
byFulfillmentUrl: describeIndex(
|
||||
"byFulfillmentUrl",
|
||||
"download.contractData.fulfillmentUrl",
|
||||
),
|
||||
// FIXME: Deduplicate!
|
||||
byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [
|
||||
"download.contractData.merchantBaseUrl",
|
||||
"download.contractData.orderId",
|
||||
]),
|
||||
byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
|
||||
"merchantBaseUrl",
|
||||
"orderId",
|
||||
]),
|
||||
},
|
||||
),
|
||||
tips: describeStore(
|
||||
|
@ -26,6 +26,9 @@
|
||||
* Imports.
|
||||
*/
|
||||
import {
|
||||
AbsoluteTime,
|
||||
AgeRestriction,
|
||||
AmountJson,
|
||||
Amounts,
|
||||
AmountString,
|
||||
codecForAny,
|
||||
@ -35,7 +38,6 @@ import {
|
||||
codecForExchangeRevealResponse,
|
||||
codecForWithdrawResponse,
|
||||
DenominationPubKey,
|
||||
eddsaGetPublic,
|
||||
encodeCrock,
|
||||
ExchangeMeltRequest,
|
||||
ExchangeProtocolVersion,
|
||||
@ -44,29 +46,15 @@ import {
|
||||
hashWire,
|
||||
Logger,
|
||||
parsePaytoUri,
|
||||
AbsoluteTime,
|
||||
UnblindedSignature,
|
||||
BankWithdrawDetails,
|
||||
parseWithdrawUri,
|
||||
AmountJson,
|
||||
AgeRestriction,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
|
||||
import { DenominationRecord } from "./db.js";
|
||||
import {
|
||||
assembleRefreshRevealRequest,
|
||||
ExchangeInfo,
|
||||
getBankWithdrawalInfo,
|
||||
HttpRequestLibrary,
|
||||
isWithdrawableDenom,
|
||||
readSuccessResponseJsonOrThrow,
|
||||
} from "./index.browser.js";
|
||||
import {
|
||||
BankAccessApi,
|
||||
BankApi,
|
||||
BankServiceHandle,
|
||||
getBankStatusUrl,
|
||||
} from "./index.js";
|
||||
import { BankAccessApi, BankApi, BankServiceHandle } from "./bank-api-client.js";
|
||||
import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from "./util/http.js";
|
||||
import { getBankStatusUrl, getBankWithdrawalInfo, isWithdrawableDenom } from "./operations/withdraw.js";
|
||||
import { ExchangeInfo } from "./operations/exchanges.js";
|
||||
import { assembleRefreshRevealRequest } from "./operations/refresh.js";
|
||||
|
||||
const logger = new Logger("dbless.ts");
|
||||
|
||||
|
@ -47,7 +47,6 @@ export * from "./wallet-api-types.js";
|
||||
export * from "./wallet.js";
|
||||
|
||||
export * from "./operations/backup/index.js";
|
||||
export { makeEventId } from "./operations/transactions.js";
|
||||
|
||||
export * from "./operations/exchanges.js";
|
||||
|
||||
|
@ -37,6 +37,9 @@ import {
|
||||
TalerProtocolTimestamp,
|
||||
CancellationToken,
|
||||
DenominationInfo,
|
||||
RefreshGroupId,
|
||||
CoinPublicKey,
|
||||
RefreshReason,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js";
|
||||
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
|
||||
@ -74,6 +77,20 @@ export interface MerchantOperations {
|
||||
): 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.
|
||||
*/
|
||||
@ -172,6 +189,7 @@ export interface InternalWalletState {
|
||||
exchangeOps: ExchangeOperations;
|
||||
recoupOps: RecoupOperations;
|
||||
merchantOps: MerchantOperations;
|
||||
refreshOps: RefreshOperations;
|
||||
|
||||
getDenomInfo(
|
||||
ws: InternalWalletState,
|
||||
|
@ -37,7 +37,7 @@ import {
|
||||
BackupExchangeDetails,
|
||||
BackupExchangeWireFee,
|
||||
BackupOperationStatus,
|
||||
BackupProposal,
|
||||
BackupPayInfo,
|
||||
BackupProposalStatus,
|
||||
BackupPurchase,
|
||||
BackupRecoupGroup,
|
||||
@ -62,11 +62,9 @@ import {
|
||||
WalletBackupContentV1,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import {
|
||||
AbortStatus,
|
||||
CoinSourceType,
|
||||
CoinStatus,
|
||||
DenominationRecord,
|
||||
OperationStatus,
|
||||
ProposalStatus,
|
||||
RefreshCoinStatus,
|
||||
RefundState,
|
||||
@ -92,7 +90,6 @@ export async function exportBackup(
|
||||
x.coins,
|
||||
x.denominations,
|
||||
x.purchases,
|
||||
x.proposals,
|
||||
x.refreshGroups,
|
||||
x.backupProviders,
|
||||
x.tips,
|
||||
@ -109,7 +106,6 @@ export async function exportBackup(
|
||||
[url: string]: BackupDenomination[];
|
||||
} = {};
|
||||
const backupPurchases: BackupPurchase[] = [];
|
||||
const backupProposals: BackupProposal[] = [];
|
||||
const backupRefreshGroups: BackupRefreshGroup[] = [];
|
||||
const backupBackupProviders: BackupBackupProvider[] = [];
|
||||
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;
|
||||
switch (prop.proposalStatus) {
|
||||
case ProposalStatus.Accepted:
|
||||
switch (purch.status) {
|
||||
case ProposalStatus.Paid:
|
||||
propStatus = BackupProposalStatus.Paid;
|
||||
return;
|
||||
case ProposalStatus.Downloading:
|
||||
case ProposalStatus.DownloadingProposal:
|
||||
case ProposalStatus.Proposed:
|
||||
propStatus = BackupProposalStatus.Proposed;
|
||||
break;
|
||||
case ProposalStatus.PermanentlyFailed:
|
||||
case ProposalStatus.ProposalDownloadFailed:
|
||||
propStatus = BackupProposalStatus.PermanentlyFailed;
|
||||
break;
|
||||
case ProposalStatus.Refused:
|
||||
case ProposalStatus.ProposalRefused:
|
||||
propStatus = BackupProposalStatus.Refused;
|
||||
break;
|
||||
case ProposalStatus.Repurchase:
|
||||
case ProposalStatus.RepurchaseDetected:
|
||||
propStatus = BackupProposalStatus.Repurchase;
|
||||
break;
|
||||
default:
|
||||
throw Error();
|
||||
}
|
||||
backupProposals.push({
|
||||
claim_token: prop.claimToken,
|
||||
nonce_priv: prop.noncePriv,
|
||||
proposal_id: prop.noncePriv,
|
||||
|
||||
const payInfo = purch.payInfo;
|
||||
let backupPayInfo: BackupPayInfo | undefined = undefined;
|
||||
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,
|
||||
repurchase_proposal_id: prop.repurchaseProposalId,
|
||||
timestamp: prop.timestamp,
|
||||
contract_terms_raw: prop.download?.contractTermsRaw,
|
||||
download_session_id: prop.downloadSessionId,
|
||||
merchant_base_url: prop.merchantBaseUrl,
|
||||
order_id: prop.orderId,
|
||||
merchant_sig: prop.download?.contractData.merchantSig,
|
||||
repurchase_proposal_id: purch.repurchaseProposalId,
|
||||
download_session_id: purch.downloadSessionId,
|
||||
timestamp_proposed: purch.timestamp,
|
||||
});
|
||||
});
|
||||
|
||||
@ -498,7 +490,6 @@ export async function exportBackup(
|
||||
wallet_root_pub: bs.walletRootPub,
|
||||
backup_providers: backupBackupProviders,
|
||||
current_device_id: bs.deviceId,
|
||||
proposals: backupProposals,
|
||||
purchases: backupPurchases,
|
||||
recoup_groups: backupRecoupGroups,
|
||||
refresh_groups: backupRefreshGroups,
|
||||
|
@ -21,8 +21,8 @@ import {
|
||||
BackupCoin,
|
||||
BackupCoinSourceType,
|
||||
BackupDenomSel,
|
||||
BackupPayInfo,
|
||||
BackupProposalStatus,
|
||||
BackupPurchase,
|
||||
BackupRefreshReason,
|
||||
BackupRefundState,
|
||||
BackupWgType,
|
||||
@ -37,7 +37,6 @@ import {
|
||||
WireInfo,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import {
|
||||
AbortStatus,
|
||||
CoinRecord,
|
||||
CoinSource,
|
||||
CoinSourceType,
|
||||
@ -48,28 +47,23 @@ import {
|
||||
OperationStatus,
|
||||
ProposalDownload,
|
||||
ProposalStatus,
|
||||
PurchasePayInfo,
|
||||
RefreshCoinStatus,
|
||||
RefreshSessionRecord,
|
||||
RefundState,
|
||||
ReserveBankInfo,
|
||||
WithdrawalGroupStatus,
|
||||
WalletContractData,
|
||||
WalletRefundItem,
|
||||
WalletStoresV1,
|
||||
WgInfo,
|
||||
WithdrawalGroupStatus,
|
||||
WithdrawalRecordType,
|
||||
} from "../../db.js";
|
||||
import { InternalWalletState } from "../../internal-wallet-state.js";
|
||||
import { assertUnreachable } from "../../util/assertUnreachable.js";
|
||||
import {
|
||||
checkDbInvariant,
|
||||
checkLogicInvariant,
|
||||
} from "../../util/invariants.js";
|
||||
import { checkLogicInvariant } from "../../util/invariants.js";
|
||||
import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
|
||||
import { RetryInfo } from "../../util/retries.js";
|
||||
import { makeCoinAvailable } from "../../wallet.js";
|
||||
import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js";
|
||||
import { getExchangeDetails } from "../exchanges.js";
|
||||
import { makeEventId, TombstoneTag } from "../transactions.js";
|
||||
import { provideBackupState } from "./state.js";
|
||||
|
||||
const logger = new Logger("operations/backup/import.ts");
|
||||
@ -95,10 +89,10 @@ async function recoverPayCoinSelection(
|
||||
denominations: typeof WalletStoresV1.denominations;
|
||||
}>,
|
||||
contractData: WalletContractData,
|
||||
backupPurchase: BackupPurchase,
|
||||
payInfo: BackupPayInfo,
|
||||
): Promise<PayCoinSelection> {
|
||||
const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
|
||||
const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
|
||||
const coinPubs: string[] = payInfo.pay_coins.map((x) => x.coin_pub);
|
||||
const coinContributions: AmountJson[] = payInfo.pay_coins.map((x) =>
|
||||
Amounts.parseOrThrow(x.contribution),
|
||||
);
|
||||
|
||||
@ -316,7 +310,6 @@ export async function importBackup(
|
||||
x.coinAvailability,
|
||||
x.denominations,
|
||||
x.purchases,
|
||||
x.proposals,
|
||||
x.refreshGroups,
|
||||
x.backupProviders,
|
||||
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) {
|
||||
const ts = makeEventId(
|
||||
TombstoneTag.DeletePayment,
|
||||
@ -678,6 +564,14 @@ export async function importBackup(
|
||||
const existingPurchase = await tx.purchases.get(
|
||||
backupPurchase.proposal_id,
|
||||
);
|
||||
let proposalStatus: ProposalStatus;
|
||||
switch (backupPurchase.proposal_status) {
|
||||
case BackupProposalStatus.Paid:
|
||||
proposalStatus = ProposalStatus.Paid;
|
||||
break;
|
||||
default:
|
||||
throw Error();
|
||||
}
|
||||
if (!existingPurchase) {
|
||||
const refunds: { [refundKey: string]: WalletRefundItem } = {};
|
||||
for (const backupRefund of backupPurchase.refunds) {
|
||||
@ -721,25 +615,6 @@ export async function importBackup(
|
||||
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(
|
||||
backupPurchase.contract_terms_raw,
|
||||
);
|
||||
@ -761,7 +636,7 @@ export async function importBackup(
|
||||
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
|
||||
merchantBaseUrl: parsedContractTerms.merchant_base_url,
|
||||
merchantPub: parsedContractTerms.merchant_pub,
|
||||
merchantSig: backupPurchase.merchant_sig,
|
||||
merchantSig: backupPurchase.merchant_sig!,
|
||||
orderId: parsedContractTerms.order_id,
|
||||
summary: parsedContractTerms.summary,
|
||||
autoRefund: parsedContractTerms.auto_refund,
|
||||
@ -790,33 +665,46 @@ export async function importBackup(
|
||||
},
|
||||
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({
|
||||
proposalId: backupPurchase.proposal_id,
|
||||
noncePriv: backupPurchase.nonce_priv,
|
||||
noncePub:
|
||||
cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
|
||||
autoRefundDeadline: TalerProtocolTimestamp.never(),
|
||||
refundAwaiting: undefined,
|
||||
timestampAccept: backupPurchase.timestamp_accept,
|
||||
timestampAccept: backupPurchase.timestamp_accepted,
|
||||
timestampFirstSuccessfulPay:
|
||||
backupPurchase.timestamp_first_successful_pay,
|
||||
timestampLastRefundStatus: undefined,
|
||||
merchantPaySig: backupPurchase.merchant_pay_sig,
|
||||
lastSessionId: undefined,
|
||||
abortStatus,
|
||||
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,
|
||||
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]);
|
||||
} else if (type === TombstoneTag.DeletePayment) {
|
||||
await tx.purchases.delete(rest[0]);
|
||||
await tx.proposals.delete(rest[0]);
|
||||
} else if (type === TombstoneTag.DeleteRefreshGroup) {
|
||||
await tx.refreshGroups.delete(rest[0]);
|
||||
} else if (type === TombstoneTag.DeleteRefund) {
|
||||
|
@ -96,7 +96,7 @@ import {
|
||||
checkPaymentByProposalId,
|
||||
confirmPay,
|
||||
preparePayForUri,
|
||||
} from "../pay.js";
|
||||
} from "../pay-merchant.js";
|
||||
import { exportBackup } from "./export.js";
|
||||
import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
|
||||
import { getWalletBackupState, provideBackupState } from "./state.js";
|
||||
@ -193,15 +193,6 @@ async function computeBackupCryptoData(
|
||||
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) {
|
||||
const { h: contractTermsHash } = await cryptoApi.hashString({
|
||||
str: canonicalJson(purch.contract_terms_raw),
|
||||
|
@ -17,38 +17,272 @@
|
||||
/**
|
||||
* Imports.
|
||||
*/
|
||||
import { TalerErrorDetail, TalerErrorCode } from "@gnu-taler/taler-util";
|
||||
import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js";
|
||||
import { TalerError, getErrorDetailFromException } from "../errors.js";
|
||||
import {
|
||||
AmountJson,
|
||||
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";
|
||||
|
||||
/**
|
||||
* Run an operation and call the onOpError callback
|
||||
* when there was an exception or operation error that must be reported.
|
||||
* The cause will be re-thrown to the caller.
|
||||
const logger = new Logger("operations/common.ts");
|
||||
|
||||
export interface CoinsSpendInfo {
|
||||
coinPubs: string[];
|
||||
contributions: AmountJson[];
|
||||
refreshReason: RefreshReason;
|
||||
/**
|
||||
* Identifier for what the coin has been spent for.
|
||||
*/
|
||||
export async function guardOperationException<T>(
|
||||
op: () => Promise<T>,
|
||||
onOpError: (e: TalerErrorDetail) => Promise<void>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await op();
|
||||
} catch (e: any) {
|
||||
if (e instanceof CryptoApiStoppedError) {
|
||||
throw e;
|
||||
allocationId: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (
|
||||
e instanceof TalerError &&
|
||||
e.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED)
|
||||
) {
|
||||
throw e;
|
||||
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,
|
||||
};
|
||||
}
|
||||
const opErr = getErrorDetailFromException(e);
|
||||
await onOpError(opErr);
|
||||
throw TalerError.fromDetail(
|
||||
TalerErrorCode.WALLET_PENDING_OPERATION_FAILED,
|
||||
{
|
||||
innerError: opErr,
|
||||
},
|
||||
car.freshCoinCount++;
|
||||
await tx.coins.put(coinRecord);
|
||||
await tx.coinAvailability.put(car);
|
||||
}
|
||||
|
||||
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 { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
||||
import { OperationAttemptResult } from "../util/retries.js";
|
||||
import { spendCoins } from "../wallet.js";
|
||||
import { makeEventId, spendCoins } from "./common.js";
|
||||
import { getExchangeDetails } from "./exchanges.js";
|
||||
import {
|
||||
extractContractData,
|
||||
generateDepositPermissions,
|
||||
getTotalPaymentCost,
|
||||
selectPayCoinsNew,
|
||||
} from "./pay.js";
|
||||
} from "./pay-merchant.js";
|
||||
import { getTotalRefreshCost } from "./refresh.js";
|
||||
import { makeEventId } from "./transactions.js";
|
||||
|
||||
/**
|
||||
* Logger.
|
||||
|
@ -40,7 +40,6 @@ import {
|
||||
parsePaytoUri,
|
||||
Recoup,
|
||||
TalerErrorCode,
|
||||
TalerErrorDetail,
|
||||
TalerProtocolDuration,
|
||||
TalerProtocolTimestamp,
|
||||
URL,
|
||||
@ -71,11 +70,9 @@ import {
|
||||
import {
|
||||
OperationAttemptResult,
|
||||
OperationAttemptResultType,
|
||||
RetryInfo,
|
||||
runOperationHandlerForResult,
|
||||
} from "../util/retries.js";
|
||||
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
|
||||
import { guardOperationException } from "./common.js";
|
||||
|
||||
const logger = new Logger("exchanges.ts");
|
||||
|
||||
|
@ -25,7 +25,7 @@ import {
|
||||
LibtoolVersion,
|
||||
} from "@gnu-taler/taler-util";
|
||||
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");
|
||||
|
||||
@ -40,7 +40,7 @@ export async function getMerchantInfo(
|
||||
return existingInfo;
|
||||
}
|
||||
|
||||
const configUrl = new URL("config", canonBaseUrl);
|
||||
const configUrl = new URL("config", canonBaseUrl);
|
||||
const resp = await ws.http.get(configUrl.href);
|
||||
|
||||
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 { checkDbInvariant } from "../util/invariants.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 { makeEventId } from "./transactions.js";
|
||||
import { internalCreateWithdrawalGroup } from "./withdraw.js";
|
||||
|
||||
const logger = new Logger("operations/peer-to-peer.ts");
|
@ -23,7 +23,6 @@
|
||||
*/
|
||||
import {
|
||||
ProposalStatus,
|
||||
AbortStatus,
|
||||
WalletStoresV1,
|
||||
BackupProviderStateTag,
|
||||
RefreshCoinStatus,
|
||||
@ -38,7 +37,6 @@ import { AbsoluteTime } from "@gnu-taler/taler-util";
|
||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||
import { GetReadOnlyAccess } from "../util/query.js";
|
||||
import { RetryTags } from "../util/retries.js";
|
||||
import { Wallet } from "../wallet.js";
|
||||
import { GlobalIDB } from "@gnu-taler/idb-bridge";
|
||||
|
||||
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(
|
||||
ws: InternalWalletState,
|
||||
tx: GetReadOnlyAccess<{
|
||||
@ -287,43 +253,26 @@ async function gatherPurchasePending(
|
||||
resp: PendingOperationsResponse,
|
||||
): Promise<void> {
|
||||
// FIXME: Only iter purchases with some "active" flag!
|
||||
await tx.purchases.iter().forEachAsync(async (pr) => {
|
||||
if (
|
||||
pr.paymentSubmitPending &&
|
||||
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,
|
||||
const keyRange = GlobalIDB.KeyRange.bound(
|
||||
OperationStatusRange.ACTIVE_START,
|
||||
OperationStatusRange.ACTIVE_END,
|
||||
);
|
||||
await tx.purchases.indexes.byStatus
|
||||
.iter(keyRange)
|
||||
.forEachAsync(async (pr) => {
|
||||
const opId = RetryTags.forPay(pr);
|
||||
const retryRecord = await tx.operationRetries.get(opId);
|
||||
const timestampDue =
|
||||
refundQueryRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
|
||||
retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
|
||||
resp.pendingOperations.push({
|
||||
type: PendingTaskType.RefundQuery,
|
||||
...getPendingCommon(ws, refundQueryOpId, timestampDue),
|
||||
type: PendingTaskType.Purchase,
|
||||
...getPendingCommon(ws, opId, timestampDue),
|
||||
givesLifeness: true,
|
||||
statusStr: ProposalStatus[pr.status],
|
||||
proposalId: pr.proposalId,
|
||||
retryInfo: refundQueryRetryRecord?.retryInfo,
|
||||
lastError: refundQueryRetryRecord?.lastError,
|
||||
retryInfo: retryRecord?.retryInfo,
|
||||
lastError: retryRecord?.lastError,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -404,7 +353,6 @@ export async function getPendingOperations(
|
||||
x.refreshGroups,
|
||||
x.coins,
|
||||
x.withdrawalGroups,
|
||||
x.proposals,
|
||||
x.tips,
|
||||
x.purchases,
|
||||
x.planchets,
|
||||
@ -419,7 +367,6 @@ export async function getPendingOperations(
|
||||
await gatherExchangePending(ws, tx, now, resp);
|
||||
await gatherRefreshPending(ws, tx, now, resp);
|
||||
await gatherWithdrawalPending(ws, tx, now, resp);
|
||||
await gatherProposalPending(ws, tx, now, resp);
|
||||
await gatherDepositPending(ws, tx, now, resp);
|
||||
await gatherTipPending(ws, tx, now, resp);
|
||||
await gatherPurchasePending(ws, tx, now, resp);
|
||||
|
@ -27,16 +27,15 @@
|
||||
import {
|
||||
Amounts,
|
||||
codecForRecoupConfirmation,
|
||||
codecForReserveStatus,
|
||||
encodeCrock,
|
||||
getRandomBytes,
|
||||
j2s,
|
||||
Logger,
|
||||
NotificationType,
|
||||
RefreshReason,
|
||||
TalerErrorDetail,
|
||||
TalerProtocolTimestamp,
|
||||
URL,
|
||||
codecForReserveStatus,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import {
|
||||
CoinRecord,
|
||||
@ -44,8 +43,8 @@ import {
|
||||
CoinStatus,
|
||||
RecoupGroupRecord,
|
||||
RefreshCoinSource,
|
||||
WithdrawalGroupStatus,
|
||||
WalletStoresV1,
|
||||
WithdrawalGroupStatus,
|
||||
WithdrawalRecordType,
|
||||
WithdrawCoinSource,
|
||||
} from "../db.js";
|
||||
@ -54,10 +53,8 @@ import { readSuccessResponseJsonOrThrow } from "../util/http.js";
|
||||
import { GetReadWriteAccess } from "../util/query.js";
|
||||
import {
|
||||
OperationAttemptResult,
|
||||
RetryInfo,
|
||||
runOperationHandlerForResult,
|
||||
} from "../util/retries.js";
|
||||
import { guardOperationException } from "./common.js";
|
||||
import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
|
||||
import { internalCreateWithdrawalGroup } from "./withdraw.js";
|
||||
|
||||
|
@ -78,7 +78,7 @@ import {
|
||||
OperationAttemptResult,
|
||||
OperationAttemptResultType,
|
||||
} from "../util/retries.js";
|
||||
import { makeCoinAvailable } from "../wallet.js";
|
||||
import { makeCoinAvailable } from "./common.js";
|
||||
import { updateExchangeFromUrl } from "./exchanges.js";
|
||||
import {
|
||||
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,
|
||||
} from "@gnu-taler/taler-util";
|
||||
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 { applyRefund } from "./refund.js";
|
||||
import { checkLogicInvariant } from "../util/invariants.js";
|
||||
import { acceptWithdrawalFromUri } from "./withdraw.js";
|
||||
|
||||
@ -471,6 +470,6 @@ export async function testPay(
|
||||
});
|
||||
checkLogicInvariant(!!purchase);
|
||||
return {
|
||||
payCoinSelection: purchase.payCoinSelection,
|
||||
payCoinSelection: purchase.payInfo?.payCoinSelection!,
|
||||
};
|
||||
}
|
||||
|
@ -18,8 +18,8 @@
|
||||
* Imports.
|
||||
*/
|
||||
import {
|
||||
AgeRestriction,
|
||||
AcceptTipResponse,
|
||||
AgeRestriction,
|
||||
Amounts,
|
||||
BlindedDenominationSignature,
|
||||
codecForMerchantTipResponseV2,
|
||||
@ -56,9 +56,8 @@ import {
|
||||
OperationAttemptResult,
|
||||
OperationAttemptResultType,
|
||||
} from "../util/retries.js";
|
||||
import { makeCoinAvailable } from "../wallet.js";
|
||||
import { makeCoinAvailable, makeEventId } from "./common.js";
|
||||
import { updateExchangeFromUrl } from "./exchanges.js";
|
||||
import { makeEventId } from "./transactions.js";
|
||||
import {
|
||||
getCandidateWithdrawalDenoms,
|
||||
getExchangeWithdrawalInfo,
|
||||
|
@ -36,12 +36,12 @@ import {
|
||||
WithdrawalType,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import {
|
||||
AbortStatus,
|
||||
DepositGroupRecord,
|
||||
ExchangeDetailsRecord,
|
||||
OperationRetryRecord,
|
||||
PeerPullPaymentIncomingRecord,
|
||||
PeerPushPaymentInitiationRecord,
|
||||
ProposalStatus,
|
||||
PurchaseRecord,
|
||||
RefundState,
|
||||
TipRecord,
|
||||
@ -50,10 +50,12 @@ import {
|
||||
WithdrawalRecordType,
|
||||
} from "../db.js";
|
||||
import { InternalWalletState } from "../internal-wallet-state.js";
|
||||
import { checkDbInvariant } from "../util/invariants.js";
|
||||
import { RetryTags } from "../util/retries.js";
|
||||
import { makeEventId, TombstoneTag } from "./common.js";
|
||||
import { processDepositGroup } from "./deposits.js";
|
||||
import { getExchangeDetails } from "./exchanges.js";
|
||||
import { processPurchasePay } from "./pay.js";
|
||||
import { expectProposalDownload, processPurchasePay } from "./pay-merchant.js";
|
||||
import { processRefreshGroup } from "./refresh.js";
|
||||
import { processTip } from "./tip.js";
|
||||
import {
|
||||
@ -63,28 +65,6 @@ import {
|
||||
|
||||
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(
|
||||
transactionsRequest: TransactionsRequest | undefined,
|
||||
currency: string,
|
||||
@ -219,29 +199,22 @@ export async function getTransactionById(
|
||||
}),
|
||||
);
|
||||
|
||||
const download = await expectProposalDownload(purchase);
|
||||
|
||||
const cleanRefunds = filteredRefunds.filter(
|
||||
(x): x is WalletRefundItem => !!x,
|
||||
);
|
||||
|
||||
const contractData = purchase.download.contractData;
|
||||
const contractData = download.contractData;
|
||||
const refunds = mergeRefundByExecutionTime(
|
||||
cleanRefunds,
|
||||
Amounts.getZero(contractData.amount.currency),
|
||||
);
|
||||
|
||||
const payOpId = RetryTags.forPay(purchase);
|
||||
const refundQueryOpId = RetryTags.forRefundQuery(purchase);
|
||||
const payRetryRecord = await tx.operationRetries.get(payOpId);
|
||||
const refundQueryRetryRecord = await tx.operationRetries.get(
|
||||
refundQueryOpId,
|
||||
);
|
||||
|
||||
const err =
|
||||
payRetryRecord !== undefined
|
||||
? payRetryRecord
|
||||
: refundQueryRetryRecord;
|
||||
|
||||
return buildTransactionForPurchase(purchase, refunds, err);
|
||||
return buildTransactionForPurchase(purchase, refunds, payRetryRecord);
|
||||
});
|
||||
} else if (type === TransactionType.Refresh) {
|
||||
const refreshGroupId = rest[0];
|
||||
@ -295,23 +268,14 @@ export async function getTransactionById(
|
||||
),
|
||||
);
|
||||
if (t) throw Error("deleted");
|
||||
|
||||
const contractData = purchase.download.contractData;
|
||||
const download = await expectProposalDownload(purchase);
|
||||
const contractData = download.contractData;
|
||||
const refunds = mergeRefundByExecutionTime(
|
||||
[theRefund],
|
||||
Amounts.getZero(contractData.amount.currency),
|
||||
);
|
||||
|
||||
const refundQueryOpId = RetryTags.forRefundQuery(purchase);
|
||||
const refundQueryRetryRecord = await tx.operationRetries.get(
|
||||
refundQueryOpId,
|
||||
);
|
||||
|
||||
return buildTransactionForRefund(
|
||||
purchase,
|
||||
refunds[0],
|
||||
refundQueryRetryRecord,
|
||||
);
|
||||
return buildTransactionForRefund(purchase, refunds[0], undefined);
|
||||
});
|
||||
} else if (type === TransactionType.PeerPullDebit) {
|
||||
const peerPullPaymentIncomingId = rest[0];
|
||||
@ -606,12 +570,13 @@ function mergeRefundByExecutionTime(
|
||||
return Array.from(refundByExecTime.values());
|
||||
}
|
||||
|
||||
function buildTransactionForRefund(
|
||||
async function buildTransactionForRefund(
|
||||
purchaseRecord: PurchaseRecord,
|
||||
refundInfo: MergedRefundInfo,
|
||||
ort?: OperationRetryRecord,
|
||||
): Transaction {
|
||||
const contractData = purchaseRecord.download.contractData;
|
||||
): Promise<Transaction> {
|
||||
const download = await expectProposalDownload(purchaseRecord);
|
||||
const contractData = download.contractData;
|
||||
|
||||
const info: OrderShortInfo = {
|
||||
merchant: contractData.merchant,
|
||||
@ -641,21 +606,22 @@ function buildTransactionForRefund(
|
||||
amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective),
|
||||
amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw),
|
||||
refundPending:
|
||||
purchaseRecord.refundAwaiting === undefined
|
||||
purchaseRecord.refundAmountAwaiting === undefined
|
||||
? undefined
|
||||
: Amounts.stringify(purchaseRecord.refundAwaiting),
|
||||
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
|
||||
pending: false,
|
||||
frozen: false,
|
||||
...(ort?.lastError ? { error: ort.lastError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTransactionForPurchase(
|
||||
async function buildTransactionForPurchase(
|
||||
purchaseRecord: PurchaseRecord,
|
||||
refundsInfo: MergedRefundInfo[],
|
||||
ort?: OperationRetryRecord,
|
||||
): Transaction {
|
||||
const contractData = purchaseRecord.download.contractData;
|
||||
): Promise<Transaction> {
|
||||
const download = await expectProposalDownload(purchaseRecord);
|
||||
const contractData = download.contractData;
|
||||
const zero = Amounts.getZero(contractData.amount.currency);
|
||||
|
||||
const info: OrderShortInfo = {
|
||||
@ -696,31 +662,34 @@ function buildTransactionForPurchase(
|
||||
),
|
||||
}));
|
||||
|
||||
const timestamp = purchaseRecord.timestampAccept;
|
||||
checkDbInvariant(!!timestamp);
|
||||
checkDbInvariant(!!purchaseRecord.payInfo);
|
||||
|
||||
return {
|
||||
type: TransactionType.Payment,
|
||||
amountRaw: Amounts.stringify(contractData.amount),
|
||||
amountEffective: Amounts.stringify(purchaseRecord.totalPayCost),
|
||||
amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
|
||||
totalRefundRaw: Amounts.stringify(totalRefund.raw),
|
||||
totalRefundEffective: Amounts.stringify(totalRefund.effective),
|
||||
refundPending:
|
||||
purchaseRecord.refundAwaiting === undefined
|
||||
purchaseRecord.refundAmountAwaiting === undefined
|
||||
? undefined
|
||||
: Amounts.stringify(purchaseRecord.refundAwaiting),
|
||||
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
|
||||
status: purchaseRecord.timestampFirstSuccessfulPay
|
||||
? PaymentStatus.Paid
|
||||
: PaymentStatus.Accepted,
|
||||
pending:
|
||||
!purchaseRecord.timestampFirstSuccessfulPay &&
|
||||
purchaseRecord.abortStatus === AbortStatus.None,
|
||||
pending: purchaseRecord.status === ProposalStatus.Paying,
|
||||
refunds,
|
||||
timestamp: purchaseRecord.timestampAccept,
|
||||
timestamp,
|
||||
transactionId: makeEventId(
|
||||
TransactionType.Payment,
|
||||
purchaseRecord.proposalId,
|
||||
),
|
||||
proposalId: purchaseRecord.proposalId,
|
||||
info,
|
||||
frozen: purchaseRecord.payFrozen ?? false,
|
||||
frozen:
|
||||
purchaseRecord.status === ProposalStatus.PaymentAbortFinished ?? false,
|
||||
...(ort?.lastError ? { error: ort.lastError } : {}),
|
||||
};
|
||||
}
|
||||
@ -745,7 +714,6 @@ export async function getTransactions(
|
||||
x.peerPullPaymentIncoming,
|
||||
x.peerPushPaymentInitiations,
|
||||
x.planchets,
|
||||
x.proposals,
|
||||
x.purchases,
|
||||
x.recoupGroups,
|
||||
x.tips,
|
||||
@ -838,30 +806,33 @@ export async function getTransactions(
|
||||
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 (
|
||||
shouldSkipCurrency(
|
||||
transactionsRequest,
|
||||
pr.download.contractData.amount.currency,
|
||||
download.contractData.amount.currency,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const contractData = pr.download.contractData;
|
||||
const contractData = download.contractData;
|
||||
if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
|
||||
return;
|
||||
}
|
||||
const proposal = await tx.proposals.get(pr.proposalId);
|
||||
if (!proposal) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
makeEventId(
|
||||
TombstoneTag.DeleteRefund,
|
||||
pr.proposalId,
|
||||
purchase.proposalId,
|
||||
`${r.executionTime.t_s}`,
|
||||
),
|
||||
);
|
||||
@ -880,29 +851,16 @@ export async function getTransactions(
|
||||
);
|
||||
|
||||
refunds.forEach(async (refundInfo) => {
|
||||
const refundQueryOpId = RetryTags.forRefundQuery(pr);
|
||||
const refundQueryRetryRecord = await tx.operationRetries.get(
|
||||
refundQueryOpId,
|
||||
);
|
||||
|
||||
transactions.push(
|
||||
buildTransactionForRefund(pr, refundInfo, refundQueryRetryRecord),
|
||||
await buildTransactionForRefund(purchase, refundInfo, undefined),
|
||||
);
|
||||
});
|
||||
|
||||
const payOpId = RetryTags.forPay(pr);
|
||||
const refundQueryOpId = RetryTags.forRefundQuery(pr);
|
||||
const payOpId = RetryTags.forPay(purchase);
|
||||
const payRetryRecord = await tx.operationRetries.get(payOpId);
|
||||
const refundQueryRetryRecord = await tx.operationRetries.get(
|
||||
refundQueryOpId,
|
||||
transactions.push(
|
||||
await buildTransactionForPurchase(purchase, refunds, payRetryRecord),
|
||||
);
|
||||
|
||||
const err =
|
||||
payRetryRecord !== undefined
|
||||
? payRetryRecord
|
||||
: refundQueryRetryRecord;
|
||||
|
||||
transactions.push(buildTransactionForPurchase(pr, refunds, err));
|
||||
});
|
||||
|
||||
tx.tips.iter().forEachAsync(async (tipRecord) => {
|
||||
@ -1020,14 +978,9 @@ export async function deleteTransaction(
|
||||
} else if (type === TransactionType.Payment) {
|
||||
const proposalId = rest[0];
|
||||
await ws.db
|
||||
.mktx((x) => [x.proposals, x.purchases, x.tombstones])
|
||||
.mktx((x) => [x.purchases, x.tombstones])
|
||||
.runReadWrite(async (tx) => {
|
||||
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);
|
||||
if (purchase) {
|
||||
found = true;
|
||||
@ -1083,7 +1036,7 @@ export async function deleteTransaction(
|
||||
const executionTimeStr = rest[1];
|
||||
|
||||
await ws.db
|
||||
.mktx((x) => [x.proposals, x.purchases, x.tombstones])
|
||||
.mktx((x) => [x.purchases, x.tombstones])
|
||||
.runReadWrite(async (tx) => {
|
||||
const purchase = await tx.purchases.get(proposalId);
|
||||
if (purchase) {
|
||||
|
@ -70,12 +70,11 @@ import {
|
||||
DenomSelectionState,
|
||||
ExchangeDetailsRecord,
|
||||
ExchangeRecord,
|
||||
OperationStatus,
|
||||
PlanchetRecord,
|
||||
WithdrawalGroupStatus,
|
||||
WalletStoresV1,
|
||||
WgInfo,
|
||||
WithdrawalGroupRecord,
|
||||
WithdrawalGroupStatus,
|
||||
WithdrawalRecordType,
|
||||
} from "../db.js";
|
||||
import {
|
||||
@ -84,7 +83,10 @@ import {
|
||||
TalerError,
|
||||
} from "../errors.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 {
|
||||
HttpRequestLibrary,
|
||||
@ -108,18 +110,16 @@ import {
|
||||
WALLET_EXCHANGE_PROTOCOL_VERSION,
|
||||
} from "../versions.js";
|
||||
import {
|
||||
makeCoinAvailable,
|
||||
runOperationWithErrorReporting,
|
||||
makeEventId,
|
||||
storeOperationError,
|
||||
storeOperationPending,
|
||||
} from "../wallet.js";
|
||||
} from "./common.js";
|
||||
import {
|
||||
getExchangeDetails,
|
||||
getExchangePaytoUri,
|
||||
getExchangeTrust,
|
||||
updateExchangeFromUrl,
|
||||
} from "./exchanges.js";
|
||||
import { makeEventId } from "./transactions.js";
|
||||
|
||||
/**
|
||||
* Logger for this file.
|
||||
|
@ -34,11 +34,9 @@ import { RetryInfo } from "./util/retries.js";
|
||||
export enum PendingTaskType {
|
||||
ExchangeUpdate = "exchange-update",
|
||||
ExchangeCheckRefresh = "exchange-check-refresh",
|
||||
Pay = "pay",
|
||||
ProposalDownload = "proposal-download",
|
||||
Purchase = "purchase",
|
||||
Refresh = "refresh",
|
||||
Recoup = "recoup",
|
||||
RefundQuery = "refund-query",
|
||||
TipPickup = "tip-pickup",
|
||||
Withdraw = "withdraw",
|
||||
Deposit = "deposit",
|
||||
@ -52,10 +50,8 @@ export type PendingTaskInfo = PendingTaskInfoCommon &
|
||||
(
|
||||
| PendingExchangeUpdateTask
|
||||
| PendingExchangeCheckRefreshTask
|
||||
| PendingPayTask
|
||||
| PendingProposalDownloadTask
|
||||
| PendingPurchaseTask
|
||||
| PendingRefreshTask
|
||||
| PendingRefundQueryTask
|
||||
| PendingTipPickupTask
|
||||
| PendingWithdrawTask
|
||||
| PendingRecoupTask
|
||||
@ -109,19 +105,6 @@ export interface PendingRefreshTask {
|
||||
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.
|
||||
*/
|
||||
@ -133,25 +116,16 @@ export interface PendingTipPickupTask {
|
||||
}
|
||||
|
||||
/**
|
||||
* The wallet is signing coins and then sending them to
|
||||
* the merchant.
|
||||
* A purchase needs to be processed (i.e. for download / payment / refund).
|
||||
*/
|
||||
export interface PendingPayTask {
|
||||
type: PendingTaskType.Pay;
|
||||
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;
|
||||
export interface PendingPurchaseTask {
|
||||
type: PendingTaskType.Purchase;
|
||||
proposalId: string;
|
||||
retryInfo?: RetryInfo;
|
||||
/**
|
||||
* Status of the payment as string, used only for debugging.
|
||||
*/
|
||||
statusStr: string;
|
||||
lastError: TalerErrorDetail | undefined;
|
||||
}
|
||||
|
||||
|
@ -30,7 +30,6 @@ import {
|
||||
BackupProviderRecord,
|
||||
DepositGroupRecord,
|
||||
ExchangeRecord,
|
||||
ProposalRecord,
|
||||
PurchaseRecord,
|
||||
RecoupGroupRecord,
|
||||
RefreshGroupRecord,
|
||||
@ -181,9 +180,6 @@ export namespace RetryTags {
|
||||
export function forExchangeCheckRefresh(exch: ExchangeRecord): string {
|
||||
return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}`;
|
||||
}
|
||||
export function forProposalClaim(pr: ProposalRecord): string {
|
||||
return `${PendingTaskType.ProposalDownload}:${pr.proposalId}`;
|
||||
}
|
||||
export function forTipPickup(tipRecord: TipRecord): string {
|
||||
return `${PendingTaskType.TipPickup}:${tipRecord.walletTipId}`;
|
||||
}
|
||||
@ -191,10 +187,7 @@ export namespace RetryTags {
|
||||
return `${PendingTaskType.TipPickup}:${refreshGroupRecord.refreshGroupId}`;
|
||||
}
|
||||
export function forPay(purchaseRecord: PurchaseRecord): string {
|
||||
return `${PendingTaskType.Pay}:${purchaseRecord.proposalId}`;
|
||||
}
|
||||
export function forRefundQuery(purchaseRecord: PurchaseRecord): string {
|
||||
return `${PendingTaskType.RefundQuery}:${purchaseRecord.proposalId}`;
|
||||
return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}`;
|
||||
}
|
||||
export function forRecoup(recoupRecord: RecoupGroupRecord): string {
|
||||
return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}`;
|
||||
@ -206,7 +199,7 @@ export namespace RetryTags {
|
||||
return `${PendingTaskType.Backup}:${backupRecord.baseUrl}`;
|
||||
}
|
||||
export function byPaymentProposalId(proposalId: string): string {
|
||||
return `${PendingTaskType.Pay}:${proposalId}`;
|
||||
return `${PendingTaskType.Purchase}:${proposalId}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -26,7 +26,6 @@ import {
|
||||
AbsoluteTime,
|
||||
AmountJson,
|
||||
Amounts,
|
||||
BalancesResponse,
|
||||
codecForAbortPayWithRefundRequest,
|
||||
codecForAcceptBankIntegratedWithdrawalRequest,
|
||||
codecForAcceptExchangeTosRequest,
|
||||
@ -35,6 +34,7 @@ import {
|
||||
codecForAcceptPeerPushPaymentRequest,
|
||||
codecForAcceptTipRequest,
|
||||
codecForAddExchangeRequest,
|
||||
codecForAddKnownBankAccounts,
|
||||
codecForAny,
|
||||
codecForApplyRefundFromPurchaseIdRequest,
|
||||
codecForApplyRefundRequest,
|
||||
@ -44,6 +44,7 @@ import {
|
||||
codecForCreateDepositGroupRequest,
|
||||
codecForDeleteTransactionRequest,
|
||||
codecForForceRefreshRequest,
|
||||
codecForForgetKnownBankAccounts,
|
||||
codecForGetContractTermsDetails,
|
||||
codecForGetExchangeTosRequest,
|
||||
codecForGetExchangeWithdrawalInfo,
|
||||
@ -81,6 +82,7 @@ import {
|
||||
GetExchangeTosResult,
|
||||
j2s,
|
||||
KnownBankAccounts,
|
||||
KnownBankAccountsInfo,
|
||||
Logger,
|
||||
ManualWithdrawalDetails,
|
||||
NotificationType,
|
||||
@ -89,9 +91,6 @@ import {
|
||||
RefreshReason,
|
||||
TalerErrorCode,
|
||||
TalerErrorDetail,
|
||||
KnownBankAccountsInfo,
|
||||
codecForAddKnownBankAccounts,
|
||||
codecForForgetKnownBankAccounts,
|
||||
URL,
|
||||
WalletCoreVersion,
|
||||
WalletNotification,
|
||||
@ -125,6 +124,7 @@ import {
|
||||
MerchantOperations,
|
||||
NotificationListener,
|
||||
RecoupOperations,
|
||||
RefreshOperations,
|
||||
} from "./internal-wallet-state.js";
|
||||
import { exportBackup } from "./operations/backup/export.js";
|
||||
import {
|
||||
@ -142,6 +142,11 @@ import {
|
||||
} from "./operations/backup/index.js";
|
||||
import { setWalletDeviceId } from "./operations/backup/state.js";
|
||||
import { getBalances } from "./operations/balance.js";
|
||||
import {
|
||||
runOperationWithErrorReporting,
|
||||
storeOperationError,
|
||||
storeOperationPending,
|
||||
} from "./operations/common.js";
|
||||
import {
|
||||
createDepositGroup,
|
||||
getFeeForDeposit,
|
||||
@ -162,12 +167,15 @@ import {
|
||||
} from "./operations/exchanges.js";
|
||||
import { getMerchantInfo } from "./operations/merchants.js";
|
||||
import {
|
||||
abortFailedPayWithRefund,
|
||||
applyRefund,
|
||||
applyRefundFromPurchaseId,
|
||||
confirmPay,
|
||||
getContractTermsDetails,
|
||||
preparePayForUri,
|
||||
processDownloadProposal,
|
||||
processPurchasePay,
|
||||
} from "./operations/pay.js";
|
||||
prepareRefund,
|
||||
processPurchase,
|
||||
} from "./operations/pay-merchant.js";
|
||||
import {
|
||||
acceptPeerPullPayment,
|
||||
acceptPeerPushPayment,
|
||||
@ -175,7 +183,7 @@ import {
|
||||
checkPeerPushPayment,
|
||||
initiatePeerRequestForPay,
|
||||
initiatePeerToPeerPush,
|
||||
} from "./operations/peer-to-peer.js";
|
||||
} from "./operations/pay-peer.js";
|
||||
import { getPendingOperations } from "./operations/pending.js";
|
||||
import {
|
||||
createRecoupGroup,
|
||||
@ -187,13 +195,6 @@ import {
|
||||
createRefreshGroup,
|
||||
processRefreshGroup,
|
||||
} from "./operations/refresh.js";
|
||||
import {
|
||||
abortFailedPayWithRefund,
|
||||
applyRefund,
|
||||
applyRefundFromPurchaseId,
|
||||
prepareRefund,
|
||||
processPurchaseQueryRefund,
|
||||
} from "./operations/refund.js";
|
||||
import {
|
||||
runIntegrationTest,
|
||||
testPay,
|
||||
@ -213,13 +214,8 @@ import {
|
||||
getWithdrawalDetailsForUri,
|
||||
processWithdrawalGroup,
|
||||
} from "./operations/withdraw.js";
|
||||
import {
|
||||
PendingOperationsResponse,
|
||||
PendingTaskInfo,
|
||||
PendingTaskType,
|
||||
} from "./pending-types.js";
|
||||
import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
|
||||
import { assertUnreachable } from "./util/assertUnreachable.js";
|
||||
import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
|
||||
import { createDenominationTimeline } from "./util/denominations.js";
|
||||
import {
|
||||
HttpRequestLibrary,
|
||||
@ -306,18 +302,10 @@ async function callOperationHandler(
|
||||
return await processWithdrawalGroup(ws, pending.withdrawalGroupId, {
|
||||
forceNow,
|
||||
});
|
||||
case PendingTaskType.ProposalDownload:
|
||||
return await processDownloadProposal(ws, pending.proposalId, {
|
||||
forceNow,
|
||||
});
|
||||
case PendingTaskType.TipPickup:
|
||||
return await processTip(ws, pending.tipId, { forceNow });
|
||||
case PendingTaskType.Pay:
|
||||
return await processPurchasePay(ws, pending.proposalId, { forceNow });
|
||||
case PendingTaskType.RefundQuery:
|
||||
return await processPurchaseQueryRefund(ws, pending.proposalId, {
|
||||
forceNow,
|
||||
});
|
||||
case PendingTaskType.Purchase:
|
||||
return await processPurchase(ws, pending.proposalId, { forceNow });
|
||||
case PendingTaskType.Recoup:
|
||||
return await processRecoupGroupHandler(ws, pending.recoupGroupId, {
|
||||
forceNow,
|
||||
@ -337,111 +325,6 @@ async function callOperationHandler(
|
||||
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.
|
||||
*/
|
||||
@ -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(
|
||||
ws: InternalWalletState,
|
||||
coinPub: string,
|
||||
@ -1649,6 +1418,10 @@ class InternalWalletStateImpl implements InternalWalletState {
|
||||
getMerchantInfo,
|
||||
};
|
||||
|
||||
refreshOps: RefreshOperations = {
|
||||
createRefreshGroup,
|
||||
};
|
||||
|
||||
// FIXME: Use an LRU cache here.
|
||||
private denomCache: Record<string, DenominationInfo> = {};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user