Level 5: thiết kế Payment System

4 câu hỏi~35 phútTừ khóa: idempotency key + UNIQUE, PROCESSING/UNKNOWN, timeout ≠ failed, webhook idempotent, reconciliation, @Transactional không cover HTTP, Saga refund, Outbox, ledger double-entry

Tiếp đi: Level 5, Payment.

Booking dẫn thẳng sang Payment. Đây là bài mà "timeout = failed" sẽ làm hệ thống mất tiền thật. Insight lớn nhất: phải model trạng thái UNKNOWN và reconcile, thay vì đoán kết quả.

1. Đề bài và requirements

Thiết kế chức năng thanh toán cho Booking/Order qua một Payment Provider/Bank bên ngoài.

User → Booking Service → Payment Service → Payment Provider / Bank → 100,000đ

2. Estimate

Peak: ~1,000 payment/s (đợt mở bán vé)
Provider latency: p50 800ms, p99 vài giây, đôi khi timeout
Mỗi payment ≈ 1 row payment + 2-4 ledger entry + 1-3 webhook event

Throughput không khó. Cái khó là correctness khi network, provider và app có thể hỏng ở bất kỳ bước nào.

3. API

POST /payments
Idempotency-Key: PAY-BOOKING-123
{ "bookingId": "BOOKING-123", "amount": 100000, "currency": "VND" }
→ 202 { "paymentId": "...", "status": "PROCESSING" }

GET  /payments/{id}               → status hiện tại
POST /payments/webhook            ← provider gọi (verify chữ ký HMAC)
POST /payments/{id}/refunds       Idempotency-Key: REFUND-BOOKING-123

4. Data model

CREATE TABLE payment (
    id                  UUID PRIMARY KEY,
    booking_id          VARCHAR(64)  NOT NULL,
    idempotency_key     VARCHAR(64)  NOT NULL,
    amount              BIGINT       NOT NULL,       -- đơn vị nhỏ nhất, không dùng double
    currency            CHAR(3)      NOT NULL,
    status              VARCHAR(20)  NOT NULL,       -- CREATED, PROCESSING, SUCCEEDED, FAILED, UNKNOWN
    provider_reference  VARCHAR(100),
    created_at          TIMESTAMP    NOT NULL,
    updated_at          TIMESTAMP    NOT NULL,
    CONSTRAINT uk_payment_idem UNIQUE (idempotency_key)
);

CREATE TABLE processed_provider_event (
    provider_event_id   VARCHAR(100) PRIMARY KEY,    -- chặn webhook trùng
    received_at         TIMESTAMP    NOT NULL
);

CREATE TABLE ledger_entry (
    id              BIGSERIAL PRIMARY KEY,
    transaction_id  UUID        NOT NULL,            -- một money movement
    account         VARCHAR(50) NOT NULL,            -- CUSTOMER_RECEIVABLE, MERCHANT_PAYABLE, ...
    direction       VARCHAR(6)  NOT NULL,            -- DEBIT / CREDIT
    amount          BIGINT      NOT NULL CHECK (amount > 0),
    created_at      TIMESTAMP   NOT NULL
);   -- append-only, không UPDATE/DELETE

CREATE TABLE outbox ( id UUID PRIMARY KEY, type VARCHAR(50), payload JSONB, created_at TIMESTAMP, published_at TIMESTAMP );

5. Problem 1: user bấm Pay hai lần → idempotency key + UNIQUE

click PAY, click PAY, frontend timeout rồi retry
→ mỗi request một charge → $100 $100 $100 💥

Client gửi Idempotency-Key gắn với business operation (thanh toán booking 123), không phải với từng HTTP request. Đừng chỉ check bằng Java:

if (!repo.existsByIdempotencyKey(key)) { repo.save(...); }   ❌ hai instance cùng thấy "chưa có"

Để DB chặn bằng UNIQUE:

public Payment startPayment(String idemKey, PaymentRequest req) {
    try {
        return txTemplate.execute(s -> repo.saveAndFlush(Payment.created(idemKey, req)));
    } catch (DataIntegrityViolationException dup) {
        Payment existing = repo.findByIdempotencyKey(idemKey).orElseThrow();
        if (!existing.matches(req)) {
            throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Key reused with different body");
        }
        return existing;     // trả kết quả/trạng thái cũ, KHÔNG charge lại
    }
}

Truyền tiếp key này sang provider (hầu hết payment provider nhận idempotency key) để chính provider cũng không charge 2 lần. Lý thuyết idempotency ở Bài 8.1.

6. Problem 2: provider timeout → UNKNOWN

Payment → Bank: charge $100 ✅
              response lost
