Back to Scenarios & Design Questions
Q40Question 40 of 67

(Scenario) A product manager wants the UI to show "payment confirmed" BEFORE the transaction is durably committed

Key Concepts
  • Never confirm before the durable commit because a rollback turns the confirmation into a lie
  • A false positive in payments is severe: the user may take irreversible real-world actions
  • For fast payments, confirm only after the database commit returns success
  • For long-running work, return 202 Accepted with an explicit pending state
  • The status transitions from PENDING to CONFIRMED only when the durable commit actually happens
  • The pending state must be explicit so the user does not take premature irreversible action
  • The confirmation transition must be idempotent and derived from the committed record

Definition

Never confirm before the durable commit, because a rollback turns your confirmation into a lie; confirm only on the real committed outcome, and for slow work return 202 with a pending state.
  • The danger – the commit can still fail (insufficient funds, constraint violation, crash) after showing confirmed.
  • False positive cost – the user may ship goods, stop chasing a bill, or take irreversible actions based on a lie.
  • Fast payments – confirm only after the durable commit returns success; show failure on rollback.
  • Long-running work – return 202 Accepted with an explicit PENDING state and a tracking handle.
  • Confirmation rule – the status transitions to CONFIRMED only when the durable commit of the final step has happened.
  • Idempotent transition – the confirmation must be derived from the committed record, not an in-memory flag.

It is like the teller shouting "Paid!" to Rajesh the moment he hands over the slip, before actually counting the cash and recording it in the ledger. If the cash count comes up short, Rajesh has already walked away believing he paid, but the bank's books say otherwise. The honest approach is to say "We are processing your payment" first, and only shout "Paid!" once the money is truly recorded.

Understanding the Concepts

Why is confirming before commit dangerous?

A payment is not real until the transaction that moves money and records it has durably committed. Before that, many things can still make it fail: insufficient funds, a constraint violation, a fraud check rejection, a dropped connection, or a crash that rolls the transaction back.

JAVA
package com.bank.payment;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class DangerousPaymentService {

    // WRONG: returns "confirmed" before commit
    public PaymentResult processWrong(String accountId, BigDecimal amount) {
        // Optimistically tell the UI "confirmed"
        uiNotifier.send(accountId, "Payment confirmed!"); // <-- LIE

        try {
            accountRepo.debit(accountId, amount); // May fail!
            // Commit happens here
        } catch (InsufficientFundsException e) {
            // Oops: already told user it was confirmed
            uiNotifier.send(accountId, "Actually, payment failed.");
            // User may have already acted on the false confirmation!
        }
        return PaymentResult.success();
    }
}
TEXT
Danger timeline:
  t=0ms:   UI shows "Payment confirmed!" (before commit)
  t=50ms:  Rajesh sees confirmation, closes app
  t=100ms: Database check: insufficient funds!
  t=101ms: Transaction rolled back
  Result:  Rajesh believes he paid. Bank says he did not.
           Reconciliation nightmare.

What are the real-world consequences?

The cost of a false positive in payments is severe and asymmetric. A wrongly-shown confirmation can lead to irreversible actions.

TEXT
Consequences of premature confirmation:
  1. Rajesh thinks bill is paid -> stops following up -> late fees
  2. Merchant sees "confirmed" -> ships goods -> no money arrives
  3. Priya transfers to friend -> friend sees "received" -> spends it
     -> rollback -> friend's account goes negative
  4. Audit trail shows confirmation at time T but no commit
     -> compliance violation

  Cost of honest "pending": slightly slower perceived UX
  Cost of false "confirmed": support tickets, reconciliation,
                              compliance issues, lost trust

How should fast payments work?

For fast synchronous payments, confirm only after the durable commit returns success.

