Skip to content

fix(payments): read every IBEX status field; never invent a settled send - #483

Open
islandbitcoin wants to merge 8 commits into
mainfrom
fix/ibex-payment-status-fail-closed
Open

fix(payments): read every IBEX status field; never invent a settled send#483
islandbitcoin wants to merge 8 commits into
mainfrom
fix/ibex-payment-status-fail-closed

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Symptom (found live)

A Cash→BTC conversion on TEST reported "conversion successful" while no funds moved. IBEX recorded no send transaction for the attempt (verified by querying the account directly).

Cause

The IBEX send resolvers read the payment state from exactly one place — transaction.payment.status.id — and silently defaulted to Pending for anything else.

  1. The state lives in up to three fields. Per the generated PayInvoiceV2 schema, IBEX returns a top-level status, plus transaction.payment.statusId and transaction.payment.status.id. Unset integers arrive as 0 rather than being omitted, so a single-path read — or lnurl-payment-send's status?.id ?? statusId chain, where a legitimate 0 short-circuits the ?? — can miss a status IBEX actually reported and call a settled or failed payment "pending".

  2. The fallback was silent. A response we couldn't parse at all was indistinguishable from a genuine in-flight payment, with nothing recorded. Combined with the client treating PENDING as done, a payment that never executed rendered as a completed conversion.

Change

