How I Diagnosed a Silent Double-Credit Bug in Production and Redesigned an Entire Payment Status Lifecycle
Documentations

How I Diagnosed a Silent Double-Credit Bug in Production and Redesigned an Entire Payment Status Lifecycle

The Incident

I discovered the issue after receiving a Stripe notification that a new customer had been created. Normally, a successful payment follows shortly after, but this time no payment came through, which prompted me to investigate.

What I found was unusual:

  • The customer's account showed a credit of $134.00
  • The booking was marked as a partial payment with -$134.00 outstanding
  • This was the customer's first time on the site, and the wallet feature had never been enabled

Three anomalies. One transaction. This post explains how I traced the root cause, fixed the immediate issue, and used the incident to harden the entire payment status lifecycle.


First Principles: How a Stripe Checkout Payment Flow Should Work

Before looking at what went wrong, it helps to establish what a Stripe Checkout integration is supposed to do:

  1. User clicks "Pay"
  2. The app creates a Stripe Checkout Session
  3. The app marks the booking as payment in progress
  4. The user is redirected to the Stripe-hosted payment page
  5. The user completes payment, fails payment, or abandons checkout
  6. Stripe redirects the user back to the app using a success_url or cancel_url
  7. Stripe also sends a server-to-server webhook event
  8. The app uses the webhook to authoritatively confirm or reject the booking

The important point is that the redirect from Stripe is only a user experience convenience. It is not a reliable payment confirmation signal. The only authoritative confirmation comes from the webhook.


Root Cause Analysis

Why Did the Customer Get Credit?

Tracing the code path showed that the confirmPayment() method, triggered when the customer landed on the success_url, was doing far more than just redirecting. It immediately marked the booking as paid and fired a BookingUpdatedEvent.

That event included wallet credit logic that was supposedly disabled, but only in the UI. On the server side, the listener still allowed the credit to be applied.

The failed 3D Secure authentication did not prevent this because the success_url redirect occurred before Stripe had fully confirmed the final payment result. This created a classic race condition between the client-side redirect and Stripe's asynchronous payment settlement flow.

Why Was the Amount -$134.00?

The booking's paid amount was set to the full expected total of $134.00 during the success redirect. Later, the payment_intent.succeeded webhook attempted to reconcile the payment using Stripe's amount_received value, which was $0.00 because the payment had actually failed.

That mismatch caused the booking to display as a partial payment with -$134.00 outstanding.


The Fix Architecture

I broke the solution into three layers:

  1. Immediate fix - remove payment confirmation logic from confirmPayment() and make the webhook the sole authority
  2. Status lifecycle redesign - clarify the meaning of each booking status
  3. UX recovery paths - make sure customers can safely recover from failed or abandoned payment attempts

Layer 1: Webhook-Only Confirmation

The change to confirmPayment() was simple and deliberate: strip out all booking state mutations and leave it as a redirect only.

Real payment confirmation now happens exclusively through the Stripe webhook. The payment_intent.succeeded event became the single source of truth for marking a booking as paid.

Most importantly, the paid amount is now taken directly from Stripe's amount_received field rather than any local assumption. That means the platform records exactly what Stripe actually captured.


Layer 2: Booking Status Lifecycle Redesign

The original booking statuses had become overloaded. The same status value meant different things in different parts of the application, which made race conditions much easier to introduce.

Before

Status Meaning
PENDING Guest checkout waiting for email or OTP authentication
UNPAID Authenticated, payment required
PAID Payment confirmed

After

Status Precise meaning
DRAFT Unauthenticated guest, tickets not held, waiting for OTP authentication
UNPAID Authenticated, payment required, no active payment attempt
PENDING Stripe or PayPal session created, customer at payment provider, tickets held
PAID Webhook confirmed, booking complete

This redesign led to several important improvements:

  • Ticket availability: Tickets are now locked as soon as a customer is redirected to Stripe
  • Checkout access control: The checkout form blocks PENDING bookings to prevent duplicate payment attempts
  • Idempotent submissions: Re-submitting checkout for a PENDING booking reuses the existing Stripe session instead of creating a new one

