Cổng thanh toán, webhook, idempotency, hoàn tiền, đối soátPayment gateways, webhooks, idempotency, refunds, reconciliation
Payment Service tạo payment intent, gọi cổng qua adapter và chờ webhook. Webhook được xác minh chữ ký, khử trùng rồi cập nhật trạng thái cùng outbox trong một transaction. Job đối soát hỏi lại cổng cho các giao dịch PENDING quá lâu. Chọn kịch bản “Mất webhook” hoặc “Webhook trùng” để xem cách hệ thống tự sửa.The Payment Service creates a payment intent, calls the gateway through an adapter and waits for a webhook. The webhook is signature-checked, deduplicated, and updates the status together with an outbox row in one transaction. A reconciliation job asks the gateway again about payments stuck in PENDING. Pick “Webhook lost” or “Duplicate webhook” to see how the system corrects itself.
1Khách bấm thanh toán đơn 8812 bằng MoMo. Client gửi kèm Idempotency-Key sinh một lần cho đơn này.The customer pays order 8812 with MoMo. The client sends an Idempotency-Key generated once for this order.
Client gửi Idempotency-Key cố định cho mỗi lần thanh toán đơn. Bảng payment có unique(idempotency_key); request lặp trả lại payment cũ thay vì tạo giao dịch mới ở cổng. Khi cổng hỗ trợ (Stripe), truyền luôn key sang cổng.The client sends a fixed Idempotency-Key per checkout. The payment table has unique(idempotency_key); a repeated request returns the existing payment instead of creating a new charge. When the gateway supports it (Stripe), pass the key through as well.
Interface PaymentGateway chung (createIntent, verifySignature, parse, queryStatus, refund). VNPay, MoMo, Stripe là các adapter; Strategy chọn adapter theo provider của payment.One PaymentGateway interface (createIntent, verifySignature, parse, queryStatus, refund). VNPay, MoMo and Stripe are adapters; a Strategy picks the adapter from the payment's provider.
Xác minh HMAC trên raw body trước khi parse. Lưu (provider, event_id) đã xử lý để bỏ qua bản trùng. State machine chỉ cho chuyển hợp lệ, nên FAILED tới sau SUCCEEDED bị bỏ qua.Verify the HMAC over the raw body before parsing. Store processed (provider, event_id) pairs to drop duplicates. The state machine only allows valid transitions, so a FAILED arriving after SUCCEEDED is ignored.
Job polling hỏi queryStatus cho payment PENDING quá 15 phút, giãn cách tăng dần. Đối soát cuối ngày so từng dòng với báo cáo quyết toán của cổng.A polling job calls queryStatus for payments PENDING over 15 minutes, with growing intervals. The nightly reconciliation matches every row against the gateway settlement report.
Ghi trạng thái payment và outbox payment.succeeded trong cùng transaction; relay đẩy sang Kafka sau commit. Order và Inventory chạy như một saga: thất bại thì nhả hàng đang giữ, hoặc hoàn tiền nếu không còn hàng.Write the payment status and a payment.succeeded outbox row in one transaction; a relay publishes after commit. Order and Inventory run as a saga: on failure release the reservation, or refund when stock is gone.
Tiền lưu bằng số nguyên đơn vị nhỏ nhất (VND không có xu, USD tính cent) hoặc BigDecimal, không dùng double. Tổng các refund SUCCEEDED không vượt số đã thu; kiểm tra dưới khoá dòng payment.Store money as integer minor units (VND has no subunit, USD uses cents) or BigDecimal, never double. The sum of SUCCEEDED refunds cannot exceed the captured amount; check it under a lock on the payment row.
Timeout ngắn khi gọi cổng, circuit breaker theo từng cổng. Breaker mở thì ẩn phương thức đó trên trang thanh toán thay vì để khách chờ.Short timeouts on gateway calls and a circuit breaker per gateway. When a breaker opens, hide that method on the checkout page instead of making customers wait.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
payment | id, order_id, provider, gateway_ref, amount_minor, currency, status, idempotency_key, version, created_at, updated_at | unique(idempotency_key), unique(provider, gateway_ref); CREATED → PENDING → SUCCEEDED · FAILED → REFUNDEDunique(idempotency_key), unique(provider, gateway_ref); CREATED → PENDING → SUCCEEDED · FAILED → REFUNDED |
refund | id, payment_id, amount_minor, status, idempotency_key, gateway_ref, reason, created_at | Nhiều refund cho một payment; sum(amount_minor) ≤ payment.amount_minorSeveral refunds per payment; sum(amount_minor) ≤ payment.amount_minor |
payment_event | id, payment_id, from_status, to_status, source, raw_payload, created_at | Lịch sử chuyển trạng thái; source = api · webhook · reconciliationStatus history; source = api · webhook · reconciliation |
processed_event | provider, event_id, received_at | PK(provider, event_id) để khử webhook trùngPK(provider, event_id) to drop duplicate webhooks |
reconciliation_item | run_date, provider, gateway_ref, gateway_amount, local_amount, result | result = MATCHED · MISSING_LOCAL · MISSING_GATEWAY · AMOUNT_MISMATCHresult = MATCHED · MISSING_LOCAL · MISSING_GATEWAY · AMOUNT_MISMATCH |
outbox | id, topic, key, payload, published_at | payment.succeeded, payment.failed, refund.succeededpayment.succeeded, payment.failed, refund.succeeded |
Java: endpoint webhook xác minh chữ ký, khử trùng, đi qua state machine rồi ghi outbox. Go: job đối soát hỏi lại cổng cho các payment PENDING quá lâu và sửa trạng thái bằng UPDATE có điều kiện.Java: a webhook endpoint that verifies the signature, deduplicates, goes through the state machine and writes the outbox. Go: a reconciliation job that asks the gateway about stale PENDING payments and fixes them with a conditional UPDATE.
@RestController
@RequiredArgsConstructor
public class WebhookController {
private final Map<String, PaymentGateway> gateways; // "vnpay", "momo", "stripe"
private final PaymentRepository payments;
private final ProcessedEventRepository processed;
private final OutboxRepository outbox;
@PostMapping("/webhooks/{provider}")
@Transactional
public ResponseEntity<Void> handle(@PathVariable String provider,
@RequestHeader HttpHeaders headers,
@RequestBody String rawBody) {
PaymentGateway gw = gateways.get(provider);
if (gw == null || !gw.verifySignature(headers, rawBody)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
GatewayEvent ev = gw.parse(rawBody);
if (!processed.insertIfAbsent(provider, ev.eventId())) {
return ResponseEntity.ok().build(); // duplicate delivery
}
Payment p = payments.lockByGatewayRef(provider, ev.reference()); // FOR UPDATE
if (p.amountMinor() != ev.amountMinor()) {
p.flagForReview(ev); // never auto-succeed
return ResponseEntity.ok().build();
}
if (!p.canMoveTo(ev.status())) {
return ResponseEntity.ok().build(); // stale or out of order
}
p.moveTo(ev.status(), "webhook"); // PENDING -> SUCCEEDED
if (p.status() == PaymentStatus.SUCCEEDED) {
outbox.save(OutboxEvent.of("payment.succeeded", p.orderId(), p));
}
return ResponseEntity.ok().build();
}
}type Reconciler struct {
db *sql.DB
gateways map[string]Gateway // "vnpay", "momo", "stripe"
payments PaymentStore
outbox Outbox
}
// Run sweeps PENDING payments older than the cutoff. It runs every 5 minutes
// as the polling fallback; the nightly report job reuses apply().
func (r *Reconciler) Run(ctx context.Context, olderThan time.Duration) error {
stale, err := r.payments.FindPending(ctx, time.Now().Add(-olderThan))
if err != nil {
return err
}
for _, p := range stale {
remote, err := r.gateways[p.Provider].QueryStatus(ctx, p.GatewayRef)
if err != nil {
slog.Warn("query failed, next run retries", "id", p.ID, "err", err)
continue
}
if remote.AmountMinor != p.AmountMinor || remote.Currency != p.Currency {
r.payments.FlagMismatch(ctx, p.ID, remote) // a person decides
continue
}
if err := r.apply(ctx, p, remote.Status); err != nil {
return err
}
}
return nil
}
func (r *Reconciler) apply(ctx context.Context, p Payment, s Status) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`UPDATE payment SET status = $1, updated_at = now()
WHERE id = $2 AND status = 'PENDING'`, s, p.ID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil // the webhook got there first
}
if s == StatusSucceeded {
if err := r.outbox.Add(ctx, tx, "payment.succeeded", p.OrderID); err != nil {
return err
}
}
return tx.Commit()
}| WebhookWebhook | Polling / đối soátPolling / reconciliation | |
|---|---|---|
| Độ trễLatency | Vài giây sau khi khách trảSeconds after the customer pays | Theo chu kỳ job, 5 phút tới 1 ngàyAs often as the job runs, 5 minutes to a day |
| Độ tin cậyReliability | Có thể mất, trùng, sai thứ tự, bị giả mạoCan be lost, duplicated, out of order or forged | Nguồn sự thật từ API và báo cáo của cổngThe gateway's API and report are the source of truth |
| Chi phíCost | Rẻ, cổng gọi mìnhCheap, the gateway calls you | Tốn quota API, phải giãn cáchUses API quota, needs spacing |
| Dùng khiUse it | Đường chính cho mọi giao dịchAs the main path for every payment | Lưới an toàn cho PENDING lâu và đối soát cuối ngàyAs the safety net for stale PENDING and the nightly reconciliation |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Idempotency key sinh phía client cho mỗi lần checkout, unique index trong bảng payment. Request thứ hai trả lại payment cũ. Nếu cổng hỗ trợ idempotency key (Stripe), truyền tiếp để chính cổng cũng không tạo charge thứ hai.A client-generated idempotency key per checkout, with a unique index on the payment table. The second request returns the existing payment. If the gateway accepts an idempotency key (Stripe), pass it on so the gateway does not create a second charge either.
State machine chỉ cho phép chuyển hợp lệ; SUCCEEDED là trạng thái cuối trừ khi hoàn tiền. Sự kiện cũ bị bỏ qua, ghi log, vẫn trả 200. Nếu nghi ngờ, gọi queryStatus để lấy trạng thái thật từ cổng.The state machine allows only valid transitions; SUCCEEDED is final except for refunds. The stale event is ignored and logged, and the endpoint still answers 200. When in doubt, call queryStatus for the gateway's real status.
Số dấu phẩy động nhị phân không biểu diễn chính xác 0.1, cộng dồn sẽ lệch. Dùng long theo đơn vị nhỏ nhất kèm mã tiền tệ, hoặc BigDecimal với scale và RoundingMode rõ ràng.Binary floating point cannot represent 0.1 exactly, so sums drift. Use a long in minor units with a currency code, or BigDecimal with an explicit scale and RoundingMode.
Thường do lỗi giữa lúc gọi cổng và lúc ghi DB, hoặc ai đó tạo giao dịch thủ công trên portal của cổng. Ghi vào reconciliation_item với MISSING_LOCAL, báo kế toán; nếu khớp được đơn thì bổ sung, không khớp thì hoàn tiền.Usually a crash between calling the gateway and writing the DB, or someone created a payment by hand in the gateway portal. Record it as MISSING_LOCAL, alert finance; if it matches an order, backfill it, otherwise refund.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.