M3 · Thanh toán an toàn
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
- Payment — idempotency, UNKNOWN, reconciliation, ledger.
- Error & idempotency — header
Idempotency-Key, lưu response, so khớp body. - Timeout, retry, deadline — retry chỉ an toàn khi idempotent.
- Partial failure — timeout không có nghĩa là thất bại.
- Hash & MAC — HMAC-SHA256, so sánh constant-time.
- REST client — đặt connect/read timeout cho
RestClient. - Timeouts — connect timeout khác read timeout.
- Banking transfer — số tiền dùng kiểu gì, không dùng
double.
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
| Method | Path | Header / Body | Response |
|---|---|---|---|
| POST | /api/bookings/{id}/confirm | Idempotency-Key | 200 CONFIRMED · 202 PAYMENT_PENDING (UNKNOWN) · 402 FAILED |
| POST | /api/payments | Idempotency-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/webhook | X-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
- 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. - Cùng key khác body → 422, không gọi PSP.
- PSP
TIMEOUT_AFTER_CHARGE→ payment UNKNOWN, booking PAYMENT_PENDING, ghế không bị nhả. - Reconciliation chốt mọi UNKNOWN trong ≤ 2 chu kỳ về đúng trạng thái thật của PSP.
- Webhook sai chữ ký hoặc timestamp quá
max-skew→ 401, không đổi DB. - Webhook trùng
eventId→ 200, xử lý đúng 1 lần. - Webhook và reconciliation cùng chốt 1 payment → kết quả cuối nhất quán, không chuyển SUCCEEDED → FAILED.
- Tiền lưu bằng
longminor unit /BigDecimal, không códouble.
5. Test cases
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| M3-T1 | Happy path | fault NONE | SUCCEEDED, booking CONFIRMED, seat SOLD |
| M3-T2 | Retry cùng key | 10 request đồng thời | 1 charge ở PSP, 10 response giống nhau |
| M3-T3 | Key trùng body khác | đổi amount | 422 |
| M3-T4 | PSP decline | fault DECLINE | FAILED, booking về HELD (cho thử lại) hoặc CANCELLED — theo ADR |
| M3-T5 | Timeout trước charge | TIMEOUT_BEFORE_CHARGE | UNKNOWN → reconcile → FAILED |
| M3-T6 | Timeout sau charge | TIMEOUT_AFTER_CHARGE | UNKNOWN → reconcile → SUCCEEDED, booking CONFIRMED |
| M3-T7 | Webhook giả mạo | chữ ký sai | 401 |
| M3-T8 | Webhook replay | chữ ký đúng, timestamp cũ 1 giờ | 401 |
| M3-T9 | Webhook trùng | DUPLICATE_WEBHOOK | xử lý 1 lần |
| M3-T10 | Webhook đến sau khi hold hết hạn | LATE_WEBHOOK SUCCEEDED | Khô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ạo | Hành vi mong đợi |
|---|---|---|
| PSP đã trừ tiền nhưng không trả lời | /psp/admin/faults TIMEOUT_AFTER_CHARGE | UNKNOWN, ghế giữ nguyên, reconciliation chốt SUCCEEDED |
| Mạng tới PSP chậm | Toxiproxy latency 4000ms payment → psp | Read timeout 3s kích hoạt, không treo thread |
| Payment-service chết sau khi gọi PSP, trước khi lưu | docker 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 response | PSP mock gửi webhook ngay, trả response trễ 2s | Khô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ía | 401 + alert/log rõ; reconciliation vẫn chốt được |
7. Số liệu cần thu
Lưu ở docs/benchmarks/m3-payment.md:
- Với fault rate 30% TIMEOUT_AFTER_CHARGE, 1000 thanh toán: số UNKNOWN, thời gian đến khi 100% được chốt, số charge trùng (phải = 0).
- Overhead của idempotency check: p99
POST /api/paymentscó/không có bảng key.
8. Checklist hoàn thành
- ☐ Acceptance criteria có test tự động và xanh
- ☐ Failure scenario đã chạy thật, có ghi kết quả
- ☐
docs/benchmarks/m3-payment.md - ☐ ADR: thứ tự ghi idempotency record / gọi PSP; xử lý webhook đến muộn
- ☐
PSP_WEBHOOK_SECRETqua env, không log secret hay chữ ký đầy đủ - ☐ Bảng trạng thái payment có luật, không cho đi lùi từ trạng thái cuối
- ☐ Metric
payment_unknown_total,payment_reconciled_total - ☐ Đã qua review + phỏng vấn của AI
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:
- Chỉ lưu sau: hai request đồng thời cùng qua check, cùng gọi PSP. Lỗ ở đâu?
- Lưu trước với PENDING (unique key chặn request thứ hai), gọi PSP, rồi cập nhật. App chết giữa chừng thì còn bản ghi PENDING — ai dọn?
- Thêm một lớp:
merchantRefgửi PSP suy ra từ key, PSP cũng dedupe. Lúc đó lỗ còn lại nằm đâu?
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ự:
- Bạn ký trên raw body bytes hay trên object đã parse rồi serialize lại? Jackson đổi thứ tự field, khoảng trắng là chữ ký khác.
- Controller nhận
@RequestBody PaymentWebhookthì raw body đã mất. Nghĩ cách nhậnbyte[]rồi tự parse. - Hex chữ hoa hay thường, có tiền tố
sha256=không? - Chuỗi ký có gồm timestamp đúng định dạng như PSP mock không?
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
- Unique constraint trên
idempotency_keyở DB, không chỉ check bằng code. - Có so
request_hashkhi key trùng? - Không có transaction DB bao quanh lời gọi HTTP tới PSP.
- Timeout/IOException → UNKNOWN, không → FAILED.
- HMAC trên raw body,
MessageDigest.isEqualđể so sánh, kiểm tra timestamp. - Webhook idempotent theo
eventId; trạng thái cuối không bị ghi đè. - Reconciliation có giới hạn batch, có ShedLock, có metric.
- Tiền không dùng
double; secret không xuất hiện trong log.
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
- Key trước, PSP sau, unique ở DB.
- Timeout = UNKNOWN, không phải FAILED.
- Webhook: raw body, HMAC, timestamp, eventId.
- Reconciliation là lưới cuối, trạng thái cuối không đi lùi.
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.