Layer 3: UX Recovery Paths

Once the status model was tightened, I also needed to handle the case where a customer left Stripe and came back later.

To solve that, I added a getActiveCheckoutUrl() method that inspects the Stripe session and returns one of three outcomes:

  • A URL string - the session is still open, so the customer can be redirected back to Stripe
  • false - payment has been captured and the webhook is still in transit, so the UI shows a processing message
  • null - the session expired or was abandoned, so the booking is reset to UNPAID and checkout can start again

This method is used in both the checkout page and the checkout form handler so every return path into the payment flow is handled consistently.

Stripe API Version Note

One subtle issue surfaced during this work: Stripe's session->status field only exists from API version 2022-08-01 onward. On older configurations it returns null, which can silently break session logic.

Using session->url together with session->payment_status proved to be the more robust and portable approach.


Handling Failed Payments Gracefully

Another issue was that the payment_intent.payment_failed webhook event had no handler. If Stripe sent that event, it was ignored and the booking could remain stuck in PENDING.

The fix was to add a proper failure handler that updates the payment record and resets the booking to UNPAID, allowing the customer to retry payment cleanly.


UI Changes: Closing the Feedback Loop

A stronger backend needed matching UI updates so customers could clearly understand the state of their booking.

Before: every PENDING booking displayed a "Pay now" button, even when Stripe had already captured the payment.

After:

  • When booking->status = pending and payment->status = pending, the UI shows a blue message: "Payment received! Your booking will be confirmed automatically within a few minutes."
  • When booking->status = pending but payment->status = draft, the UI shows a "Complete Payment" button so the customer can return to Stripe
  • In booking history, the "Pay now" action is replaced with a "Processing" badge while the webhook is still in transit

What This Incident Taught Me

1. Never trust the success redirect for payment confirmation

The success URL is a convenience for the customer, not a secure confirmation mechanism. Webhooks must be the source of truth.

2. Idempotency is a design requirement

Payment flows must be safe to repeat. If Stripe retries a webhook or a customer submits the checkout form twice, the result should still be correct and should never create duplicate charges.

3. Status fields are contracts, not labels

Each status should have one precise meaning. Once that meaning is enforced consistently, a whole class of bugs disappears.

4. API version compatibility matters

Small differences in provider API versions can break assumptions in production. Using long-supported fields is often the safer path.

5. Server-side enforcement is essential

The wallet feature looked disabled in the UI, but the underlying server-side logic was still active. UI checks are not security controls. Business rules must always be enforced on the server.


Summary of Changes

Area Change
confirmPayment() Redirect only, with no booking state mutations
Webhook: payment_intent.succeeded Uses Stripe amount_received; sole authority for marking bookings as paid
Webhook: payment_intent.payment_failed Added handler to reset bookings to UNPAID and allow retry
cancelPayment() Accepts PENDING bookings and resets them to UNPAID
getActiveCheckoutUrl() New method to resume open sessions, show processing state, or reset expired sessions
Ticket availability PENDING bookings now hold tickets during active payment attempts
Booking status model Redefined as DRAFT, UNPAID, PENDING, and PAID with precise meanings
Guest booking creation Initial status changed from PENDING to DRAFT
checkout() and doCheckout() Reuse active sessions, reset expired ones, and block duplicate payment attempts
Detail and history views Added processing banners, badges, and a complete-payment return path
   

Tech Stack

  • Backend: PHP 8.2, Laravel 10
  • Payment: Stripe Checkout Sessions, Stripe Webhooks
  • Frontend: Laravel Blade, vanilla JavaScript with AJAX polling for real-time status updates
  • Database: MySQL with lockForUpdate() transactions for concurrent booking protection
  • Infrastructure: GitLab CI/CD, Redis, Nginx

Get In Touch

Get in touch with me about work opportunities


HIRE ME

Quick Links

Extras

Social Media Links

Copyright © Mark Clarke, 2026