Payment: TIMEOUT → không biết bank đã trừ tiền chưa

timeout ≠ failed. Đánh FAILED rồi retry có thể double charge; release ghế thì user mất tiền mà không có vé.

                CREATED
                   ↓
               PROCESSING
              /     |      \
       SUCCEEDED  FAILED   UNKNOWN   ← timeout / 5xx mơ hồ
                            │
                 webhook / status query / reconciliation
                            ↓
                   SUCCEEDED | FAILED
public void charge(Payment p) {
    repo.transition(p.getId(), CREATED, PROCESSING);           // commit trước khi gọi ra ngoài
    try {
        ProviderResult r = provider.charge(p.getIdempotencyKey(), p.getAmount(), p.getCurrency());
        if (r.succeeded()) completeSucceeded(p.getId(), r.reference());   // ghi ledger + outbox
        else               completeFailed(p.getId(), r.errorCode());      // lỗi rõ ràng: thẻ bị từ chối
    } catch (ResourceAccessException | ProviderServerErrorException ex) {
        repo.transition(p.getId(), PROCESSING, UNKNOWN);       // trung thực: chưa biết
    }
}

UNKNOWN không phải lỗi thiết kế. Nó cho thấy hệ thống trung thực với những gì nó biết.

7. Problem 3–4: webhook trùng và webhook bị mất

Webhook trùng: provider gửi PAYMENT_SUCCESS 3 lần. Handler phải idempotent: lưu provider_event_id và chuyển trạng thái có điều kiện, cả hai trong một transaction.

@PostMapping("/payments/webhook")
public ResponseEntity<Void> webhook(@RequestHeader("X-Signature") String sig, @RequestBody String body) {
    if (!signatureVerifier.isValid(body, sig)) return ResponseEntity.status(401).build();
    ProviderEvent e = parse(body);
    webhookService.handle(e);
    return ResponseEntity.ok().build();      // 2xx để provider ngừng gửi lại
}

@Transactional
public void handle(ProviderEvent e) {
    if (eventRepo.insertIfAbsent(e.id()) == 0) return;          // đã xử lý event này
    if (e.type() == SUCCEEDED) {
        // chỉ đi tiếp từ PROCESSING/UNKNOWN, không lùi trạng thái đã cuối
        if (repo.transitionFrom(e.paymentId(), Set.of(PROCESSING, UNKNOWN), SUCCEEDED) == 1) {
            ledger.recordCharge(repo.findById(e.paymentId()).orElseThrow());
            outbox.save("PAYMENT_SUCCEEDED", e.paymentId());
        }
    }
}

Webhook bị mất: không dựa 100% vào webhook. Chạy reconciliation:

@Scheduled(fixedDelay = 30_000)
public void reconcile() {
    Instant cutoff = Instant.now().minusSeconds(60);
    for (Payment p : repo.findStuck(Set.of(PROCESSING, UNKNOWN), cutoff)) {
        ProviderStatus s = provider.queryStatus(p.getIdempotencyKey());
        switch (s) {
            case SUCCEEDED -> completeSucceeded(p.getId(), s.reference());
            case FAILED    -> completeFailed(p.getId(), "PROVIDER_FAILED");
            case NOT_FOUND -> { /* provider chưa từng nhận: an toàn để FAILED hoặc retry cùng key */ }
            default        -> { /* vẫn pending: để lần sau, quá X giờ thì alert người xử lý */ }
        }
    }
}

Thêm một lớp nữa: đối soát cuối ngày với settlement file của provider để bắt mọi lệch.

8. Problem 5: có @Transactional rồi sao vẫn phức tạp?

Xem câu hỏi phụ bên dưới. Tóm tắt: transaction chỉ cover DB local, không cover HTTP call tới bank.

9. Problem 6: payment thành công nhưng booking fail → Saga / refund

Payment SUCCEEDED (user -100K) → Booking Service 💥 (ghế đã EXPIRED)
Không thể ROLLBACK bank.charge()
ActionCompensation
Reserve seatRelease seat
Charge paymentRefund payment
Create shipmentCancel shipment

Refund là một financial operation mới, không phải rollback: cũng cần idempotency key (REFUND-BOOKING-123), state machine riêng, retry và reconciliation. Chi tiết Saga ở Bài 14.6.

10. Problem 7: DB update xong nhưng publish event fail → Outbox

UPDATE payment → SUCCEEDED ✅
publish PAYMENT_SUCCEEDED   ❌ app crash
→ Booking không bao giờ biết để chuyển BOOKED
BEGIN
  UPDATE payment SET status='SUCCEEDED'
  INSERT ledger_entry ...
  INSERT outbox (PAYMENT_SUCCEEDED)
