The MVP rails have genuinely different payer journeys, and the core selects
between them on a capability value (ProviderFlow), never on a rail name.
| push (MTN MoMo) | redirect (Orange Money) | |
|---|---|---|
| How the payer acts | Prompt on their handset; they enter a PIN | Browser redirect to the rail's hosted page; they enter an OTP from USSD |
| Who holds the payer identifier | We do — it is an input to submit | The rail does. We may never learn it |
| Submit returns | An acknowledgement, no id | A pay_token and a URL to redirect to |
Status after confirm |
processing |
requires_action |
| Can the payer act before we persist? | Yes | No |
That last row is the whole reason docs/flows/crash-safety.md has two sections.
stateDiagram-v2
direction TB
[*] --> requires_payment_method : create
requires_payment_method --> canceled : cancel
requires_payment_method --> processing : confirm on a push rail
requires_payment_method --> requires_action : confirm on a redirect rail
requires_action --> succeeded : rail says succeeded
requires_action --> failed : rail says failed
processing --> processing : timers only
processing --> succeeded : rail says succeeded
processing --> failed : rail says failed
state "requires_payment_method + last_payment_error" as failed
succeeded --> succeeded : refund
succeeded --> [*]
canceled --> [*]
failed --> [*]
(Corrected 2026-09-23. Until then the diagram drew
requires_action --> processing : payer redirected, token durable and
requires_action --> failed : submit response lost, payer never redirected,
both from the 2026-08-09 scaffold. Neither is a transition the code makes,
and both had been wrong since Step 4 (2026-09-03), when the settlement
transaction first moved a redirect intent at all. vpay_core::next_status
gives requires_action no outgoing edge, and vpay_db::payment_intents'
SETTLEABLE_STATUSES settles or fails an intent straight from
requires_action, as it does from processing. A redirect submit whose
response is lost never reaches requires_action in the first place: the
charge stays submitting, the intent stays requires_payment_method, and
crash-safety.md says to abandon that order.)
confirm always submits. requires_confirmation is never emitted; it is
absent from the enum rather than present and unreachable.
requires_action is redirect-only. It carries Stripe's own
next_action.redirect_to_url shape, so merchants' existing redirect handling
works unchanged. Push rails never enter this state — there is nothing for a
browser to do while a payer types a PIN into their own handset.
processing leaves only on a terminal answer from the rail. Timers fire but
assert nothing. This is the crux of the design: a payment that is still pending
at minute 15 can resolve successfully at hour 30, and pretending otherwise is
how you double-charge.
A rail-reported failure is the only thing that fails a payment. The intent
returns to requires_payment_method with last_payment_error populated —
terminal in practice, because only one charge may ever exist per intent.
canceled is reachable only from requires_payment_method. Once a rail has
the request you cannot recall it.
A failure carries a FailureCode, and not every rail can produce every
one. The vocabulary and what each code means are
failures.md; the part that belongs to the lifecycle is that
the code a merchant reads on last_payment_error depends on which rail the
charge went to, and the two MVP rails differ by eight of eleven:
| MTN MoMo (push) | Orange Money (redirect) | |
|---|---|---|
| Codes it can produce | all eleven | payer_timeout, provider_account_blocked, provider_error |
| Where the vocabulary comes from | MTN's published ErrorReason.code enum, seventeen values, twelve of them mapped |
five documented statuses, no sub-reason for FAILED |
| A payer who refuses | payer_declined (PAYMENT_NOT_APPROVED) |
arrives as EXPIRED → payer_timeout; the rail does not distinguish it from an abandoned page |
The full table — code by code, which rail's reason produces it, and the conformance case that proves it — is in failures.md § Which rail can produce which code. A merchant writing one branch per code is doing the right thing; a merchant assuming every branch is reachable on the rail in front of them is not.
A code no rail produces is a reservation, not dead weight. Until
2026-09-10 payer_declined was one, and it was being promised to buyers by
examples/shop while nothing could emit it
(issue #59). Variants are never
deleted — the vocabulary is a wire contract — so the answer is to document
what produces each one and let a test fail when a promise outruns a producer.
Refunds do not change intent status. A refund is a separate object.
CREATE UNIQUE INDEX one_charge_per_intent ON charges (payment_intent_id);A plain unique index, not a partial one. Scoping it to live states leaks: the
moment a charge moves to failed, the predicate stops covering it and a second
charge becomes insertable — and "failed" can mean a state we reached before
the rail's answer was final.
Retry means a new PaymentIntent. This is the one place the API deviates noticeably from Stripe's ergonomics, and it is deliberate.
Updated 2026-09-03 (Step 2). Types and the flow-selection logic are
implemented and tested in vpay_core::state: Transition, next_status,
and a transition table proven exhaustive over every (status, verb) pair
(next_status_answers_the_lifecycle_diagram_for_every_pair,
the_transition_table_covers_every_status_and_verb,
cancel_is_legal_only_from_requires_payment_method,
confirm_routes_through_the_flows_own_answer,
a_new_intent_starts_where_the_diagram_says,
every_state_is_live_or_terminal_exclusively).
Two transitions are now driven by real HTTP requests, and neither of them reaches a rail:
- Birth.
POST /v1/payment_intentswrites a row inrequires_payment_method— the status comes fromIntentStatus::INITIAL, never a literal (create_then_retrieve_round_trips_through_the_sdk,backends/tests/integration/tests/payment_intents.rs). - Cancel.
POST /v1/payment_intents/{id}/cancelmovesrequires_payment_method→canceledas a compare-and-swap that also refuses when a live charge exists (cancel_is_legal_only_from_requires_payment_method,a_confirmed_intent_cannot_be_canceled, andcancel_refuses_an_intent_with_a_live_charge_and_allows_one_with_a_terminal_chargeinbackends/crates/vpay-db/tests/repositories.rs). Since 2026-09-10 (issue #57) it emits onepayment_intent.canceled, in the same transaction as the status flip. Before that it emitted nothing: the type had been in the vocabulary and in both SDKs since they were written, and no code wrote it, so a merchant driven by webhooks could not observe a cancellation at all. There is no pooled cancel left to reach around it —vpay_db::PaymentIntentslost the method, andTxRepositories::cancel_in_txis the only one — and a cancel the compare-and-swap refuses writes no event (a_cancel_emits_one_payment_intent_canceled_and_it_reaches_the_receiver,a_cancel_and_its_event_roll_back_together).
A cancel racing a settlement leaves one terminal state and one terminal
event — and the guard that decides it is the settlement's, not the cancel's.
Added by the sabotage review of 2026-09-10 because a merchant now receives
payment_intent.canceled, which makes "exactly one terminal event" a claim
someone builds dedupe logic on rather than an internal detail.
The cancel's NOT EXISTS on a live charge closes the window in which a charge
is already committed when the cancel's statement takes its snapshot. It cannot
close the other one: a confirm may commit its charge while a cancel's
transaction is open, which is a legal interleaving of two requests and not a
defect in either. What stops that becoming a payment settled onto a withdrawn
intent is payment_intents::succeed_after_submission's own
WHERE … status IN (SETTLEABLE_STATUSES), which canceled is not in: the
settlement blocks on the row the cancel holds, re-evaluates against the
committed canceled row, matches nothing, and the whole settlement
transaction rolls back with DbError::WriteMatchedNoRow.
That is a loud outcome and not a safe one: the rail accepted a payment for
an intent that was withdrawn, the charge is left where a retry expects it, and
vpay_db::settlement classifies the answer as something that pages rather
than as a merchant's problem. Reconciling it is an operator's job and vpay has
no repair path for it. a_cancel_racing_a_settlement_leaves_one_terminal_state_and_one_event
in backends/crates/vpay-db/tests/repositories.rs forces the interleaving with
a barrier and pins all four halves: one status, one event, the settlement's
error, and the charge unchanged.
Updated 2026-09-03 (Step 3): confirm now moves the intent, because it
now reaches a rail. It commits a charge in submitting, records the
attempt, awaits adapter.submit(..), and then does one of four things —
which one is decided by the error's own classification, never by anything
the handler knows about rails:
- push rail accepts → charge
submitted, intentprocessing,200withnext_action: null(a_push_confirm_the_rail_accepts_moves_the_intent_to_processing,backends/tests/integration/tests/confirm_rails.rs); - redirect rail accepts → charge
submittedcarrying the rail'spay_tokenandredirect_url, intentrequires_action,200withnext_action.redirect_to_url. The rail's material and the merchant'sreturn_urlare committed before the response is built, and thenext_actionis rendered only from the committed charge row (redirect_confirm_commits_the_rails_material_before_it_answers); - the rail declines (
ProviderError::Rejected) → chargefailedwith itsfailure_code, intent staysrequires_payment_methodcarryinglast_payment_error, and the merchant gets409 charge_declined. The lifecycle has nofailedintent status, and a retry is a new intent (a_payer_the_rail_does_not_know_is_a_decline_the_merchant_can_read,credentials_the_rail_refuses_are_a_page_and_a_terminal_charge); - anything else (transport, malformed, misconfiguration) → nothing
moves. The charge stays
submittingand the attempt stays unanswered, because we do not know what the rail did (an_unreachable_rail_leaves_the_charge_where_recovery_expects_it).
last_payment_error (columns since migration 0014) is written by the
decline path and read back by GET. One charge per intent is enforced at
the API level as well as by the index
(a_second_confirm_cannot_produce_a_second_charge).
Updated 2026-09-03 (Step 4): succeeded happens, and the worker is what
makes it happen. A confirmed intent no longer stops at
processing/requires_action. The poll_charge job committed with the charge
drives it to a terminal state:
- the rail reports the payment → charge
succeededcarrying the rail'sprovider_txn_id(migration0021), intentsucceededwithamount_received = amount, and onepayment_intent.succeededevent — all in one transaction (vpay_db::Settlement::apply_succeeded), so there is no state in which the intent is paid and the event is missing (a_confirmed_payment_is_driven_to_succeeded_and_the_merchant_sees_it,backends/tests/integration/tests/worker_e2e.rs, which drives a real confirm through the real loop against a WireMock rail and reads the result back throughGET /v1/payment_intents/{id}); - the rail reports a decline after submission → charge
failedwith itsfailure_code/failure_raw, intent back torequires_payment_methodcarryinglast_payment_error, and onepayment_intent.payment_failedevent, in the same single transaction (apply_failed→payment_intents::fail_after_submission). This is the transition this document describes and nothing could previously perform:record_payment_errorstamps the error without moving the status, so a sibling writer was added that does both in one statement (a_decline_after_submission_returns_the_intent_to_requires_payment_method). A retry is still a new intent — the charge is terminal andone_charge_per_intentis forever; - the rail never answers → after 24 hours the charge moves to
unresolvedand a human is alerted, while the intent stays where it is.unresolvedis an escalation, not a verdict; the charge is still polled hourly and a late success settles it normally (reconciler.md).
The settlement's intent guard accepts processing, requires_action and
requires_payment_method, because a confirm that crashed before it could move
the intent leaves a live charge against an intent still reading
requires_payment_method — see crash-safety.md.
What still has never happened. canceled after a confirm (cancel is legal
only from requires_payment_method and refuses an intent with a live charge —
by design, not by omission); any partial amount_received, because neither
rail can collect part of an amount and ChargeStatus::Succeeded carries no
amount at all; prompt_expired_at and the payment_intent.processing
milestone (reconciler.md); and any of this against a real
rail — every settlement observed so far came from a WireMock host.
Since Step 9 the settlement transaction also moves a checkout session when
one drove the payment — paid/complete on success, failed/expired on a
terminal decline, in the same commit as the intent's own status, so the two can
never be observed disagreeing (vpay_db::checkout_sessions::settle_for_intent,
called from vpay_db::settlement). Nothing else about the lifecycle changed: a
session is a view of one checkout attempt and moves no money
(hosted-checkout.md).
A checkout session also ends on its own: the worker's hourly housekeeping sweep
moves an open session past its 24-hour horizon to expired, unless its intent
has a charge the rail may still be acting on — the clock never overrules a live
payment. payment_status is untouched by that, exactly as by a merchant's own
expire. Since 2026-09-04 that transition also emits one
checkout.session.expired, in the same commit as the flip, and it is the
only one of the session's four movers that emits anything: a settlement
already sends a payment_intent.* event for the same thing happening, and a
merchant's own expire tells them what they just asked for
(webhooks.md, hosted-checkout.md).
See ../status.md.