M3 · Thanh toán an toàn

5 câu hỏi~4–5 ngày codeTừ khóa: Idempotency-Key, mock PSP, timeout, UNKNOWN, webhook, HMAC, reconciliation, at-least-once

Giữ ghế và hết hạn ổn rồi. Giờ M3: thanh toán. Mình sợ nhất là trừ tiền 2 lần và trường hợp PSP không trả lời.

Sợ đúng hai chỗ đó. Thanh toán là nơi "gần đúng" không được chấp nhận. Giao bài nhé.

1. Bối cảnh & bài toán

User confirm booking → booking gọi payment-service → payment gọi PSP (mock). Ba chuyện sẽ xảy ra:

1. User bấm 2 lần / client retry      → 2 request, phải ra 1 lần trừ tiền
2. PSP timeout sau 3s                 → tiền đã trừ hay chưa? KHÔNG BIẾT
3. PSP gửi webhook (có thể trùng, trễ, giả mạo)

booking ──POST /api/payments──▶ payment ──charge──▶ PSP mock
   ▲      Idempotency-Key            │   timeout?      │
   │                                 ▼                 │
   └──── kết quả / webhook ◀── payments(status) ◀── webhook (HMAC)
                                     ▲
                       reconciliation job hỏi PSP các UNKNOWN

Nếu bỏ qua: timeout bị coi là FAILED → nhả ghế, nhưng tiền đã trừ. Hoặc retry không key → trừ 2 lần. Cả hai đều là sự cố thật ở các công ty thật.

2. Ôn lại trước khi code

3. Đề bài

3.1. Schema (payment DB)