COMMIT
Relay (poller / Debezium CDC) → Kafka/SQS → Booking consumer (idempotent)

Outbox cho at-least-once, nên consumer vẫn phải idempotent. Chi tiết ở Bài 14.7.

11. Problem 8: tiền thay đổi cần lịch sử → ledger double-entry

Chỉ lưu balance = 1,500,000 → có bug thì không biết vì sao.

Ledger (append-only):
Deposit       +2,000,000
Payment #100    -200,000
Payment #101    -100,000
Refund  #100    +200,000
Payment #102    -400,000
-------------------------
Balance        1,500,000   (derived)

Double-entry: mỗi money movement gồm ít nhất hai entry, tổng DEBIT = tổng CREDIT. Tiền không tự sinh ra hay mất đi, chỉ đi từ account này sang account khác.

Charge 100,000 (transaction T1):
  DEBIT  CUSTOMER_RECEIVABLE   100,000
  CREDIT MERCHANT_PAYABLE      100,000

Refund (transaction T2): ghi entry đảo chiều, KHÔNG sửa/xóa T1
  DEBIT  MERCHANT_PAYABLE      100,000
  CREDIT CUSTOMER_RECEIVABLE   100,000

Kiểm tra: SELECT transaction_id FROM ledger_entry GROUP BY transaction_id
          HAVING SUM(CASE direction WHEN 'DEBIT' THEN amount ELSE -amount END) <> 0;  -- phải rỗng
@Transactional(propagation = Propagation.MANDATORY)   // phải chạy trong transaction cập nhật payment
public void recordCharge(Payment p) {
    UUID tx = UUID.randomUUID();
    ledgerRepo.saveAll(List.of(
        LedgerEntry.debit(tx,  "CUSTOMER_RECEIVABLE", p.getAmount()),
        LedgerEntry.credit(tx, "MERCHANT_PAYABLE",    p.getAmount())));
}

Tiền lưu bằng BIGINT đơn vị nhỏ nhất hoặc BigDecimal, không bao giờ double.

12. Full architecture

                 Booking / Order Service
                          │ POST /payments (Idempotency-Key)
                          ▼
                    Payment Service ──────────→ Payment Provider
                     │     │     │   timeout, retry cùng key,  │
                     │     │     │   circuit breaker           │ webhook (HMAC)
                     │     │     └──── Webhook Handler ◀───────┘
                     │     │
           Payment DB (payment, ledger, outbox, processed_event)
                     │
             Outbox relay → Broker → Booking (BOOKED / release + refund)
                     │
       Reconciliation job ⇄ Provider status API / settlement file

 Metrics: UNKNOWN count & age, webhook lag, reconciliation mismatch, refund rate
Vai tròAWSAzure
APIAPI GatewayAPI Management
ComputeECS / EKSAKS / App Service
DBRDS / AuroraAzure SQL / PostgreSQL
Queue / eventsSQS, EventBridge, MSKService Bus, Event Grid
Secrets (API key provider)Secrets ManagerKey Vault
MonitoringCloudWatchAzure Monitor

Mình đã có @Transactional bọc cả hàm pay() rồi, lỗi thì Spring rollback hết, sao vẫn phức tạp vậy?

Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: Spring rollback được DB, không rollback được bank.

@Transactional
public void pay() {
    paymentRepository.save(payment);   // DB
    provider.charge();                 // HTTP tới bank
    payment.markSucceeded();           // DB
}
DB insert ✓ → Bank charge 100K ✓ → 💥 app crash
DB: ROLLBACK → không có payment record
Bank: user -100K ✓

@Transactional chỉ đảm bảo atomicity cho resource trong transaction đó, thường là DB local. HTTP call sang bank không tham gia. Còn tệ hơn: gọi HTTP trong transaction là giữ connection DB suốt thời gian chờ bank.

Cách đúng: chia thành các transaction ngắn và commit trạng thái trước khi gọi ra ngoài:

TX1: INSERT payment(PROCESSING) → COMMIT
     call provider (ngoài transaction, có idempotency key)
TX2: UPDATE → SUCCEEDED / FAILED / UNKNOWN + ledger + outbox → COMMIT

App chết giữa chừng thì record vẫn ở PROCESSING, reconciliation sẽ tìm và hỏi provider. Không có gì "biến mất". Các bẫy khác của @Transactional xem Bài 4.3.3.

Nếu DB của mình ghi PAYMENT_FAILED mà bank nói đã CHARGED thì tin ai? Làm sao hệ thống tự phát hiện và sửa?