JAVA
package com.bank.payment;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class CorrectPaymentService {

    private final AccountRepository accountRepo;

    public CorrectPaymentService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    @Transactional
    public PaymentResult process(String accountId, BigDecimal amount) {
        // Perform the charge
        accountRepo.debit(accountId, amount);
        // Transaction COMMITS here (end of @Transactional)
        // Only AFTER commit: return success
        return PaymentResult.confirmed(accountId, amount);
    }

    // If commit fails, exception propagates, UI shows error
    // Never shows "confirmed" unless money actually moved
}
TEXT
Correct flow:
  t=0ms:   UI shows spinner
  t=50ms:  Database commit succeeds
  t=51ms:  API returns 200 {"status": "CONFIRMED"}
  t=52ms:  UI shows "Payment confirmed!"
  Guarantee: confirmation = committed

  If commit fails:
  t=50ms:  Database rollback
  t=51ms:  API returns 400 {"error": "Insufficient funds"}
  t=52ms:  UI shows "Payment failed: insufficient funds"
  Guarantee: user never sees false confirmation

How should long-running payments work?

For slow work involving external gateways, return 202 Accepted with an explicit PENDING state. The confirmation transitions only when the durable commit of the final step happens.

JAVA
package com.bank.payment;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;

@RestController
public class LongRunningPaymentController {

    private final PaymentJobService jobService;

    public LongRunningPaymentController(PaymentJobService jobService) {
        this.jobService = jobService;
    }

    @PostMapping("/api/payments/international")
    public ResponseEntity<PaymentJob> pay(@RequestBody PaymentRequest req) {
        // Fast: validate + persist PENDING + enqueue
        PaymentJob job = jobService.accept(req);

        return ResponseEntity
            .status(HttpStatus.ACCEPTED)
            .location(URI.create("/api/payments/" + job.getId() + "/status"))
            .body(job);
        // UI shows: "Payment processing..."
        // NOT "Payment confirmed!"
    }
}
JAVA
package com.bank.payment;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PaymentWorker {

    private final PaymentJobRepository jobRepo;
    private final SwiftGateway swiftGateway;
    private final NotificationService notifications;

    public PaymentWorker(PaymentJobRepository jobRepo,
                          SwiftGateway swiftGateway,
                          NotificationService notifications) {
        this.jobRepo = jobRepo;
        this.swiftGateway = swiftGateway;
        this.notifications = notifications;
    }

    @Transactional
    public void process(String jobId) {
        PaymentJob job = jobRepo.findById(jobId);

        try {
            swiftGateway.send(job);
            // SWIFT confirms -> commit CONFIRMED status
            job.setStatus(PaymentStatus.CONFIRMED);
            jobRepo.save(job);
            // NOW notify: confirmation driven by committed outcome
            notifications.send(job.getAccountId(),
                "Payment confirmed: " + job.getAmount());
        } catch (Exception e) {
            job.setStatus(PaymentStatus.FAILED);
            jobRepo.save(job);
            notifications.send(job.getAccountId(),
                "Payment failed: " + e.getMessage());
        }
    }
}
TEXT
Long-running flow:
  t=0ms:   UI shows "Processing..." (PENDING)
  t=30s:   SWIFT confirms
  t=30.1s: Database commits CONFIRMED status
  t=30.2s: Push notification: "Payment confirmed!"
  Guarantee: confirmation always after durable commit

How should the status transitions work?

The status transitions from PENDING to CONFIRMED only when the durable commit happens, and to FAILED if it does not.

DIAGRAM
Status state machine:
  PENDING ──> PROCESSING ──> CONFIRMED (durable commit succeeded)
                          ──> FAILED    (commit failed or rolled back)

  Rules:
  - PENDING shown to user immediately (202 response)
  - PROCESSING shown during background work
  - CONFIRMED only after durable commit of final step
  - FAILED on any rollback or rejection
  - Transition to CONFIRMED is idempotent
    (duplicate webhook cannot flip FAILED to CONFIRMED)

Why must the transition be idempotent?

The confirmation must be derived from the committed record, not from an in-memory flag, so a replay or duplicate notification cannot flip a failed payment to confirmed.

