Definition
- 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.
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();
}
}
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.
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.
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
}
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.
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!"
}
}
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());
}
}
}
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.
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.
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");
}
}
}
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.
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
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.
---