wallet-core: handle Gone in peer-pull-debit
This commit is contained in:
parent
bdb67c83a9
commit
da927b5e48
@ -2052,6 +2052,8 @@ export interface PeerPullPaymentIncomingRecord {
|
|||||||
*/
|
*/
|
||||||
totalCostEstimated: AmountString;
|
totalCostEstimated: AmountString;
|
||||||
|
|
||||||
|
abortRefreshGroupId?: string;
|
||||||
|
|
||||||
coinSel?: PeerPullPaymentCoinSelection;
|
coinSel?: PeerPullPaymentCoinSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,13 +134,6 @@ export interface RecoupOperations {
|
|||||||
exchangeBaseUrl: string,
|
exchangeBaseUrl: string,
|
||||||
coinPubs: string[],
|
coinPubs: string[],
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
processRecoupGroup(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
recoupGroupId: string,
|
|
||||||
options?: {
|
|
||||||
forceNow?: boolean;
|
|
||||||
},
|
|
||||||
): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationListener = (n: WalletNotification) => void;
|
export type NotificationListener = (n: WalletNotification) => void;
|
||||||
|
@ -863,9 +863,7 @@ export async function updateExchangeFromUrlHandler(
|
|||||||
if (recoupGroupId) {
|
if (recoupGroupId) {
|
||||||
// Asynchronously start recoup. This doesn't need to finish
|
// Asynchronously start recoup. This doesn't need to finish
|
||||||
// for the exchange update to be considered finished.
|
// for the exchange update to be considered finished.
|
||||||
ws.recoupOps.processRecoupGroup(ws, recoupGroupId).catch((e) => {
|
ws.workAvailable.trigger();
|
||||||
logger.error("error while recouping coins:", e);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
@ -28,6 +28,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
TalerErrorCode,
|
TalerErrorCode,
|
||||||
TalerPreciseTimestamp,
|
TalerPreciseTimestamp,
|
||||||
|
TalerUriAction,
|
||||||
TransactionAction,
|
TransactionAction,
|
||||||
TransactionMajorState,
|
TransactionMajorState,
|
||||||
TransactionMinorState,
|
TransactionMinorState,
|
||||||
@ -37,11 +38,11 @@ import {
|
|||||||
WalletKycUuid,
|
WalletKycUuid,
|
||||||
codecForAny,
|
codecForAny,
|
||||||
codecForWalletKycUuid,
|
codecForWalletKycUuid,
|
||||||
constructPayPullUri,
|
|
||||||
encodeCrock,
|
encodeCrock,
|
||||||
getRandomBytes,
|
getRandomBytes,
|
||||||
j2s,
|
j2s,
|
||||||
makeErrorDetail,
|
makeErrorDetail,
|
||||||
|
stringifyTalerUri,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import {
|
import {
|
||||||
readSuccessResponseJsonOrErrorCode,
|
readSuccessResponseJsonOrErrorCode,
|
||||||
@ -741,7 +742,8 @@ export async function initiatePeerPullPayment(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
talerUri: constructPayPullUri({
|
talerUri: stringifyTalerUri({
|
||||||
|
type: TalerUriAction.PayPull,
|
||||||
exchangeBaseUrl: exchangeBaseUrl,
|
exchangeBaseUrl: exchangeBaseUrl,
|
||||||
contractPriv: contractKeyPair.priv,
|
contractPriv: contractKeyPair.priv,
|
||||||
}),
|
}),
|
||||||
|
@ -17,8 +17,10 @@
|
|||||||
import {
|
import {
|
||||||
AcceptPeerPullPaymentResponse,
|
AcceptPeerPullPaymentResponse,
|
||||||
Amounts,
|
Amounts,
|
||||||
|
CoinRefreshRequest,
|
||||||
ConfirmPeerPullDebitRequest,
|
ConfirmPeerPullDebitRequest,
|
||||||
ExchangePurseDeposits,
|
ExchangePurseDeposits,
|
||||||
|
HttpStatusCode,
|
||||||
Logger,
|
Logger,
|
||||||
PeerContractTerms,
|
PeerContractTerms,
|
||||||
PreparePeerPullDebitRequest,
|
PreparePeerPullDebitRequest,
|
||||||
@ -48,6 +50,8 @@ import {
|
|||||||
PeerPullDebitRecordStatus,
|
PeerPullDebitRecordStatus,
|
||||||
PeerPullPaymentIncomingRecord,
|
PeerPullPaymentIncomingRecord,
|
||||||
PendingTaskType,
|
PendingTaskType,
|
||||||
|
RefreshOperationStatus,
|
||||||
|
createRefreshGroup,
|
||||||
} from "../index.js";
|
} from "../index.js";
|
||||||
import { assertUnreachable } from "../util/assertUnreachable.js";
|
import { assertUnreachable } from "../util/assertUnreachable.js";
|
||||||
import {
|
import {
|
||||||
@ -68,6 +72,7 @@ import {
|
|||||||
notifyTransition,
|
notifyTransition,
|
||||||
stopLongpolling,
|
stopLongpolling,
|
||||||
} from "./transactions.js";
|
} from "./transactions.js";
|
||||||
|
import { checkLogicInvariant } from "../util/invariants.js";
|
||||||
|
|
||||||
const logger = new Logger("pay-peer-pull-debit.ts");
|
const logger = new Logger("pay-peer-pull-debit.ts");
|
||||||
|
|
||||||
@ -104,24 +109,89 @@ async function processPeerPullDebitPendingDeposit(
|
|||||||
logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
|
logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const httpResp = await ws.http.postJson(purseDepositUrl.href, depositPayload);
|
const transactionId = constructTransactionIdentifier({
|
||||||
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
|
tag: TransactionType.PeerPullDebit,
|
||||||
logger.trace(`purse deposit response: ${j2s(resp)}`);
|
peerPullPaymentIncomingId,
|
||||||
|
});
|
||||||
|
|
||||||
await ws.db
|
const httpResp = await ws.http.fetch(purseDepositUrl.href, {
|
||||||
.mktx((x) => [x.peerPullPaymentIncoming])
|
method: "POST",
|
||||||
.runReadWrite(async (tx) => {
|
body: depositPayload,
|
||||||
const pi = await tx.peerPullPaymentIncoming.get(
|
});
|
||||||
peerPullPaymentIncomingId,
|
if (httpResp.status === HttpStatusCode.Gone) {
|
||||||
);
|
const transitionInfo = await ws.db
|
||||||
if (!pi) {
|
.mktx((x) => [
|
||||||
throw Error("peer pull payment not found anymore");
|
x.peerPullPaymentIncoming,
|
||||||
}
|
x.refreshGroups,
|
||||||
if (pi.status === PeerPullDebitRecordStatus.PendingDeposit) {
|
x.denominations,
|
||||||
|
x.coinAvailability,
|
||||||
|
x.coins,
|
||||||
|
])
|
||||||
|
.runReadWrite(async (tx) => {
|
||||||
|
const pi = await tx.peerPullPaymentIncoming.get(
|
||||||
|
peerPullPaymentIncomingId,
|
||||||
|
);
|
||||||
|
if (!pi) {
|
||||||
|
throw Error("peer pull payment not found anymore");
|
||||||
|
}
|
||||||
|
if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldTxState = computePeerPullDebitTransactionState(pi);
|
||||||
|
|
||||||
|
const currency = Amounts.currencyOf(pi.totalCostEstimated);
|
||||||
|
const coinPubs: CoinRefreshRequest[] = [];
|
||||||
|
|
||||||
|
if (!pi.coinSel) {
|
||||||
|
throw Error("invalid db state");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
|
||||||
|
coinPubs.push({
|
||||||
|
amount: pi.coinSel.contributions[i],
|
||||||
|
coinPub: pi.coinSel.coinPubs[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = await createRefreshGroup(
|
||||||
|
ws,
|
||||||
|
tx,
|
||||||
|
currency,
|
||||||
|
coinPubs,
|
||||||
|
RefreshReason.AbortPeerPushDebit,
|
||||||
|
);
|
||||||
|
|
||||||
|
pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
|
||||||
|
pi.abortRefreshGroupId = refresh.refreshGroupId;
|
||||||
|
const newTxState = computePeerPullDebitTransactionState(pi);
|
||||||
|
await tx.peerPullPaymentIncoming.put(pi);
|
||||||
|
return { oldTxState, newTxState };
|
||||||
|
});
|
||||||
|
notifyTransition(ws, transactionId, transitionInfo);
|
||||||
|
} else {
|
||||||
|
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
|
||||||
|
logger.trace(`purse deposit response: ${j2s(resp)}`);
|
||||||
|
|
||||||
|
const transitionInfo = await ws.db
|
||||||
|
.mktx((x) => [x.peerPullPaymentIncoming])
|
||||||
|
.runReadWrite(async (tx) => {
|
||||||
|
const pi = await tx.peerPullPaymentIncoming.get(
|
||||||
|
peerPullPaymentIncomingId,
|
||||||
|
);
|
||||||
|
if (!pi) {
|
||||||
|
throw Error("peer pull payment not found anymore");
|
||||||
|
}
|
||||||
|
if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldTxState = computePeerPullDebitTransactionState(pi);
|
||||||
pi.status = PeerPullDebitRecordStatus.DonePaid;
|
pi.status = PeerPullDebitRecordStatus.DonePaid;
|
||||||
}
|
const newTxState = computePeerPullDebitTransactionState(pi);
|
||||||
await tx.peerPullPaymentIncoming.put(pi);
|
await tx.peerPullPaymentIncoming.put(pi);
|
||||||
});
|
return { oldTxState, newTxState };
|
||||||
|
});
|
||||||
|
notifyTransition(ws, transactionId, transitionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: OperationAttemptResultType.Finished,
|
type: OperationAttemptResultType.Finished,
|
||||||
@ -133,7 +203,50 @@ async function processPeerPullDebitAbortingRefresh(
|
|||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
peerPullInc: PeerPullPaymentIncomingRecord,
|
peerPullInc: PeerPullPaymentIncomingRecord,
|
||||||
): Promise<OperationAttemptResult> {
|
): Promise<OperationAttemptResult> {
|
||||||
throw Error("not implemented");
|
const peerPullPaymentIncomingId = peerPullInc.peerPullPaymentIncomingId;
|
||||||
|
const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
|
||||||
|
checkLogicInvariant(!!abortRefreshGroupId);
|
||||||
|
const transactionId = constructTransactionIdentifier({
|
||||||
|
tag: TransactionType.PeerPullDebit,
|
||||||
|
peerPullPaymentIncomingId,
|
||||||
|
});
|
||||||
|
const transitionInfo = await ws.db
|
||||||
|
.mktx((x) => [x.refreshGroups, x.peerPullPaymentIncoming])
|
||||||
|
.runReadWrite(async (tx) => {
|
||||||
|
const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
|
||||||
|
let newOpState: PeerPullDebitRecordStatus | undefined;
|
||||||
|
if (!refreshGroup) {
|
||||||
|
// Maybe it got manually deleted? Means that we should
|
||||||
|
// just go into failed.
|
||||||
|
logger.warn("no aborting refresh group found for deposit group");
|
||||||
|
newOpState = PeerPullDebitRecordStatus.Failed;
|
||||||
|
} else {
|
||||||
|
if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
|
||||||
|
newOpState = PeerPullDebitRecordStatus.Aborted;
|
||||||
|
} else if (
|
||||||
|
refreshGroup.operationStatus === RefreshOperationStatus.Failed
|
||||||
|
) {
|
||||||
|
newOpState = PeerPullDebitRecordStatus.Failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newOpState) {
|
||||||
|
const newDg = await tx.peerPullPaymentIncoming.get(
|
||||||
|
peerPullPaymentIncomingId,
|
||||||
|
);
|
||||||
|
if (!newDg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldTxState = computePeerPullDebitTransactionState(newDg);
|
||||||
|
newDg.status = newOpState;
|
||||||
|
const newTxState = computePeerPullDebitTransactionState(newDg);
|
||||||
|
await tx.peerPullPaymentIncoming.put(newDg);
|
||||||
|
return { oldTxState, newTxState };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
notifyTransition(ws, transactionId, transitionInfo);
|
||||||
|
// FIXME: Shouldn't this be finished in some cases?!
|
||||||
|
return OperationAttemptResult.pendingEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processPeerPullDebit(
|
export async function processPeerPullDebit(
|
||||||
@ -158,7 +271,7 @@ export async function processPeerPullDebit(
|
|||||||
return {
|
return {
|
||||||
type: OperationAttemptResultType.Finished,
|
type: OperationAttemptResultType.Finished,
|
||||||
result: undefined,
|
result: undefined,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmPeerPullDebit(
|
export async function confirmPeerPullDebit(
|
||||||
|
@ -304,24 +304,7 @@ async function recoupRefreshCoin(
|
|||||||
export async function processRecoupGroup(
|
export async function processRecoupGroup(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
recoupGroupId: string,
|
recoupGroupId: string,
|
||||||
options: {
|
|
||||||
forceNow?: boolean;
|
|
||||||
} = {},
|
|
||||||
): Promise<void> {
|
|
||||||
await unwrapOperationHandlerResultOrThrow(
|
|
||||||
await processRecoupGroupHandler(ws, recoupGroupId, options),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processRecoupGroupHandler(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
recoupGroupId: string,
|
|
||||||
options: {
|
|
||||||
forceNow?: boolean;
|
|
||||||
} = {},
|
|
||||||
): Promise<OperationAttemptResult> {
|
): Promise<OperationAttemptResult> {
|
||||||
const forceNow = options.forceNow ?? false;
|
|
||||||
let recoupGroup = await ws.db
|
let recoupGroup = await ws.db
|
||||||
.mktx((x) => [x.recoupGroups])
|
.mktx((x) => [x.recoupGroups])
|
||||||
.runReadOnly(async (tx) => {
|
.runReadOnly(async (tx) => {
|
||||||
|
@ -1273,7 +1273,6 @@ export interface WithdrawalGroupContext {
|
|||||||
export async function processWithdrawalGroup(
|
export async function processWithdrawalGroup(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
withdrawalGroupId: string,
|
withdrawalGroupId: string,
|
||||||
options: {} = {},
|
|
||||||
): Promise<OperationAttemptResult> {
|
): Promise<OperationAttemptResult> {
|
||||||
logger.trace("processing withdrawal group", withdrawalGroupId);
|
logger.trace("processing withdrawal group", withdrawalGroupId);
|
||||||
const withdrawalGroup = await ws.db
|
const withdrawalGroup = await ws.db
|
||||||
@ -1303,9 +1302,8 @@ export async function processWithdrawalGroup(
|
|||||||
switch (withdrawalGroup.status) {
|
switch (withdrawalGroup.status) {
|
||||||
case WithdrawalGroupStatus.PendingRegisteringBank:
|
case WithdrawalGroupStatus.PendingRegisteringBank:
|
||||||
await processReserveBankStatus(ws, withdrawalGroupId);
|
await processReserveBankStatus(ws, withdrawalGroupId);
|
||||||
return await processWithdrawalGroup(ws, withdrawalGroupId, {
|
// FIXME: This will get called by the main task loop, why call it here?!
|
||||||
forceNow: true,
|
return await processWithdrawalGroup(ws, withdrawalGroupId);
|
||||||
});
|
|
||||||
case WithdrawalGroupStatus.PendingQueryingStatus: {
|
case WithdrawalGroupStatus.PendingQueryingStatus: {
|
||||||
runLongpollAsync(ws, retryTag, (ct) => {
|
runLongpollAsync(ws, retryTag, (ct) => {
|
||||||
return queryReserve(ws, withdrawalGroupId, ct);
|
return queryReserve(ws, withdrawalGroupId, ct);
|
||||||
|
@ -219,9 +219,7 @@ import {
|
|||||||
} from "./operations/pay-peer-push-debit.js";
|
} from "./operations/pay-peer-push-debit.js";
|
||||||
import { getPendingOperations } from "./operations/pending.js";
|
import { getPendingOperations } from "./operations/pending.js";
|
||||||
import {
|
import {
|
||||||
createRecoupGroup,
|
createRecoupGroup, processRecoupGroup,
|
||||||
processRecoupGroup,
|
|
||||||
processRecoupGroupHandler,
|
|
||||||
} from "./operations/recoup.js";
|
} from "./operations/recoup.js";
|
||||||
import {
|
import {
|
||||||
autoRefresh,
|
autoRefresh,
|
||||||
@ -295,27 +293,20 @@ const logger = new Logger("wallet.ts");
|
|||||||
async function callOperationHandler(
|
async function callOperationHandler(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
pending: PendingTaskInfo,
|
pending: PendingTaskInfo,
|
||||||
forceNow = false,
|
|
||||||
): Promise<OperationAttemptResult> {
|
): Promise<OperationAttemptResult> {
|
||||||
switch (pending.type) {
|
switch (pending.type) {
|
||||||
case PendingTaskType.ExchangeUpdate:
|
case PendingTaskType.ExchangeUpdate:
|
||||||
return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl, {
|
return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl);
|
||||||
forceNow,
|
|
||||||
});
|
|
||||||
case PendingTaskType.Refresh:
|
case PendingTaskType.Refresh:
|
||||||
return await processRefreshGroup(ws, pending.refreshGroupId);
|
return await processRefreshGroup(ws, pending.refreshGroupId);
|
||||||
case PendingTaskType.Withdraw:
|
case PendingTaskType.Withdraw:
|
||||||
return await processWithdrawalGroup(ws, pending.withdrawalGroupId, {
|
return await processWithdrawalGroup(ws, pending.withdrawalGroupId);
|
||||||
forceNow,
|
|
||||||
});
|
|
||||||
case PendingTaskType.TipPickup:
|
case PendingTaskType.TipPickup:
|
||||||
return await processTip(ws, pending.tipId);
|
return await processTip(ws, pending.tipId);
|
||||||
case PendingTaskType.Purchase:
|
case PendingTaskType.Purchase:
|
||||||
return await processPurchase(ws, pending.proposalId);
|
return await processPurchase(ws, pending.proposalId);
|
||||||
case PendingTaskType.Recoup:
|
case PendingTaskType.Recoup:
|
||||||
return await processRecoupGroupHandler(ws, pending.recoupGroupId, {
|
return await processRecoupGroup(ws, pending.recoupGroupId);
|
||||||
forceNow,
|
|
||||||
});
|
|
||||||
case PendingTaskType.ExchangeCheckRefresh:
|
case PendingTaskType.ExchangeCheckRefresh:
|
||||||
return await autoRefresh(ws, pending.exchangeBaseUrl);
|
return await autoRefresh(ws, pending.exchangeBaseUrl);
|
||||||
case PendingTaskType.Deposit: {
|
case PendingTaskType.Deposit: {
|
||||||
@ -342,16 +333,15 @@ async function callOperationHandler(
|
|||||||
*/
|
*/
|
||||||
export async function runPending(
|
export async function runPending(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
forceNow = false,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const pendingOpsResponse = await getPendingOperations(ws);
|
const pendingOpsResponse = await getPendingOperations(ws);
|
||||||
for (const p of pendingOpsResponse.pendingOperations) {
|
for (const p of pendingOpsResponse.pendingOperations) {
|
||||||
if (!forceNow && !AbsoluteTime.isExpired(p.timestampDue)) {
|
if (!AbsoluteTime.isExpired(p.timestampDue)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await runOperationWithErrorReporting(ws, p.id, async () => {
|
await runOperationWithErrorReporting(ws, p.id, async () => {
|
||||||
logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`);
|
logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`);
|
||||||
return await callOperationHandler(ws, p, forceNow);
|
return await callOperationHandler(ws, p);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1168,7 +1158,8 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
|
|||||||
return getContractTermsDetails(ws, req.proposalId);
|
return getContractTermsDetails(ws, req.proposalId);
|
||||||
}
|
}
|
||||||
case WalletApiOperation.RetryPendingNow: {
|
case WalletApiOperation.RetryPendingNow: {
|
||||||
await runPending(ws, true);
|
// FIXME: Should we reset all operation retries here?
|
||||||
|
await runPending(ws);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case WalletApiOperation.PreparePayForUri: {
|
case WalletApiOperation.PreparePayForUri: {
|
||||||
@ -1624,8 +1615,8 @@ export class Wallet {
|
|||||||
this.ws.stop();
|
this.ws.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
runPending(forceNow = false): Promise<void> {
|
runPending(): Promise<void> {
|
||||||
return runPending(this.ws, forceNow);
|
return runPending(this.ws);
|
||||||
}
|
}
|
||||||
|
|
||||||
runTaskLoop(opts?: RetryLoopOpts): Promise<TaskLoopResult> {
|
runTaskLoop(opts?: RetryLoopOpts): Promise<TaskLoopResult> {
|
||||||
@ -1673,7 +1664,6 @@ class InternalWalletStateImpl implements InternalWalletState {
|
|||||||
|
|
||||||
recoupOps: RecoupOperations = {
|
recoupOps: RecoupOperations = {
|
||||||
createRecoupGroup,
|
createRecoupGroup,
|
||||||
processRecoupGroup,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
merchantOps: MerchantOperations = {
|
merchantOps: MerchantOperations = {
|
||||||
|
Loading…
Reference in New Issue
Block a user