Skip to content

Latest commit

 

History

History
273 lines (234 loc) · 16.1 KB

File metadata and controls

273 lines (234 loc) · 16.1 KB

Payment lifecycle

Two flow shapes

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.

States

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 --> [*]
Loading

(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.)

What each transition means

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.

One charge per intent, forever

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.

Status

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_intents writes a row in requires_payment_method — the status comes from IntentStatus::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}/cancel moves requires_payment_method → canceled as 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, and cancel_refuses_an_intent_with_a_live_charge_and_allows_one_with_a_terminal_charge in backends/crates/vpay-db/tests/repositories.rs). Since 2026-09-10 (issue #57) it emits one payment_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::PaymentIntents lost the method, and TxRepositories::cancel_in_tx is 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, intent processing, 200 with next_action: null (a_push_confirm_the_rail_accepts_moves_the_intent_to_processing, backends/tests/integration/tests/confirm_rails.rs);
  • redirect rail accepts → charge submitted carrying the rail's pay_token and redirect_url, intent requires_action, 200 with next_action.redirect_to_url. The rail's material and the merchant's return_url are committed before the response is built, and the next_action is rendered only from the committed charge row (redirect_confirm_commits_the_rails_material_before_it_answers);
  • the rail declines (ProviderError::Rejected) → charge failed with its failure_code, intent stays requires_payment_method carrying last_payment_error, and the merchant gets 409 charge_declined. The lifecycle has no failed intent 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 submitting and 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 succeeded carrying the rail's provider_txn_id (migration 0021), intent succeeded with amount_received = amount, and one payment_intent.succeeded event — 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 through GET /v1/payment_intents/{id});
  • the rail reports a decline after submission → charge failed with its failure_code/failure_raw, intent back to requires_payment_method carrying last_payment_error, and one payment_intent.payment_failed event, 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_error stamps 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 and one_charge_per_intent is forever;
  • the rail never answers → after 24 hours the charge moves to unresolved and a human is alerted, while the intent stays where it is. unresolved is 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.