Skip to content
Merged
32 changes: 31 additions & 1 deletion src/app/offers/ValidOffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { notifyOpsEvent } from "@services/alerts/ops-events"
import ErpNext, { CashoutId } from "@services/frappe/ErpNext"
import { CashoutDraftError, CashoutSubmitError } from "@services/frappe/errors"
import { baseLogger } from "@services/logger"
import { IbexError } from "@services/ibex/errors"
import { FailedIbexPayment, IbexError } from "@services/ibex/errors"
import { paymentSendStatusOrPending } from "@services/ibex/payment-status"
import { Cashout } from "@config"

import { CashoutDetails, ValidationInputs } from "./types"
Expand Down Expand Up @@ -73,6 +74,35 @@ class ValidOffer extends Offer {
this.notifyStepFailed("payInvoice", resp)
return resp
}

// A 200 from IBEX is not a settlement. The same payInvoiceV2 body that
// carries a successful send also carries a FAILED one, and reading only
// `resp instanceof IbexError` here treated both as paid — submitting a
// cashout in ERPNext, on the fiat-payout rail, with no lightning payment
// behind it. Unlike cash-wallet-cutover, this path has no
// balanceVerifier.verifyBalanceMove backstop, so the status field is the
// only check there is. See @services/ibex/payment-status.
const paymentStatus = paymentSendStatusOrPending(resp)
if (paymentStatus === PaymentSendStatus.Failure) {
const failure = new FailedIbexPayment(
`IBEX reported the cashout payment as FAILED for cashout ${cashoutId}`,
)
baseLogger.error(
{ cashoutId, resp },
"IBEX reported the cashout payment as failed — not submitting the cashout",
)
this.notifyStepFailed("payInvoice", failure)
return failure
}
// Pending is submitted deliberately, not by omission. `pending` here means
// "IBEX accepted it and has not told us the outcome" — including the
// unreadable-response case, which paymentSendStatusOrPending reports as
// pending precisely so a same-key retry cannot pay twice. The cashout is
// an async, reconciled flow (InitiatedCashout.status is Pending by
// construction and ops settle the fiat leg against the draft), so holding
// the ERPNext submit on a payment that probably did settle would strand
// the user's funds in a draft nobody is watching. Refusing to submit is
// reserved for the one answer that is definitively negative, above.
} else {
baseLogger.warn({ cashoutId }, "Skipping Ibex payment (skipPayment=true)")
}
Expand Down
35 changes: 16 additions & 19 deletions src/app/payments/send-intraledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AccountsRepository, WalletsRepository } from "@services/mongoose"

import Ibex from "@services/ibex/client"
import { UnexpectedIbexResponse } from "@services/ibex/errors"
import { paymentSendStatusOrPending } from "@services/ibex/payment-status"

import { withPaymentIdempotency } from "./idempotency"

Expand Down Expand Up @@ -97,25 +98,21 @@ const intraledgerPaymentSendWalletId = async ({
})
if (payResp instanceof Error) return payResp

// https://docs.ibexmercado.com/reference/flow-1#payment-status
let paymentSendStatus: PaymentSendStatus
switch (payResp.status) {
case 1:
paymentSendStatus = PaymentSendStatus.Pending
break
case 2:
paymentSendStatus = PaymentSendStatus.Success
break
case 3:
paymentSendStatus = PaymentSendStatus.Failure
break
case 0:
return new UnexpectedIbexResponse("Invoice already paid")
default:
return new UnexpectedIbexResponse(
`StatusId (${payResp.status}) not in documentation`,
)
}
// Same payInvoiceV2 response, same reader as every other IBEX send path —
// this used to be a fourth private dialect of the status switch, and the only
// one reading the top-level `status` alone. See @services/ibex/payment-status
// for the field precedence and for why 0 is not "invoice already paid".
//
// CONTRACT CHANGE (deliberate, not incidental): an unreadable payInvoiceV2
// response used to return `UnexpectedIbexResponse` here, which
// intraledger-usd-payment-send maps to `{ status: "failed" }`. It now reports
// `pending`, for the double-pay reason documented on
// paymentSendStatusOrPending — an error return is left uncached by
// withPaymentIdempotency, so a same-key retry could re-execute the one send
// whose outcome we do not know. Until flash-mobile#699 lands, the shipped
// client renders PENDING as a completed conversion, so this rail is
// fail-open on that one case; the PR body states this and sequences the two.
const paymentSendStatus = paymentSendStatusOrPending(payResp)

