fix(payments): read every IBEX status field; never invent a settled send - #483
Open
islandbitcoin wants to merge 8 commits into
Open
fix(payments): read every IBEX status field; never invent a settled send#483islandbitcoin wants to merge 8 commits into
islandbitcoin wants to merge 8 commits into
Conversation
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
force-pushed
the
fix/ibex-payment-status-fail-closed
branch
from
August 16, 2026 04:23
8f9780a to
88b4384
Compare
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 toPendingfor anything else.The state lives in up to three fields. Per the generated
PayInvoiceV2schema, IBEX returns a top-levelstatus, plustransaction.payment.statusIdandtransaction.payment.status.id. Unset integers arrive as 0 rather than being omitted, so a single-path read — orlnurl-payment-send'sstatus?.id ?? statusIdchain, where a legitimate0short-circuits the??— can miss a status IBEX actually reported and call a settled or failed payment "pending".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
PENDINGas 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 unset0can't mask a real status. The top-levelstatusis read only when both payment-level fields are unreadable, and then it can never invent a terminal outcome in either direction:Successfrom a lone top-level2;Failurefrom a lone top-level3either — accepted only when IBEX also names a failure code (failureReasonortransaction.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 hazardwithPaymentIdempotency(Collapse payInvoice raw-fetch seam now that ibex-client 3.3.0 ApiError carries the body #478) exists to close.paymentSendStatusFromIbexLnurlPay(payToLnurl201) — this response carries no top-levelstatusand notransaction.payment.statusobject. Settlement is read from payment-level evidence only: a recognisedtransaction.payment.status.id/statusId, or failing those a populatedtransaction.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 — rejecting0and the"0001-01-01T00:00:00Z"zero-date sentinel alike).The top-level
settleDateUtcis deliberately not read. The vendor's documented 201 example sets it to1668544241, which istransaction.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 assuccessregardless 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.payToLnurlalso registers an async settlement webhook". That was wrong.payToLnurldoes pass awebhookUrl(client.ts:348), but the value it passes isibexWebhookEndpoints.onPay.lnurl, which resolves to<uri>/pay/lnurl/:username— the express route template fromwebhook-config.ts:13,:usernamenever 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) areresp.status(200).end()stubs. So an LNURL send this reader reports aspendingis never resolved server-side — no webhook, no poller, no reconciliation job — andwithPaymentIdempotencyreplays thatpendingfor 24h under the same key. The conclusion above (do not read the top-levelsettleDateUtc) 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 bytest/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 returnPending. (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-levelstatusalone (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 onepayInvoiceV2200 and onepayToLnurl201 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 definitivePaymentSendStatus; anApplicationErrorreturn 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) andlnurl-payment-send(on the LNURL reader), replacing four private copies of the status switch.lnurl-payment-send's localIbexPaymentStatustype and helper go away.intraledger: an unreadable IBEX status now returnspendingwhere it previously returned a GraphQL error.send-intraledgerused to returnUnexpectedIbexResponsefor both the0case and the default case;intraledger-usd-payment-send.tsmaps that to{ status: "failed", errors: [...] }. It now reports{ errors: [], status: "pending" }, for the double-pay reason above.A bare top-level
status: 3falls into the same bucket: intraledger's old switch reported it asfailedoutright, and it now reportspendingunless IBEX also names a failure code (see the failure-corroboration rule above).Since the shipped mobile client renders
PENDINGas 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 renderpendinghonestly. The change is deliberate and pinned by a test (test/flash/unit/app/payments/send-intraledger.spec.ts), documented at the call site and onpaymentSendStatusOrPending— 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
Pendingfor these responses." That was wrong, and the merge-sequencing argument above should not be read as resting on it. The old LN switches readtransaction.payment.status.idand nothing else, defaulting toPending, so two readings genuinely change onln-invoice-payment-sendandln-noamount-usd-invoice-payment-send:failedwhere it used to bepending.{ status: 3, failureReason: 2, transaction: { payment: { statusId: 0 } } }returnedpendingfrom the old switch; it now returnsfailed. Pinned bytest/flash/unit/graphql/ln-invoice-payment-send.spec.ts("reports a corroborated top-level failure").statusIdis now read at all.{ transaction: { payment: { statusId: 2 } } }(or3) returnedpendingfrom the old switch and now returnssuccess/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.tsxroutesSUCCESS/PENDINGto the success screen and everything else —FAILUREincluded — 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 intraledgerpendingcase only.Deliberately not touched: the generic "An unexpected error occurred" message for non-insufficient-balance IBEX errors — #476 landed typed
INSUFFICIENT_BALANCEand consciously kept the generic text for the rest.The client half of the bug (the app rendering
PENDINGas 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.tsandtest/flash/mocks/ibex/pay-to-lnurl.tsare 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:settleDateUtcrule 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 reportsuccessfrom a field the vendor's own example populates on an unsettled payment.Capture one
payInvoiceV2200 and onepayToLnurl201 on TEST, commit them beside the examples, then revisit both the settle-date rule and the severities.A
pendingLNURL send is never resolved server-side. Stated plainly because the correction above removed the sentence that implied otherwise:payToLnurlis handed awebhookUrl, 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,pendingis terminal in practice —withPaymentIdempotencyreplays it for 24h under the same key, and after flash-mobile#699 makes the client renderpendinghonestly, such a send shows "pending" to the user indefinitely. This is pre-existing (the rail already reportedpendingfor 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) callsspan.setStatus({ code: SpanStatusCode.ERROR })unconditionally;ErrorLevelonly decides whicherror.*attributes win inupdateErrorForSpan. 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.recordUnconfirmednow splits by evidence rather than by severity: a response that reported nothing at all gets span attributes plus anibex.payment.unconfirmedevent (fully queryable, span stays green), while a response carrying a top-levelSUCCEEDED/FAILEDthe corroboration rules refused keeps the recorded exception — there the payload disagrees with itself and thependingmay be wrong in a direction that moved money. Carried onUnconfirmedIbexPayment.uncorroboratedOutcome; pinned at the reader and on all three payInvoiceV2 rails.Tests
test/flash/unit/services/ibex/payment-status.spec.ts): field precedence when fields disagree, the unset-0mask, failure-code corroboration including named string reasons, the payment-levelsettleDateUtcin both serialisations plus the zero-date sentinel, the top-levelsettleDateUtcbeing 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 — neverSuccesswithout an explicit success id, neverFailurewithout a corroborated one, an unreadable response is alwaysPending, and the recorded severity is the error's own.ValidOffer.executechecked onlyresp instanceof IbexError, so a 200 carryingtransaction.payment.status.id === 3counted as paid and Flash submitted a cashout in ERPNext — on the fiat-payout rail, with nobalanceVerifier.verifyBalanceMovebackstop, this PR's headline defect one call site over. It now refusessubmitCashoutonFailure(newFailedIbexPayment, mapped inerror-map, reported to ops as thepayInvoicestep) and still submits onPending, 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 intest/flash/unit/app/offers/ops-events-valid-offer.spec.ts.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 isGET, 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.payToLnurl201 example must read as unconfirmed →pending(neversuccess), and thepayInvoiceV2200 example as in-flight — the regression tests for "a creation-time echo is not a settlement".lnurlPaymentSendStatusOrPendingaccepts 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-sendgets its first spec file.tsc-check,tsc-check-noimplicitany,eslint,build,madge-checkandcheck-yamlall 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