CREATE TABLE payments (
  id               BIGSERIAL PRIMARY KEY,
  booking_id       BIGINT       NOT NULL,
  idempotency_key  VARCHAR(80)  NOT NULL UNIQUE,
  request_hash     CHAR(64)     NOT NULL,      -- SHA-256 của body
  amount_minor     BIGINT       NOT NULL,      -- VND, đơn vị nhỏ nhất
  currency         CHAR(3)      NOT NULL,
  status           VARCHAR(16)  NOT NULL,      -- PENDING, SUCCEEDED, FAILED, UNKNOWN
  psp_ref          VARCHAR(64),
  attempts         INT          NOT NULL DEFAULT 0,
  created_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at       TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE TABLE psp_webhook_events (            -- chống xử lý webhook trùng
  event_id    VARCHAR(64) PRIMARY KEY,
  payload     JSONB       NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Booking thêm trạng thái PAYMENT_PENDING giữa HELD và CONFIRMED. Cập nhật bảng chuyển trạng thái của M2 và nghĩ: booking PAYMENT_PENDING có bị job expire không?

3.2. API

MethodPathHeader / BodyResponse
POST/api/bookings/{id}/confirmIdempotency-Key200 CONFIRMED · 202 PAYMENT_PENDING (UNKNOWN) · 402 FAILED
POST/api/paymentsIdempotency-Key; {"bookingId":1,"amountMinor":1500000,"currency":"VND"}201 {"paymentId","status"}; cùng key cùng body → trả lại đúng response cũ; cùng key khác body → 422
GET/api/payments/{id}—200 status hiện tại
POST/api/payments/webhookX-PSP-Signature: sha256=<hex>, X-PSP-Timestamp; {"eventId","pspRef","status"}200 (kể cả trùng); 401 sai chữ ký
POST/psp/charges (mock){"merchantRef","amountMinor"}200 {"pspRef","status"}
GET/psp/charges?merchantRef= (mock)—trạng thái thật bên PSP, dùng cho reconciliation
POST/psp/admin/faults (mock){"mode":"TIMEOUT_AFTER_CHARGE","delayMs":5000,"rate":0.3}204

Fault mode tối thiểu: NONE, DECLINE, SLOW, TIMEOUT_BEFORE_CHARGE, TIMEOUT_AFTER_CHARGE (đã trừ tiền nhưng không trả lời), DUPLICATE_WEBHOOK, LATE_WEBHOOK.

3.3. Skeleton

public interface PaymentService {
  /** Idempotent theo key. Không bao giờ ném timeout ra ngoài: chuyển thành UNKNOWN. */
  PaymentResult pay(String idempotencyKey, PayCommand cmd);
}

public interface PspClient {
  PspChargeResponse charge(String merchantRef, long amountMinor);   // timeout cấu hình
  Optional<PspChargeResponse> findByMerchantRef(String merchantRef);
}

public interface WebhookVerifier {
  /** HMAC-SHA256(secret, timestamp + "." + rawBody), so sánh constant-time, chặn timestamp cũ */
  boolean verify(String signatureHeader, String timestamp, byte[] rawBody);
}

@Component
class ReconciliationJob {
  @Scheduled(fixedDelayString = "${payment.reconcile.interval:PT30S}")
  @SchedulerLock(name = "payment-reconcile", lockAtMostFor = "PT2M")
  void run() { /* TODO: lấy UNKNOWN/PENDING quá X giây, hỏi PSP, chốt trạng thái */ }
}
payment:
  psp:
    base-url: ${PSP_BASE_URL}
    connect-timeout: 500ms
    read-timeout: 3s
  webhook:
    secret: ${PSP_WEBHOOK_SECRET}
    max-skew: 5m
  reconcile:
    interval: PT30S
    unknown-older-than: PT1M

Câu hỏi bạn tự quyết (ghi ADR): merchantRef gửi PSP là gì để PSP cũng dedupe được? Idempotency record tạo trước hay sau khi gọi PSP? Hai request cùng key đến đồng thời thì request thứ hai nhận gì?

4. Acceptance criteria

  1. Gửi lại cùng Idempotency-Key + cùng body 10 lần (tuần tự và đồng thời) → PSP mock ghi nhận đúng 1 charge; mọi response giống nhau.
  2. Cùng key khác body → 422, không gọi PSP.
  3. PSP TIMEOUT_AFTER_CHARGE → payment UNKNOWN, booking PAYMENT_PENDING, ghế không bị nhả.
  4. Reconciliation chốt mọi UNKNOWN trong ≤ 2 chu kỳ về đúng trạng thái thật của PSP.
  5. Webhook sai chữ ký hoặc timestamp quá max-skew → 401, không đổi DB.
  6. Webhook trùng eventId → 200, xử lý đúng 1 lần.
  7. Webhook và reconciliation cùng chốt 1 payment → kết quả cuối nhất quán, không chuyển SUCCEEDED → FAILED.
  8. Tiền lưu bằng long minor unit / BigDecimal, không có double.

5. Test cases

IDKịch bảnInput / điều kiệnKỳ vọng
M3-T1Happy pathfault NONESUCCEEDED, booking CONFIRMED, seat SOLD
M3-T2Retry cùng key10 request đồng thời1 charge ở PSP, 10 response giống nhau
M3-T3Key trùng body khácđổi amount422
M3-T4PSP declinefault DECLINEFAILED, booking về HELD (cho thử lại) hoặc CANCELLED — theo ADR
M3-T5Timeout trước chargeTIMEOUT_BEFORE_CHARGEUNKNOWN → reconcile → FAILED
M3-T6Timeout sau chargeTIMEOUT_AFTER_CHARGEUNKNOWN → reconcile → SUCCEEDED, booking CONFIRMED
M3-T7Webhook giả mạochữ ký sai401
M3-T8Webhook replaychữ ký đúng, timestamp cũ 1 giờ401
M3-T9Webhook trùngDUPLICATE_WEBHOOKxử lý 1 lần
M3-T10Webhook đến sau khi hold hết hạnLATE_WEBHOOK SUCCEEDEDKhông mất tiền khách: booking xử lý theo ADR (giữ ghế vì PAYMENT_PENDING, hoặc refund)
@SpringBootTest @Testcontainers
class IdempotentPaymentIT {
  @Autowired PaymentService payments;
  @Autowired FakePsp psp;   // đếm số charge

  @Test
  void sameKeyConcurrentChargesOnce() throws Exception {
    String key = UUID.randomUUID().toString();
    var cmd = new PayCommand(1L, 1_500_000L, "VND");
    // TODO: 10 thread, latch, gọi payments.pay(key, cmd)
    // TODO: assert psp.chargeCount(key-derived merchantRef) == 1
    // TODO: assert mọi result cùng paymentId và status
  }
}

class WebhookVerifierTest {
  @Test void rejectsTamperedBody() { /* TODO: ký body A, verify body B → false */ }
  @Test void rejectsOldTimestamp() { /* TODO */ }
}

6. Failure scenarios

Sự cốCách tạoHành vi mong đợi
PSP đã trừ tiền nhưng không trả lời/psp/admin/faults TIMEOUT_AFTER_CHARGEUNKNOWN, ghế giữ nguyên, reconciliation chốt SUCCEEDED
Mạng tới PSP chậmToxiproxy latency 4000ms payment → pspRead timeout 3s kích hoạt, không treo thread
Payment-service chết sau khi gọi PSP, trước khi lưudocker kill payment-service giữa chừng (thêm sleep debug)Bản ghi PENDING còn lại → reconciliation tìm theo merchantRef và chốt
Webhook đến trước responsePSP mock gửi webhook ngay, trả response trễ 2sKhông ghi đè trạng thái cuối bằng trạng thái cũ hơn
Secret webhook bị đổiĐổi PSP_WEBHOOK_SECRET một phía401 + alert/log rõ; reconciliation vẫn chốt được

7. Số liệu cần thu

Lưu ở docs/benchmarks/m3-payment.md:

8. Checklist hoàn thành

Lưu idempotency record trước hay sau khi gọi PSP? Mình thấy cách nào cũng có lỗ.

Đúng, cách nào cũng có lỗ nếu chỉ có một bước. Gợi ý để bạn tự vẽ timeline:

Và đừng giữ DB transaction mở trong lúc gọi PSP 3 giây. Tách thành hai transaction ngắn.

Verify HMAC mình làm rồi mà chữ ký luôn sai, dù secret đúng.

Lỗi rất hay gặp. Kiểm tra theo thứ tự:

Viết unit test cho verifier với một vector cố định trước, rồi mới test qua HTTP.

Payment UNKNOWN mà hold sắp hết hạn thì sao? Job M2 có nhả ghế không?

Câu hỏi rất đúng chỗ — đây là chỗ M2 và M3 chạm nhau. Gợi ý: job M2 chỉ expire booking ở HELD. Nếu booking đã sang PAYMENT_PENDING thì nó không đụng tới. Nhưng PAYMENT_PENDING không thể giữ ghế mãi. Tự đặt câu hỏi: giới hạn bao lâu? Hết giới hạn mà reconciliation vẫn chưa biết thì làm gì — nhả ghế và refund nếu sau đó tiền về? Ghi quyết định vào ADR.

Mình làm xong rồi, review và phỏng vấn mình đi.

Checklist review

Câu hỏi phỏng vấn

1. Làm sao không trừ tiền hai lần?

🇻🇳 Tiếng Việt:

Client gửi Idempotency-Key. Em lưu key với unique constraint trước khi gọi PSP, request trùng trả lại kết quả cũ. Em còn gửi merchantRef suy ra từ key để PSP cũng dedupe. Đã test 10 request đồng thời ra đúng 1 charge.

🇬🇧 English:

Clients send an Idempotency-Key. I persist it under a unique constraint before calling the PSP, and duplicates get the stored result. I also pass a merchant reference derived from the key so the PSP dedupes too. Ten concurrent requests produced exactly one charge.

2. PSP timeout thì xử lý sao?

🇻🇳 Tiếng Việt:

Timeout không phải thất bại, nên em ghi UNKNOWN và giữ ghế. Kết quả đến từ webhook hoặc job reconciliation hỏi PSP theo merchantRef. Không bao giờ nhả ghế chỉ vì timeout.

🇬🇧 English:

A timeout is not a failure, so I record UNKNOWN and keep the seat. The outcome arrives via webhook or a reconciliation job querying the PSP by merchant reference. I never release the seat just because of a timeout.

3. Bảo vệ webhook thế nào?

🇻🇳 Tiếng Việt:

Verify HMAC-SHA256 trên raw body với secret chung, so sánh constant-time, từ chối timestamp quá 5 phút để chống replay, và dedupe theo eventId.

🇬🇧 English:

I verify an HMAC-SHA256 over the raw body with a shared secret, compare in constant time, reject timestamps older than five minutes to stop replays, and dedupe by event ID.

4. Vì sao cần reconciliation khi đã có webhook?

🇻🇳 Tiếng Việt:

Webhook có thể mất, trễ hoặc bị từ chối do lỗi cấu hình. Reconciliation là lưới an toàn chủ động, đảm bảo mọi UNKNOWN cuối cùng đều được chốt.

🇬🇧 English:

Webhooks can be lost, delayed, or rejected by misconfiguration. Reconciliation is the active safety net that guarantees every UNKNOWN is eventually resolved.

Cách nhớ ngắn gọn


Tự kiểm tra

Cùng Idempotency-Key nhưng body khác thì trả gì, vì sao?

422. Key bị dùng lại cho yêu cầu khác là lỗi client; trả kết quả cũ sẽ che lỗi, xử lý mới sẽ phá idempotency.

Vì sao không giữ transaction DB trong lúc gọi PSP?

Giữ connection và lock vài giây làm cạn pool khi PSP chậm; chia thành ghi PENDING, gọi PSP, cập nhật kết quả.

Vì sao HMAC phải tính trên raw body?

Parse rồi serialize lại có thể đổi thứ tự field/khoảng trắng, làm chữ ký khác dù nội dung giống.

Webhook FAILED đến sau khi reconciliation đã chốt SUCCEEDED thì sao?

Không ghi đè trạng thái cuối; log/alert để điều tra, vì trạng thái cuối không được đi lùi.