// flash fork: no longer adding contact on payments
// if (senderAccount.id !== recipientAccount.id) {
Expand Down
1 change: 1 addition & 0 deletions src/graphql/error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ export const mapError = (error: ApplicationError): CustomApolloError => {

case "IbexError":
case "UnexpectedIbexResponse":
case "FailedIbexPayment":
return new IbexError(baseLogger)
case "OfferNotFound":
return new NotFoundError({
Expand Down
16 changes: 2 additions & 14 deletions src/graphql/public/root/mutation/ln-invoice-payment-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import dedent from "dedent"
import { PaymentSendStatus } from "@domain/bitcoin/lightning"
import Ibex from "@services/ibex/client"
import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors"
import { paymentSendStatusOrPending } from "@services/ibex/payment-status"
import { withPaymentIdempotency } from "@app/payments/idempotency"

const LnInvoicePaymentInput = GT.Input({
Expand Down Expand Up @@ -99,20 +100,7 @@ const LnInvoicePaymentSendMutation = GT.Field<
return PayLightningInvoice
}

let ibexStatus: PaymentSendStatus = PaymentSendStatus.Pending
switch (PayLightningInvoice.transaction?.payment?.status?.id) {
case 1:
ibexStatus = PaymentSendStatus.Pending
break
case 2:
ibexStatus = PaymentSendStatus.Success
break
case 3:
ibexStatus = PaymentSendStatus.Failure
break
}

return ibexStatus
return paymentSendStatusOrPending(PayLightningInvoice)
},
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@ import dedent from "dedent"
import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction"

// FLASH FORK: import ibex dependencies
import { PaymentSendStatus } from "@domain/bitcoin/lightning"

import { usdWalletAmountFromWalletId } from "@app/wallets"
import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover"
import Ibex from "@services/ibex/client"

import { IbexError } from "@services/ibex/errors"
import { paymentSendStatusOrPending } from "@services/ibex/payment-status"

const LnNoAmountUsdInvoicePaymentInput = GT.Input({
name: "LnNoAmountUsdInvoicePaymentInput",
Expand Down Expand Up @@ -126,18 +125,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field<
}
}

let status: PaymentSendStatus = PaymentSendStatus.Pending
switch (PayLightningInvoice.transaction?.payment?.status?.id) {
case 1:
status = PaymentSendStatus.Pending
break
case 2:
status = PaymentSendStatus.Success
break
case 3:
status = PaymentSendStatus.Failure
break
}
const status = paymentSendStatusOrPending(PayLightningInvoice)

return {
errors: [],
Expand Down
34 changes: 6 additions & 28 deletions src/graphql/public/root/mutation/lnurl-payment-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
validateLnurlPayAmountMsat,
} from "@app/payments/lnurl-pay"
import { usdWalletAmountFromWalletId } from "@app/wallets"
import { PaymentSendStatus } from "@domain/bitcoin/lightning"
import { InvalidLnurlError } from "@domain/errors"
import { GT } from "@graphql/index"
import { mapAndParseErrorForGqlResponse } from "@graphql/error-map"
Expand All @@ -20,6 +19,7 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id"
import { DealerPriceService } from "@services/dealer-price"
import Ibex from "@services/ibex/client"
import { IbexError } from "@services/ibex/errors"
import { lnurlPaymentSendStatusOrPending } from "@services/ibex/payment-status"

type LnurlPayMetadata = {
callback: string
Expand Down Expand Up @@ -76,32 +76,10 @@ const paramsFromMetadata = ({
tag: "payRequest",
})

type IbexPaymentStatus = {
transaction?: {
payment?: {
status?: {
id?: number
}
statusId?: number
}
}
}

const paymentStatusFromIbex = (payment: IbexPaymentStatus): PaymentSendStatus => {
switch (
payment.transaction?.payment?.status?.id ??
payment.transaction?.payment?.statusId
) {
case 1:
return PaymentSendStatus.Pending
case 2:
return PaymentSendStatus.Success
case 3:
return PaymentSendStatus.Failure
default:
return PaymentSendStatus.Pending
}
}
// Status reading (including the "no recognised status" case) lives in
// @services/ibex/payment-status. payToLnurl gets its own reader there: its 201
// response carries no top-level `status` and no `transaction.payment.status`
// object, and reports settlement via `settleDateUtc` instead.

const LnurlPaymentSendMutation = GT.Field<
null,
Expand Down Expand Up @@ -226,7 +204,7 @@ const LnurlPaymentSendMutation = GT.Field<

return {
errors: [],
status: paymentStatusFromIbex(payment).value,
status: lnurlPaymentSendStatusOrPending(payment).value,
}
},
})
Expand Down
54 changes: 54 additions & 0 deletions src/services/ibex/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,60 @@ export class InsufficientIbexBalance extends IbexError {
}
export class CompletedInvoice extends IbexError {}

/**
* IBEX accepted the request but the response carries no recognisable payment
* status, so we cannot say whether funds moved. Distinct from the generic
* UnexpectedIbexResponse so this condition — money may or may not have left a
* user's wallet — is greppable and alertable on its own. Never returned to
* clients: the send resolvers record it and report the payment as still in
* flight, the only honest reading of "we don't know yet".
*
* SEVERITY: the default is **Warn**, and Warn is the honest level until a real
* response from either send endpoint has been captured. Neither `payInvoiceV2`
* nor `payToLnurl` has an observed 200/201 in this repo — the committed
* fixtures under test/flash/mocks/ibex/ are the vendor's openapi examples — so
* an unreadable response may well be a rail's ordinary shape rather than an
* incident, and paging on every send of that rail would be an outage, not a
* severity. Raise the default (or pass Critical at a specific call site) once a
* capture proves the payment-level fields are populated. The level is honoured
* by the recorder in ./payment-status, which passes `level: error.level`
* straight through to the span.
*
* `uncorroboratedOutcome` names a terminal outcome IBEX *claimed* in the
* top-level `status` field and this codebase refused to honour uncorroborated.
* It is what tells that same recorder how loud to be: a response that reported
* nothing at all is an ordinary in-flight payment and gets a span event, while
* one carrying an unhonoured SUCCEEDED/FAILED is a genuine field-level
* disagreement and gets a recorded exception (which sets the span status to
* ERROR). See the doc block on `recordUnconfirmed` in ./payment-status.
*/
export class UnconfirmedIbexPayment extends IbexError {
readonly uncorroboratedOutcome?: "SUCCEEDED" | "FAILED"

constructor(
message: string,
level: ErrorLevel = ErrorLevel.Warn,
uncorroboratedOutcome?: "SUCCEEDED" | "FAILED",
) {
super(new UnexpectedResponseError(message), level)
this.uncorroboratedOutcome = uncorroboratedOutcome
}
}

/**
* IBEX answered 200/201 and the response says the payment FAILED — a definite,
* corroborated negative, not an unreadable one. Distinct from
* `UnconfirmedIbexPayment` ("we cannot tell") and from the generic `IbexError`
* ("the call itself errored"): this is the shape that sails through any caller
* which only checks `resp instanceof IbexError`, letting downstream side
* effects fire behind a lightning payment that never settled.
*/
export class FailedIbexPayment extends IbexError {
constructor(message: string, level: ErrorLevel = ErrorLevel.Warn) {
super(new IbexClientError(message), level)
}
}

/**
* Best-effort extraction of the IBEX error text from a failed call.
* Shapes handled:
Expand Down
Loading
Loading