Không chọn một bên rồi coi bên kia không tồn tại. Mỗi bên là authoritative cho một thứ:

AiLà nguồn sự thật cho
Provider / bankThực tế tiền đã bị trừ hay chưa (external financial reality)
Hệ thống mìnhBusiness state: booking, ledger nội bộ, refund đã yêu cầu chưa

Việc của mình là reconcile hai thế giới:

Reconciliation job / settlement file
   Our DB: PAY-123 = FAILED     Bank: PAY-123 = CHARGED
                 ↓ mismatch
   1. Ghi nhận lệch (audit), alert
   2. Sửa business state theo rule:
        booking còn giữ được  → SUCCEEDED → BOOKED
        booking đã mất ghế    → SUCCEEDED → refund (Saga)
   3. Ghi ledger entry tương ứng (không sửa entry cũ)

Nhưng tốt nhất là không để rơi vào FAILED sai ngay từ đầu: chỉ đánh FAILED khi provider trả lỗi rõ ràng; timeout thì UNKNOWN. Mismatch dạng này thường là dấu hiệu code đang coi timeout là failed.

Phỏng vấn hỏi "Design a payment system" thì trả lời sao?

Q1. How do you make sure a customer is charged only once?

🇻🇳 Tiếng Việt:

Mỗi thao tác thanh toán có một idempotency key gắn với nghiệp vụ, lưu với UNIQUE constraint trong DB, không chỉ check bằng code. Request trùng trả lại kết quả cũ. Em cũng truyền key đó sang provider để chính provider không charge lần hai khi em retry.

🇬🇧 English:

Every payment operation has a business-level idempotency key stored with a UNIQUE constraint in the database, not just checked in code. Duplicate requests return the existing result. I also pass the same key to the provider, so a retry never creates a second charge on their side.

Q2. The provider call times out. What do you do?

🇻🇳 Tiếng Việt:

Timeout không có nghĩa là thất bại. Em chuyển payment sang UNKNOWN, không retry mù và không release tài nguyên. Kết quả được xác định bằng webhook, gọi API query status, và job reconciliation định kỳ; webhook handler idempotent theo event ID.

🇬🇧 English:

A timeout is not a failure. I move the payment to UNKNOWN, don't retry blindly and don't release resources. The outcome is resolved through webhooks, status queries and a periodic reconciliation job, and the webhook handler is idempotent on the provider event ID.

Q3. Payment succeeded but the booking failed. Can you roll back?

🇻🇳 Tiếng Việt:

Không rollback được charge của bank vì nó nằm ngoài transaction DB. Em dùng Saga: bước bù trừ là refund, bản thân refund cũng idempotent và có state riêng. Các event giữa service phát bằng transactional outbox để không mất event khi DB đã commit.

🇬🇧 English:

We can't roll back a bank charge because it is outside our database transaction. I use a Saga where the compensating step is a refund, which is itself idempotent with its own state. Events between services go through a transactional outbox so they are not lost after the database commits.

Q4. Why a ledger instead of a balance column?

🇻🇳 Tiếng Việt:

Ledger là bản ghi append-only của mọi chuyển động tiền, nên audit và điều tra được. Theo double-entry, mỗi giao dịch có debit và credit bằng nhau, tổng luôn cân. Sửa sai bằng entry đảo chiều chứ không update hay delete. Balance là giá trị suy ra từ ledger.

🇬🇧 English:

A ledger is an append-only record of every money movement, so it is auditable. With double-entry, each transaction has equal debits and credits, so the books always balance. Corrections are reversing entries, never updates or deletes. Balances are derived from the ledger.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao if (!exists) insert() không đủ để chống double charge?

Hai instance có thể cùng thấy "chưa có" rồi cùng insert. Phải để DB enforce bằng UNIQUE(idempotency_key).

Khi nào đánh FAILED, khi nào UNKNOWN?

FAILED khi provider trả lỗi rõ ràng (thẻ bị từ chối). UNKNOWN khi timeout hoặc lỗi mơ hồ: tiền có thể đã bị trừ.

Webhook đã có, sao vẫn cần reconciliation?

Webhook có thể bị mất hoặc đến muộn. Job reconciliation chủ động hỏi provider cho các payment PROCESSING/UNKNOWN quá lâu.

Refund có phải rollback không?

Không. Đó là một financial operation mới với idempotency, state, retry riêng, và ghi ledger entry đảo chiều.

Double-entry đảm bảo điều gì?

Mỗi transaction có tổng debit = tổng credit, tiền chỉ di chuyển giữa các account, nên có thể kiểm tra sổ luôn cân và audit được.