New shared @services/ibex/payment-status, with two readers for two dialects — the endpoints do not return the same shape:

  • paymentSendStatusFromIbex (payInvoiceV2) takes the first recognised id across all three status fields, so an unset 0 can't mask a real status. The top-level status is read only when both payment-level fields are unreadable, and then it can never invent a terminal outcome in either direction:

    • never Success from a lone top-level 2;
    • never Failure from a lone top-level 3 either — accepted only when IBEX also names a failure code (failureReason or transaction.payment.failureId: a number > 0, or a named string reason like "NO_ROUTE"; only an explicit zero or blank means "no failure"). A fabricated "failed" sends the user back to retry with a fresh idempotency key against a send IBEX may in fact have made, which is the same double-pay hazard withPaymentIdempotency (Collapse payInvoice raw-fetch seam now that ibex-client 3.3.0 ApiError carries the body #478) exists to close.
    • Pending needs no gate: it is the outcome the unconfirmed path reports anyway.
  • paymentSendStatusFromIbexLnurlPay (payToLnurl 201) — this response carries no top-level status and no transaction.payment.status object. Settlement is read from payment-level evidence only: a recognised transaction.payment.status.id / statusId, or failing those a populated transaction.payment.settleDateUtc (integer epoch or ISO string — that field has no declared type, and every payment-level date this vendor does declare is a string — rejecting 0 and the "0001-01-01T00:00:00Z" zero-date sentinel alike).

    The top-level settleDateUtc is deliberately not read. The vendor's documented 201 example sets it to 1668544241, which is transaction.createdAt ("2022-11-15T20:30:41.960887Z") floored to the second, while every payment-level field in the same body says nothing settled: statusId: 0, payment.settleDateUtc: null, paidMsat: 0, payment.hash: null. On the example's own evidence, then, a 201 is an acceptance rather than a settlement — reading that echo would report every LNURL send as success regardless of outcome, i.e. the exact bug at the top of this PR, on the one rail whose real response has never been observed. The example is now committed as a fixture (test/flash/mocks/ibex/pay-to-lnurl.ts) and both readers are asserted against it.

    Correction — there is no settlement webhook. An earlier revision of this description, and of the reader's doc block, justified the acceptance reading with "Ibex.payToLnurl also registers an async settlement webhook". That was wrong. payToLnurl does pass a webhookUrl (client.ts:348), but the value it passes is ibexWebhookEndpoints.onPay.lnurl, which resolves to <uri>/pay/lnurl/:username — the express route template from webhook-config.ts:13, :username never substituted. The only route mounted at that path is the public GET LNURL-pay callback for inbound payments (webhook-server/routes/on-pay.ts:143); there is no POST handler for it, and the two POST webhook routes that do exist (/pay/invoice, /pay/onchain) are resp.status(200).end() stubs. So an LNURL send this reader reports as pending is never resolved server-side — no webhook, no poller, no reconciliation job — and withPaymentIdempotency replays that pending for 24h under the same key. The conclusion above (do not read the top-level settleDateUtc) is unaffected: it rests on the example's payment-level fields, not on the webhook. The gap is now stated in the reader's doc block and pinned by test/flash/unit/services/ibex/webhook-server/routes/on-pay-lnurl-gap.spec.ts, which fails the moment a real POST handler is mounted, so whoever wires one is forced back to both. See the Known gap section.

  • Neither reader ever guesses: with no readable field they return UnconfirmedIbexPayment. paymentSendStatusOrPending / lnurlPaymentSendStatusOrPending — what the resolvers call — record that on the current span at Warn (both readers) with the transaction id, account id and payment hash attached, and still return Pending. (On which span channel, and why level alone was not enough, see "Alerting keys on span status" under Known gap.) Warn rather than Critical because neither endpoint has a captured response: the committed fixtures are the vendor's openapi examples, and the pre-PR intraledger code read the top-level status alone (with a { status: 2 } fixture modelling exactly that), so a rail populating only the field the reader treats as uncorroborated is a live possibility. Paging on every flash-to-flash send until a capture proves otherwise would be an outage, not a severity. Capture one payInvoiceV2 200 and one payToLnurl 201 on TEST, commit them beside the examples, then raise the levels (noted in both readers' doc blocks).

    Why still Pending, deliberately: withPaymentIdempotency (Collapse payInvoice raw-fetch seam now that ibex-client 3.3.0 ApiError carries the body #478) caches only a definitive PaymentSendStatus; an ApplicationError return is left uncached so a same-key retry can re-execute. Returning an error here would therefore make the one case where we don't know whether funds moved the one case a same-key retry could pay twice. Pending keeps it cacheable and replayable. What changes is that the anomaly is recorded rather than invisible, and that a status IBEX did report can no longer be missed.

Applied to four call sites: ln-invoice-payment-send, ln-noamount-usd-invoice-payment-send, send-intraledger (all three on the payInvoiceV2 reader) and lnurl-payment-send (on the LNURL reader), replacing four private copies of the status switch. lnurl-payment-send's local IbexPaymentStatus type and helper go away.

⚠️ Behaviour change on the intraledger rail — read before merging

intraledger: an unreadable IBEX status now returns pending where it previously returned a GraphQL error. send-intraledger used to return UnexpectedIbexResponse for both the 0 case and the default case; intraledger-usd-payment-send.ts maps that to { status: "failed", errors: [...] }. It now reports { errors: [], status: "pending" }, for the double-pay reason above.

A bare top-level status: 3 falls into the same bucket: intraledger's old switch reported it as failed outright, and it now reports pending unless IBEX also names a failure code (see the failure-corroboration rule above).

Since the shipped mobile client renders PENDING as a completed conversion — the symptom this PR opens with — flash-to-flash USD sends move from fail-closed to fail-open on that one case until the client fix lands. This PR should therefore be sequenced behind lnflash/flash-mobile#699, which makes the app render pending honestly. The change is deliberate and pinned by a test (test/flash/unit/app/payments/send-intraledger.spec.ts), documented at the call site and on paymentSendStatusOrPending — it is not an incidental side effect of the refactor.

Behaviour change on the LN rails too — correcting an earlier claim in this description

An earlier revision of this description said "the three LN rails are unaffected: their inline switches already produced Pending for these responses." That was wrong, and the merge-sequencing argument above should not be read as resting on it. The old LN switches read transaction.payment.status.id and nothing else, defaulting to Pending, so two readings genuinely change on ln-invoice-payment-send and ln-noamount-usd-invoice-payment-send:

  • A corroborated top-level failure is now failed where it used to be pending. { status: 3, failureReason: 2, transaction: { payment: { statusId: 0 } } } returned pending from the old switch; it now returns failed. Pinned by test/flash/unit/graphql/ln-invoice-payment-send.spec.ts ("reports a corroborated top-level failure").
  • A flat payment-level statusId is now read at all. { transaction: { payment: { statusId: 2 } } } (or 3) returned pending from the old switch and now returns success / failed — a status IBEX did report and we were dropping.

Both are fail-closed movements on those rails (pending → a definite outcome), the opposite direction to the intraledger change, and the shipped client already handles them without extra sequencing: send-bitcoin-confirmation-screen.tsx routes SUCCESS/PENDING to the success screen and everything else — FAILURE included — to the error path (errorsMessage || "Something went wrong", error haptic). No client change is required for the LN rails; flash-mobile#699 remains the sequencing constraint for the intraledger pending case only.

Deliberately not touched: the generic "An unexpected error occurred" message for non-insufficient-balance IBEX errors — #476 landed typed INSUFFICIENT_BALANCE and consciously kept the generic text for the rest.

The client half of the bug (the app rendering PENDING as a completed conversion, plus swallowed fee-probe errors) is lnflash/flash-mobile#699.

Known gap

Neither endpoint has a captured response. test/flash/mocks/ibex/pay-invoice.ts and test/flash/mocks/ibex/pay-to-lnurl.ts are the vendor's openapi examples, committed verbatim and labelled as such, not observations — they are in the repo so the readers are asserted against the only evidence that exists rather than against shapes we imagined. Two things follow, both deliberate:

  • The LNURL settleDateUtc rule is payment-level only and schema-derived; on the documented 201 example the reader reports pending, which is what shipped before this PR. It cannot report success from a field the vendor's own example populates on an unsettled payment.
  • Both unreadable cases record at Warn, not Critical.

Capture one payInvoiceV2 200 and one payToLnurl 201 on TEST, commit them beside the examples, then revisit both the settle-date rule and the severities.

A pending LNURL send is never resolved server-side. Stated plainly because the correction above removed the sentence that implied otherwise: payToLnurl is handed a webhookUrl, but this repo mounts no POST handler behind it (the path it sends is the un-substituted route template /pay/lnurl/:username, and the only route there is the public inbound GET callback). There is no poller and no reconciliation job either. So on the LNURL rail, pending is terminal in practice — withPaymentIdempotency replays it for 24h under the same key, and after flash-mobile#699 makes the client render pending honestly, such a send shows "pending" to the user indefinitely. This is pre-existing (the rail already reported pending for this shape before this PR); what this PR changes is that the gap is documented rather than implied away, and pinned by a test that fails when a real handler lands. Wiring that handler — or a reconciler — is the follow-up.

Alerting keys on span status, not just error.level — checked. recordException (services/tracing.ts:354) calls span.setStatus({ code: SpanStatusCode.ERROR }) unconditionally; ErrorLevel only decides which error.* attributes win in updateErrorForSpan. Recording every unreadable response as an exception would therefore have turned 100% of the LNURL rail's spans red if the 201 acceptance shape is the norm — this PR's own position — and the same on intraledger if the payment-level fields turn out unpopulated. recordUnconfirmed now splits by evidence rather than by severity: a response that reported nothing at all gets span attributes plus an ibex.payment.unconfirmed event (fully queryable, span stays green), while a response carrying a top-level SUCCEEDED/FAILED the corroboration rules refused keeps the recorded exception — there the payload disagrees with itself and the pending may be wrong in a direction that moved money. Carried on UnconfirmedIbexPayment.uncorroboratedOutcome; pinned at the reader and on all three payInvoiceV2 rails.

Tests

  • Unit tests on the two readers (test/flash/unit/services/ibex/payment-status.spec.ts): field precedence when fields disagree, the unset-0 mask, failure-code corroboration including named string reasons, the payment-level settleDateUtc in both serialisations plus the zero-date sentinel, the top-level settleDateUtc being ignored in every serialisation, span-attribute identity for both dialects, which of the two span channels each unreadable shape earns (quiet event vs recorded exception, asserted as exactly one and never both), and four invariants — never Success without an explicit success id, never Failure without a corroborated one, an unreadable response is always Pending, and the recorded severity is the error's own.
  • The cashout rail now reads the status field at all. ValidOffer.execute checked only resp instanceof IbexError, so a 200 carrying transaction.payment.status.id === 3 counted as paid and Flash submitted a cashout in ERPNext — on the fiat-payout rail, with no balanceVerifier.verifyBalanceMove backstop, this PR's headline defect one call site over. It now refuses submitCashout on Failure (new FailedIbexPayment, mapped in error-map, reported to ops as the payInvoice step) and still submits on Pending, deliberately and documented: the cashout is an async reconciled flow, and holding the submit on a payment that probably settled would strand funds in a draft nobody watches. Pinned in test/flash/unit/app/offers/ops-events-valid-offer.spec.ts.
  • The LNURL webhook gap is pinned (test/flash/unit/services/ibex/webhook-server/routes/on-pay-lnurl-gap.spec.ts): the webhook URL handed to IBEX is an un-substituted route template, the only method mounted at that path is GET, and the router's complete POST set is the two no-op stubs. Mounting a real handler fails this test, forcing the reader's doc block back into review.
  • Both vendor examples are pinned as fixtures. The payToLnurl 201 example must read as unconfirmed → pending (never success), and the payInvoiceV2 200 example as in-flight — the regression tests for "a creation-time echo is not a settlement".
  • Call-site wiring assertions on all four rails. The two readers are structurally interchangeable at the type level (lnurlPaymentSendStatusOrPending accepts a payInvoiceV2 response without complaint), so wrong-reader-on-wrong-endpoint — the bug that had shipped on LNURL — is invisible to the readers' own unit suite and only an assertion at the call site catches it. Each rail asserts a payment-level settle, the unreadable case, and a reading only its own reader makes (the LN rails: a corroborated top-level failure, and the unreadable message in the payInvoiceV2 dialect; LNURL: settlement from a payment-level settle date). ln-noamount-usd-invoice-payment-send gets its first spec file.
  • Unit suite: 186 suites / 1710 tests (1707 passing, 3 pre-existing skips). tsc-check, tsc-check-noimplicitany, eslint, build, madge-check and check-yaml all clean.

(Rebased onto current main — the first push was based on a 40-commit-stale main, which is what the earlier schema check was complaining about.)

🤖 Generated with Claude Code

https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV

The IBEX send resolvers read the payment state from exactly one place,
`transaction.payment.status.id`, and silently fell back to Pending for
anything else. Two problems:

- IBEX reports the state in up to three fields (top-level `status`,
  `payment.statusId`, `payment.status.id` — see the generated
  PayInvoiceV2 schema) and sends unset integers as 0 rather than
  omitting them. A single-path read, or lnurl-payment-send's
  `status?.id ?? statusId` chain, can miss a status IBEX did report and
  call a settled or failed payment "pending".
- When nothing was readable the fallback was silent, so a response we
  could not parse at all was indistinguishable from a genuine in-flight
  payment. That is what a conversion reporting success while no funds
  moved looked like from the server side.

A shared `paymentSendStatusFromIbex` now takes the first RECOGNISED id
across all three fields (so an unset 0 cannot mask a real status) and
returns `UnconfirmedIbexPayment` when there is none. The resolver-facing
`paymentSendStatusOrPending` records that on the current span and still
reports Pending — deliberately, because `withPaymentIdempotency` caches
only a definitive PaymentSendStatus, so returning an error would make
the one case where we do not know whether funds moved the one case a
same-key retry could pay twice. Success is now reported only when IBEX
explicitly says so.

Applied to all three send mutations, replacing three copies of the
switch (lnurl's local IbexPaymentStatus type and helper go with it).
The client-facing half of the phantom-success bug — the app rendering
PENDING as a completed conversion — is flash-mobile#699.

Tests: 8 covering field precedence, the unset-0 mask, every unreadable
shape, and the "never Success without an explicit success id" and
"unreadable is always Pending" invariants. Unit suite 184 suites /
1634 passing, tsc and eslint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
@islandbitcoin
islandbitcoin force-pushed the fix/ibex-payment-status-fail-closed branch from 8f9780a to 88b4384 Compare August 16, 2026 04:23
@islandbitcoin islandbitcoin changed the title fix(payments): never report an unconfirmed IBEX send as pending-success fix(payments): read every IBEX status field; never invent a settled send Aug 16, 2026
bobodread876 and others added 7 commits August 15, 2026 21:43
…y on payment-level fields

Review fixes for the IBEX payment-status PR.

- send-intraledger consumed the same payInvoiceV2 response through a fourth
  private switch that read the top-level `status` alone. A flash-to-flash USD
  send where IBEX populated transaction.payment.status.id = 2 and left the
  top-level status at 0 returned UnexpectedIbexResponse("Invoice already paid")
  for a payment that settled. It now uses paymentSendStatusOrPending.

- Settled the "is 0 a real status" contradiction against IBEX's own table
  (docs.poweredbyibex.io/reference/flow-1#payment-status): 0 is UNKNOWN, not
  "already paid". Documented it as IbexPaymentStatusId.Unknown, kept it out of
  the recognised set (it reports no outcome and is indistinguishable from an
  unset field), and deleted the contradicting `case 0`. Doc link now lives in
  the module comment.

- Success is now taken only from a payment-level field. The top-level `status`
  — the least corroborated of the three, and only ever read when both
  payment-level fields are unreadable — can produce Pending/Failure, never a
  settlement.

- payToLnurl gets its own reader. Its 201 has no top-level `status` and no
  transaction.payment.status object; its documented statusId example is 0 next
  to a populated settleDateUtc. Reading it with the payInvoiceV2 reader would
  have paged Critical on every LNURL send. Settlement now comes from
  settleDateUtc, and the residual unreadable case is raised at Warn until a
  real response is captured on TEST.

- The Critical span now carries ibex.transaction.id, ibex.payment.hash and
  ibex.account.id, so an operator can name the payment without trace-hopping.

- UnconfirmedIbexPayment's `level` argument is live again: the recorder passes
  `level: error.level` instead of hardcoding Critical.

Tests: recording behaviour is now covered (mocked @services/tracing asserts the
call count, error type, level and attributes, and that nothing is recorded for a
recognised status); field-precedence disagreement is covered in both directions
at every level; the LNURL shape and the intraledger regression shapes each have
their own cases.
…s; test reader wiring

Round-2 review fixes on the IBEX payment-status reader.

- Top-level `status: 3` no longer returns Failure on its own. The doc block
  argues the top-level field is the least corroborated of the three and is only
  ever read in the anomalous context — that argument does not stop applying when
  the digit is 3. A fabricated "failed" sends the user back to retry with a fresh
  idempotency key against a send IBEX may have made: the exact double-pay hole
  `withPaymentIdempotency` (#478) closes. It is now accepted only when IBEX also
  names a failure code (`failureReason` / `transaction.payment.failureId` > 0),
  both of which are widened onto `IbexPaymentStatusResponse`. Pending stays
  ungated — it commits to nothing.

- `settledAt` accepts a parseable ISO date string as well as a positive integer.
  This vendor serialises payment-level dates as strings (`creationDateUtc`:
  "2023-07-06T14:51:59.389565Z") and declares `payment.settleDateUtc` with no
  type at all, so the number-only reader left the payment-level fallback dead —
  and would have reported every LNURL send pending forever, with a Warn span per
  send, had the top-level field also arrived as a string. Settlement-on-settle-
  date is the only route by which an LNURL send can report success. Both forms
  normalise to an epoch and must be > 0, which also rejects the vendor's
  "0001-01-01T00:00:00Z" zero-date sentinel.

- `identityAttributes` falls back to the top-level `hash`, where the payToLnurl
  201 actually puts the payment hash (`transaction.payment.hash` has an empty
  schema there). An operator paged on an unreadable LNURL response was getting
  transaction id and account id but not the field that names the payment.

- Call-site wiring is now asserted for `ln-invoice-payment-send` and
  `ln-noamount-usd-invoice-payment-send` (new spec). The two readers are
  structurally interchangeable at the type level, so only a call-site assertion
  catches them being swapped; Critical-vs-Warn on the unreadable case and the
  top-level-status reading are the discriminators.

- The intraledger contract change is stated outright instead of being implied:
  an unreadable payInvoiceV2 response used to return `UnexpectedIbexResponse`
  (rendered "failed") and now reports `pending`. Documented in the reader doc
  block, at the call site, in the test that pins it, and in the PR body, and
  sequenced behind flash-mobile#699.

Also captures the still-outstanding item on the LNURL reader: no real
payToLnurl response has been captured on TEST, so its settle-date type is
schema-derived rather than observed, and the unreadable case stays at Warn
until it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
settledAt() routed every string through Date.parse, which inverts the
settlement check in both directions for the stringified-integer form the
doc block above it explicitly anticipates:

  Date.parse("0")          -> 946713600000   (V8 reads a bare "0" as the
                                              year 2000, so > 0)
  Date.parse("1668544241") -> NaN            (out-of-range year)

So an UNSET top-level settleDateUtc of "0" reported the LNURL send as
SUCCEEDED, and a real epoch in string form reported it as unsettled —
the false-success direction being exactly what this module exists to
prevent. All-digit strings are now coerced numerically and still
required to be > 0; ISO strings keep the Date.parse path.

Tests: "0", "00", "-1", "+0" rejected; "1668544241" (the schema's own
example, stringified) settles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Review fixes on the two IBEX payment-status readers.

- payToLnurl: stop reading the TOP-LEVEL `settleDateUtc` as proof of
  settlement. The vendor's documented 201 example sets it to 1668544241 —
  `transaction.createdAt` floored to the second — while `statusId: 0`,
  `payment.settleDateUtc: null`, `paidMsat: 0` and `payment.hash: null` all say
  nothing settled, and payToLnurl registers an async settlement webhook, so the
  201 is an acceptance. Reading it reported EVERY LNURL send as success
  regardless of outcome — the "conversion successful while no funds moved" bug
  this module exists to close — with the Warn record suppressed on that path.
  Settlement is now read from payment-level evidence only.

- Commit both vendor examples as fixtures (test/flash/mocks/ibex/pay-to-lnurl.ts,
  header on pay-invoice.ts) labelled as openapi examples rather than captures,
  and assert the readers against them: the payToLnurl 201 example must read as
  unconfirmed -> pending, the payInvoiceV2 200 example as in-flight.

- Drop the payInvoiceV2 unreadable case from Critical to Warn. Neither endpoint
  has a captured response, and the pre-#483 intraledger code read the top-level
  `status` alone — so paging on a response that populates only that field would
  fire on every flash-to-flash send. Both readers now record at Warn until a
  capture justifies raising them.

- reportsFailureCode: accept a NAMED string reason ("NO_ROUTE"). Number() turned
  it into NaN, so a definitively failed send reported pending and
  withPaymentIdempotency cached that for 24h under the same key. Explicit zero,
  blank and malformed values stay uncorroborated.

- settledAt -> hasSettleDate, returning a boolean: it mixed epoch seconds
  (number branch) with milliseconds (ISO branch) behind a name promising a
  timestamp, and every caller only ever tested presence.

Call-site wiring specs no longer lean on Critical-vs-Warn to tell the two
readers apart (both are Warn now); they assert a reading only the rail's own
reader makes plus the reader's own message dialect.

Unit suite: 185 suites / 1694 tests (1691 passing, 3 pre-existing skips).
tsc-check, tsc-check-noimplicitany, eslint and check-yaml clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… span; guard the cashout rail

Review fixes on #483.

1. payment-status.ts — the LNURL doc block justified "a 201 is an acceptance"
   with "payToLnurl also registers an async settlement webhook". No such
   webhook exists. `Ibex.payToLnurl` does pass a webhookUrl, but the value is
   `ibexWebhookEndpoints.onPay.lnurl` = `<uri>/pay/lnurl/:username`, the
   un-substituted express route template; the only route mounted there is the
   public GET LNURL-pay callback for INBOUND payments, and the two POST webhook
   routes that exist are 200 no-ops. The conclusion (do not read the top-level
   settleDateUtc) stands on the example's own payment-level fields, but the
   claim that something resolves a pending LNURL send does not. Replaced with a
   GAP paragraph stating plainly that nothing transitions it server-side and
   that withPaymentIdempotency replays the pending for 24h; pinned by
   test/flash/unit/services/ibex/webhook-server/routes/on-pay-lnurl-gap.spec.ts
   so wiring a real handler forces both to be revisited.

2. ErrorLevel does not control span volume: recordException ends in an
   unconditional span.setStatus({ code: ERROR }), so a Warn-level record still
   paints the span red and any trigger keyed on span status fires on it. If the
   LNURL 201 really is an acceptance — this PR's position — that was 100% of
   that rail, and the same on intraledger if the payment-level fields turn out
   unpopulated. recordUnconfirmed now splits by evidence, not by severity: a
   response that reported nothing at all gets span attributes plus an
   `ibex.payment.unconfirmed` event and leaves the span green, while one
   carrying a SUCCEEDED/FAILED the corroboration rules refused keeps the
   recorded exception it earns. Carried on a new
   UnconfirmedIbexPayment.uncorroboratedOutcome field.

3. UnconfirmedIbexPayment defaults to Warn, not Critical. The Critical default
   was unreachable (both construction sites passed Warn) and would have paged
   the next caller who omitted the argument, on rails the module documents as
   un-captured. Doc block rewritten to say so.

4. ValidOffer.execute read no status field at all: any non-IbexError return
   counted as paid, so a 200 carrying `status.id === 3` submitted a cashout in
   ERPNext, on the fiat-payout rail, with no lightning payment behind it — this
   PR's own headline defect, and with no balanceVerifier backstop on that path.
   It now reads paymentSendStatusOrPending and refuses to call submitCashout on
   Failure (new FailedIbexPayment, mapped in error-map, reported to ops as the
   payInvoice step). Pending still submits, deliberately and documented: the
   cashout is an async reconciled flow, and holding the submit on a payment
   that probably settled would strand funds in a draft nobody watches.

Unit suite: 186 suites / 1710 tests (1707 passing, 3 pre-existing skips).
tsc-check, tsc-check-noimplicitany, eslint, build, madge-check and check-yaml
all clean.
The Spell Check job on #483 fails on "unparseable" in the payment-status spec
(pre-existing from an earlier commit on this branch, surfaced now). Test name
only — no behaviour change.
Every status assertion in this PR was derived from the generated openapi
*examples*, which are illustrative — the reviews kept (correctly)
pointing out that no real response had been observed. Captured one from
the sandbox hub: GET /v2/transaction/<id> for a settled Lightning send
off the TEST cash wallet, committed verbatim as a fixture.

It settles three things the readers depend on:

- A real settled payment populates BOTH payment-level fields,
  `payment.statusId: 2` AND `payment.status.id: 2` (name "SUCCEEDED").
  The payment-level precedence is therefore reading fields IBEX
  actually fills, and the unreadable case is a genuine anomaly rather
  than this vendor's ordinary shape — which is what justifies keeping
  it at Critical.
- `payment.failureId` is 0 on success, confirming 0 = "no failure" for
  the corroboration rule.
- `payment.settleDateUtc` arrives as an ISO STRING, not the integer
  epoch the payToLnurl example declares — so hasSettleDate has to
  accept both forms, as it now does.

The capture is transaction-details, not the payInvoiceV2 send response;
the fixture says so, and notes that its transaction-level `status` is
the string "completed" rather than the send response's integer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants