Why this matters: in this problem, the code that matters is small and specific: the confirm flow that catches a timeout and lands in UNKNOWN instead of FAILED, the create that replays instead of duplicating, and the reconciler that settles what's hanging. Interviewers watch for exactly those paths — and for ledger appends happening at settling moments, not scattered wherever. This walkthrough writes them in live order, narration included.
Vocabulary types first
enum PaymentState { CREATED, CONFIRMING, SUCCEEDED, FAILED, UNKNOWN }
record Money(long minorUnits, String currency) {} // cents, never floats
record LedgerEntry(String intentId, EntryType type, Money amount,
Instant at) {}
enum EntryType { CHARGE_SUCCEEDED, REFUND_ISSUED, CORRECTION }
class PaymentIntent {
final String id;
final String idempotencyKey; // client-supplied, fixed at creation
final Money amount;
PaymentState state = PaymentState.CREATED;
Money refunded = Money.zero(); // tracks the refundable remainder
}Two narration beats while typing: money is integer minor units, because floating-point cents is how you end up explaining a missing penny to finance; and the idempotency key is final on the intent — set once at creation, it never changes, which is the whole point.
The gateway port, with the method everyone forgets
interface PaymentGateway {
// Throws GatewayTimeout when the outcome is genuinely unknown.
ChargeResult charge(String idemKey, Money amount, Duration timeout)
throws GatewayTimeout;
// The reconciler's tool: what happened to this key, really?
Optional<ChargeResult> lookup(String idemKey);
}Say it as you write lookup: "this is the method that makes UNKNOWN survivable — without a way to ask the gateway what happened, unknown would be a dead end." In tests, a stub implementation flips between success, decline, and thrown timeouts on command; that stub is all this problem needs — no HTTP client, no webhook plumbing.
Create: replay, don't duplicate
class PaymentService {
Map<String, PaymentIntent> intents; // intentId -> intent
Map<String, String> byIdemKey; // idemKey -> intentId
List<LedgerEntry> ledger; // append-only, ever
PaymentGateway gateway;
PaymentIntent createIntent(Money amount, String idemKey) {
String existing = byIdemKey.get(idemKey);
if (existing != null)
return intents.get(existing); // replay: same intent back
PaymentIntent intent = new PaymentIntent(newId(), idemKey, amount);
intents.put(intent.id, intent);
byIdemKey.put(idemKey, intent.id);
return intent;
}The double-click test: two identical create calls, one intent. The second call returns the first's result — no error, no duplicate. Idempotency means replays are boring.
Confirm: the timeout lands in UNKNOWN
PaymentState confirm(String intentId) {
PaymentIntent intent = intents.get(intentId);
if (intent.state == PaymentState.SUCCEEDED
|| intent.state == PaymentState.UNKNOWN)
return intent.state; // replay-safe: change nothing
intent.state = PaymentState.CONFIRMING;
try {
// The SAME key rides every attempt — the gateway dedups on it,
// so a retried confirm cannot bill twice.
ChargeResult r = gateway.charge(intent.idempotencyKey,
intent.amount,
Duration.ofSeconds(30));
settle(intent, r);
} catch (GatewayTimeout t) {
intent.state = PaymentState.UNKNOWN; // NOT FAILED. We don't know.
}
return intent.state;
}
private void settle(PaymentIntent intent, ChargeResult r) {
if (r.approved()) {
intent.state = PaymentState.SUCCEEDED;
ledger.add(new LedgerEntry(intent.id, EntryType.CHARGE_SUCCEEDED,
intent.amount, now()));
} else {
intent.state = PaymentState.FAILED; // gateway SAID no
}
}Point at the catch block and hold the room there for a second: "this is the whole problem. The timeout path sets UNKNOWN — failed is reserved for the gateway actually declining." Then point at settle: the ledger append lives inside the settling function, so success-and-entry happen together or not at all — there is no code path that reaches SUCCEEDED without writing the ledger, and none that writes the ledger without settling.
The reconciler: the exit from UNKNOWN
void reconcile() {
for (PaymentIntent intent : intentsIn(PaymentState.UNKNOWN)) {
Optional<ChargeResult> r = gateway.lookup(intent.idempotencyKey);
if (r.isPresent())
settle(intent, r.get()); // same settle: state + ledger
// gateway still can't say? stay UNKNOWN — never guess.
}
}Small loop, load-bearing lines. It reuses settle, so reconciled payments hit the exact same state-plus-ledger discipline as prompt ones. And the no-answer case stays unknown — the system tolerates prolonged ignorance and never converts it to a guess.
Refunds: validate against the remainder
void refund(String intentId, Money amount) {
PaymentIntent intent = intents.get(intentId);
require(intent.state == PaymentState.SUCCEEDED,
"only settled payments refund");
require(amount.lte(intent.amount.minus(intent.refunded)),
"exceeds refundable remainder");
// own gateway call, own small lifecycle; on success:
intent.refunded = intent.refunded.plus(amount);
ledger.add(new LedgerEntry(intent.id, EntryType.REFUND_ISSUED,
amount, now()));
}The remainder check is the line to narrate: three partial refunds of 20 against a 50 payment — the third must bounce. History stays honest because each refund is its own ledger entry against the original; nothing about the charge entry changes.
What you'd say about complexity
Every operation is hash-map work plus one gateway call — O(1) locally, and the gateway's 30-second worst case towers over everything, which is why confirm must never loop on it synchronously. The reconciler is O(u) in unknown payments per sweep; at thousands of payments a day with rare timeouts, u is small — but say the honest bit: its cost is bounded by gateway calls, so it batches or paces itself rather than hammering a gateway that's already unhealthy.
Key takeaway
Write it in this order: states and money types, the gateway port with both charge and lookup, create that replays on a seen key, confirm whose timeout catch sets UNKNOWN — never FAILED — and a reconciler that settles through the same settle() as the happy path, so ledger appends happen at settling moments and nowhere else. The three lines interviewers are watching for: the replay return in create, the UNKNOWN assignment in the catch block, and the remainder check in refund.