DEV Community

Payment Flow Engineering
Payment Flow Engineering

Posted on Edited on

Modeling Wallet-to-Card Transfers as a State Machine

A wallet-to-card transfer may look like a simple form with an amount, a destination card, and a confirmation button. But the real challenge begins after the user clicks Transfer.

A request can time out even after the server has accepted it. A payment provider may process a transaction asynchronously. A user may refresh the page while the transfer is still pending. If the interface treats every network error as a failed payment, it can display incorrect balances or encourage duplicate transfers.

A more reliable approach is to model the transaction as an explicit state machine.

1. Define the transaction states

A basic transfer lifecycle can include four states:

  • *Initiated *– the transfer has been submitted for processing.
  • *Pending *– the operation is being processed, but its final outcome is unknown.
  • *Confirmed *– the system has authoritative evidence that the transfer completed successfully.
  • *Failed *– the system has authoritative evidence that the transfer did not complete.


The exact states depend on the payment provider and the application's business rules. Some systems also need states such as cancelled, expired, or reversed.

The important distinction is between the transaction lifecycle and the HTTP request lifecycle. A request can fail or time out while the underlying transaction continues.

For example, a timeout does not prove that a transfer failed. It only means the client did not receive a response in time.

The frontend should display the latest known transaction state rather than infer the outcome from network errors or loading indicators.

2. Separate available and pending balances

A single balance number may not accurately represent a wallet's financial state.

At minimum, the interface may need to distinguish between:

  • Available balance: funds that can be used for new operations.
  • Pending amount: funds associated with transactions that have not reached a final state.
  • Posted or settled balance: funds recorded according to the system's accounting rules.

Suppose a user has 500 units available and initiates a transfer of 120 units. If the system reserves the amount when processing begins, the interface might show 380 units available and a transfer of 120 units pending.

This is an illustrative model, not a universal accounting rule. The backend must define when funds are reserved, debited, or released.

Avoid blindly updating balances on the client. If the frontend subtracts the amount and restores it after a timeout, it may show an incorrect balance when the server has already accepted the transfer.

The backend should remain the source of truth, while the UI clearly distinguishes confirmed data from provisional information.

3. Prevent duplicate transfers with idempotency

Disabling the Transfer button while a request is in progress helps prevent accidental double-clicks, but it does not protect against retries, network failures, or concurrent requests.

One common solution is an idempotency key. Stripe describes this approach in its idempotent requests documentation.

The key identifies one logical transfer attempt. If the request times out, retrying the same operation should reuse the original key rather than create a new one. A genuinely new transfer should receive a new key.

The backend must enforce this behavior. Repeated requests with the same key should not create multiple financial operations, and incompatible reuse should be rejected.

Idempotency reduces duplicate execution within the guarantees of the implementation. It does not eliminate the need for provider-side safeguards, transaction reconciliation, or appropriate ledger controls.

4. Treat timeouts as unknown outcomes

Consider a common failure scenario:

  1. The user submits a transfer.
  2. The backend sends the operation to a payment provider.
  3. The provider accepts it.
  4. The response is lost.
  5. The frontend times out.

The client cannot conclude that the transfer failed. It needs to check the existing transaction's status.

A reliable recovery flow should preserve the transaction identifier, retrieve the latest status from the backend, and let the user check again if the result remains unknown. After a page reload, the application should restore the transaction from the server rather than rely on temporary frontend state.

A pending transfer should remain pending until the system receives reliable evidence of a final outcome.

5. Design clear errors and recovery paths

Payment errors should explain what is known and what the user can safely do next.

For example, “Transfer failed. Try again” is misleading if the transaction's status is unknown. A better message is: “We couldn't verify the transfer status. Check the existing transaction before starting another transfer.”

Different situations require different responses:

-** Validation error**: explain what needs to be corrected.

  • Confirmed failure: explain the issue and provide a relevant next step.
  • Request timeout: explain that the result is unknown and direct the user to check the transaction.
  • Pending transfer: show that processing is ongoing.
  • Status service unavailable: allow the user to retry the status check later.

Accessibility matters, too. Important status updates should be available to assistive technologies. The W3C WAI-ARIA Alert Pattern provides guidance on communicating dynamic messages. Not every payment error needs a modal dialog; inline messages are often sufficient.

6. Test beyond the happy path

A reliable transfer flow should be tested under realistic failure conditions, including:

  • A timeout after the backend has accepted the request.
  • Multiple requests using the same idempotency key.
  • A pending transfer after a page reload.
  • Duplicate or out-of-order provider notifications.
  • A confirmed failure followed by balance reconciliation.
  • Temporary unavailability of the status endpoint.

Tests should verify that duplicate events do not produce duplicate financial effects and that the UI never presents an unknown outcome as a confirmed failure.

Conclusion

Reliable wallet-to-card transfers require more than a loading spinner and a success screen. They need explicit transaction states, clear balance semantics, safe retries, authoritative status updates, and predictable recovery paths.

The key engineering principle is simple: a request that did not return a response is not the same as a transaction that definitively failed.

Model the transaction first, build the interface around that model, and test the failure paths as carefully as the successful ones.

Top comments (0)