JAVA
package com.bank.payment;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class IdempotentStatusTransition {

    private final PaymentJobRepository jobRepo;

    public IdempotentStatusTransition(PaymentJobRepository jobRepo) {
        this.jobRepo = jobRepo;
    }

    @Transactional
    public void confirmIfPending(String jobId) {
        // Conditional update: only transition from PROCESSING
        int updated = jobRepo.updateStatusConditional(
            jobId, PaymentStatus.PROCESSING, PaymentStatus.CONFIRMED);

        if (updated == 0) {
            // Already confirmed or failed: no-op (idempotent)
            System.out.println("Job " + jobId +
                " not in PROCESSING state, skipping");
        }
    }
}
TEXT
Idempotent transition:
  Webhook 1: confirmIfPending("JOB-123") -> PROCESSING -> CONFIRMED
  Webhook 2: confirmIfPending("JOB-123") -> already CONFIRMED -> no-op
  Duplicate webhook cannot flip FAILED to CONFIRMED

What should you tell the product manager?

Explain the asymmetric cost: a false confirmation has expensive real-world consequences, while an honest pending state with a fast 202 response still feels responsive.

| Approach | UX Speed | Correctness | Risk |
|---|---|---|---|
| Confirm before commit | Instant | Wrong | False positives, support tickets |
| Confirm after commit | ~50ms slower | Correct | None |
| 202 + pending (long work) | Instant ack | Correct | None |

What are common mistakes?

The most common mistake is sending a confirmation notification from an event handler that fires before the transaction commits.

TEXT
WRONG: @TransactionalEventListener(phase = BEFORE_COMMIT)
  Fires notification before commit -> commit may still fail
  User sees "confirmed" -> then gets "actually failed"

CORRECT: @TransactionalEventListener(phase = AFTER_COMMIT)
  Fires notification ONLY after commit succeeds
  User sees "confirmed" only when money has actually moved

Visual Overview

DIAGRAM
  WRONG: Confirm Before Commit
  Client ──POST──> Server: "Confirmed!" ──> DB: COMMIT
                                              (may fail!)
  User acts on false confirmation.

  CORRECT (Fast): Confirm After Commit
  Client ──POST──> Server ──> DB: COMMIT ──> "Confirmed!"
                                              (only if committed)

  CORRECT (Long): 202 + Pending + Eventual Confirm
  Client ──POST──> Server: 202 PENDING ──> Queue
  Client <──poll── Status: PROCESSING
  Client <──poll── Status: CONFIRMED     (after durable commit)

  Status State Machine:
  ┌─────────┐     ┌────────────┐     ┌───────────┐
  │ PENDING │ ──> │ PROCESSING │ ──> │ CONFIRMED │
  └─────────┘     └──────┬─────┘     └───────────┘
                         │
                         └──> ┌────────┐
                              │ FAILED │
                              └────────┘

Key Takeaway

When a product manager asks to show "payment confirmed" before the durable commit, the answer is a clear no. A payment is not real until the transaction has durably committed, and before that the operation can still fail: insufficient funds, constraint violation, fraud rejection, or crash rollback. A false confirmation leads to irreversible real-world actions: Rajesh believes he paid, a merchant ships goods, or a friend spends money that will be clawed back.

The design principle is that the user-facing confirmation must always be driven by the real committed outcome. For fast payments, confirm only after the database commit returns success. For long-running work, return 202 Accepted with an explicit PENDING state and a tracking handle. The status transitions to CONFIRMED only when the durable commit of the final step has happened, and to FAILED if it did not. The app still feels responsive because of the immediate 202, but it never tells a customer something false.

At National Bank of India, some optimistically-confirmed payments later failed the final fraud commit, leaving customers believing they had paid and creating reconciliation tickets. The fix was to confirm only after the commit, use AFTER_COMMIT event listeners for notifications, and make the status transition idempotent so duplicate webhooks cannot flip a failed payment to confirmed.

Remember one sentence: never confirm before the durable commit because a rollback makes the confirmation a lie, so confirm only on the committed outcome, and for slow work return 202 with a pending state the client polls.

---

Share
Use ← → arrow keys to navigate