Thiết kế hệ thống Payment Gateway 2026 - Idempotency, Saga Pattern và phòng thủ Double-Charge cho Stripe-scale
Posted on: 4/16/2026 9:11:57 PM
Table of contents
- 1. Ba đảm bảo không được phép sai — và vì sao payment system là đỉnh của distributed system
- 2. Kiến trúc tổng quan của một payment gateway chuẩn 2026
- 3. Idempotency Key — lá chắn đầu tiên chống double-charge
- 4. Ledger double-entry — nguồn truth kinh tế không được sửa, chỉ được ghi thêm
- 5. Saga Pattern — khi một authorization là năm bước có thể fail ở bất kỳ đâu
- 6. Transactional Outbox — cầu bảo đảm giữa DB commit và message publish
- 7. Webhook từ acquirer — tất cả đều là at-least-once, signature và replay-protection
- 8. Reconciliation — nightly worker nhìn thấy mọi thứ webhook bỏ sót
- 9. 3DS 2.x và SCA — flow async cần state machine riêng
- 10. Observability cho payment — metric nào phải có dashboard riêng
- 11. Bảo mật và compliance — PCI DSS v4, tokenization, và luật chơi không được đùa
- 12. Production checklist — 20 mục không được thiếu trước khi go-live
- 13. Kết luận — payment là nơi kỷ luật kỹ thuật gặp kỷ luật kinh doanh
- 14. Tham khảo
1. Ba đảm bảo không được phép sai — và vì sao payment system là đỉnh của distributed system
Viết một hệ thống thanh toán nghĩa là chấp nhận rằng mọi sai lầm đều quy ra tiền mặt. Không có tính năng nào trong một sản phẩm SaaS có tỷ lệ "không được sai lần nào" cao bằng payment: một lỗi nhỏ có thể tính phí hai lần cho khách, hoàn tiền hai lần cho chính mình, hoặc tệ hơn là âm thầm nuốt giao dịch mà không ai biết cho đến khi đối soát cuối tháng. Đó là lý do mọi trang thanh toán nghiêm chỉnh — Stripe, Adyen, PayPal, VNPay, MoMo — đều sống và chết với ba đảm bảo bất di bất dịch.
Ba đảm bảo cốt lõi của một payment system
- No double charge — một intent thanh toán dù được retry bao nhiêu lần cũng chỉ đi qua acquirer đúng một lần, dù client retry, dù network flap, dù server crash giữa chừng.
- No double refund — ngược lại, một refund request dù được trigger trong đêm bảo trì, trong webhook retry, hay trong tay hỗ trợ khách, vẫn chỉ hoàn đúng một lần.
- Không mất giao dịch — mỗi lần tiền rời tài khoản khách, hệ thống ghi nhận đầy đủ và không để rơi; state của từng payment luôn có thể truy nguyên từ ledger, dù service nào đó có chết một hai giờ.
Ba đảm bảo này không phải là "tính năng" — nó là hợp đồng đạo đức mà hệ thống phải giữ dù xung quanh có xảy ra bất cứ loại sự cố nào: replica DB rớt, message broker nuốt message, acquirer timeout, webhook duplicate, hay một PM vô tình bấm "reprocess" hai lần trong admin tool. Toàn bộ bài này xoay quanh những kỹ thuật đã được công nghiệp hóa để giữ ba đảm bảo đó, với ngôn ngữ của .NET 10 và stack quen thuộc của team engineer Việt Nam.
2. Kiến trúc tổng quan của một payment gateway chuẩn 2026
Một payment system không phải là một service, mà là một chuỗi service với biên giới trách nhiệm rất rõ. Gộp chung làm một monolith nghe có vẻ đơn giản, nhưng sẽ là thảm họa khi team vận hành không thể chỉ ra chính xác "đoạn này là của ai" lúc incident — và incident trong payment luôn xảy ra lúc 2h sáng.
flowchart LR
CL(["Client / Checkout UI"]) --> API["Payment API
(.NET 10, Minimal API)"]
API --> IDP[("Idempotency Store
Redis + SQL backup")]
API --> LDG[("Ledger DB
Postgres Serializable")]
API --> ORC["Saga Orchestrator
Temporal / MassTransit"]
ORC --> RISK["Risk / Fraud
Engine"]
ORC --> TOK["Tokenization
Vault"]
ORC --> ACQ(("Acquirer / PSP
Stripe, Adyen, VNPay"))
ACQ -. async .-> WHK["Webhook
Listener"]
WHK --> LDG
LDG --> RECON["Reconciliation
Worker (nightly)"]
RECON --> REP[("Acquirer Report
SFTP / API")]
LDG --> OUT[("Transactional
Outbox")]
OUT --> BUS[("Event Bus
Kafka / RabbitMQ")]
BUS --> DW[("Data Warehouse
ClickHouse / BigQuery")]
Hình 1: Kiến trúc layered của một payment gateway — API, ledger, orchestrator, acquirer, reconciliation
Có năm vùng trách nhiệm tách biệt cần nhận diện:
- Payment API — nhận request từ client, verify idempotency, tạo intent, trả về client_secret hoặc redirect URL. Không bao giờ gọi trực tiếp acquirer; chỉ ghi intent rồi đẩy cho orchestrator.
- Saga Orchestrator — điều phối chuỗi bước (risk check → tokenize → authorize → capture → webhook) với khả năng compensate mỗi bước. Đây là nơi state machine "sống", nơi resume sau crash.
- Ledger — nguồn sự thật kinh tế của hệ thống. Mỗi movement (authorize, capture, refund, chargeback) là một dòng immutable. Không có UPDATE, chỉ INSERT; số dư là phép tổng.
- Webhook Listener — consume sự kiện async từ acquirer (payment_intent.succeeded, charge.refunded, dispute.created). Xác minh chữ ký, cập nhật ledger, trigger downstream.
- Reconciliation — nightly worker đối chiếu ledger nội bộ với acquirer settlement file, phát hiện mismatch trước khi chúng biến thành dispute của kế toán.
3. Idempotency Key — lá chắn đầu tiên chống double-charge
Idempotency key là tấm khiên quan trọng nhất, và đồng thời là kỹ thuật bị triển khai sai thường xuyên nhất. Nguyên tắc do Stripe chuẩn hóa từ 2017 và giờ đã trở thành chuẩn ngành: client tự sinh một key duy nhất cho mỗi payment intent; mọi request tạo payment gửi cùng key về server sẽ trả về cùng response, dù request lặp bao nhiêu lần.
Key này không chỉ là "de-dup": nó là hợp đồng giữa client và server nói rằng "request này là cùng một ý định, đừng xử lý lại". Khi network flap khiến client không nhận được response và retry, server phải nhận ra và trả về response cũ — không tạo authorization mới.
// Payment API endpoint — .NET 10 Minimal API, idempotency đúng chuẩn
app.MapPost("/v1/payment_intents", async (
[FromHeader(Name = "Idempotency-Key")] string idemKey,
[FromBody] CreateIntentRequest req,
IIdempotencyStore idem,
IPaymentService svc,
CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(idemKey) || idemKey.Length > 255)
return Results.BadRequest("Idempotency-Key header is required");
// hash body để detect reuse key với body khác
var bodyHash = SHA256Hex(JsonSerializer.SerializeToUtf8Bytes(req));
var saved = await idem.TryBeginAsync(idemKey, bodyHash, ct);
if (saved is { Status: IdemStatus.Completed } done)
return Results.Content(done.ResponseJson, "application/json", null, done.StatusCode);
if (saved is { Status: IdemStatus.InFlight })
return Results.StatusCode(409); // conflict — để client retry sau
if (saved is { Status: IdemStatus.BodyMismatch })
return Results.StatusCode(422); // key reuse với body khác — bug client
try
{
var intent = await svc.CreateIntentAsync(req, ct);
var resp = JsonSerializer.Serialize(intent);
await idem.CompleteAsync(idemKey, 201, resp, ct);
return Results.Content(resp, "application/json", null, 201);
}
catch (Exception ex)
{
await idem.FailAsync(idemKey, ex.Message, ct);
throw;
}
});
Năm chi tiết khiến idempotency triển khai đúng thay vì sai
- Lưu cả body hash, không chỉ key. Nếu client gửi cùng key nhưng body khác (bug hoặc attack), server phải từ chối bằng 422 thay vì trả về response cũ gây lầm tưởng.
- Status In-Flight rõ ràng. Request đang chạy phải đánh dấu để request sau retry nhận 409 và chờ, thay vì chạy song song và tạo hai authorization.
- TTL 24–72 giờ là sweet spot. Ngắn hơn thì client retry sau crash khó match; dài hơn thì storage phình vô tội vạ.
- Serializable transaction cho phase insert. Race giữa hai request cùng key phải được cắt ngay ở tầng DB, không tin tưởng logic application-level.
- Scope theo tenant/merchant. Key "abc-123" của merchant A không được trùng lẫn với merchant B; composite primary key luôn gồm
(tenant_id, idem_key).
Storage lý tưởng cho idempotency là hai lớp: Redis làm first hit để phục vụ check dưới 5ms, nhưng mọi thay đổi phải ghi kèm Postgres làm nguồn truth. Mất Redis không mất tiền; mất Postgres là mất tiền. Pattern "Redis write-through Postgres" là chuẩn, không dùng Redis làm nguồn duy nhất.
4. Ledger double-entry — nguồn truth kinh tế không được sửa, chỉ được ghi thêm
Database design sai thường xuyên nhất trong payment system là một bảng payments với cột status được UPDATE liên tục. Thiết kế này chết ngay khi có tranh chấp: không có cách nào biết payment đã ở trạng thái gì lúc nào, không cách nào audit, không cách nào rebuild số dư. Chuẩn ngành kế toán từ 500 năm trước đã trả lời: double-entry ledger.
flowchart LR
subgraph Ledger
LT[("ledger_txn
id, type, idem_key, created_at")]
LE[("ledger_entry
txn_id, account_id, amount, sign")]
end
LT --- LE
A1(["customer:123:available"])
A2(["merchant:anhtu:pending"])
A3(["merchant:anhtu:available"])
A4(["bank:acquirer:stripe"])
LE -.->|"capture"| A1
LE -.->|"capture"| A2
LE -.->|"settle T+2"| A2
LE -.->|"settle T+2"| A3
LE -.->|"payout"| A3
LE -.->|"payout"| A4
Hình 2: Double-entry ledger — mỗi transaction sinh ra ít nhất hai entry cân bằng, số dư là phép tổng theo account
Hai nguyên tắc không được vi phạm:
- Entries là append-only. Không UPDATE, không DELETE. Muốn "hủy" một transaction nghĩa là ghi thêm một transaction ngược dấu (reversal), để audit log giữ nguyên lịch sử.
- Tổng entries của mỗi transaction = 0. Nếu bạn rút 100k từ tài khoản A, phải có đúng 100k được ghi vào một tài khoản khác. Ràng buộc này được enforce bằng trigger hoặc domain rule, đảm bảo ledger không bao giờ "phình" hoặc "co" vô cớ.
-- Postgres schema tối giản nhưng đúng bản chất
CREATE TABLE ledger_txn (
id bigint PRIMARY KEY,
tenant_id bigint NOT NULL,
type text NOT NULL, -- authorize | capture | refund | chargeback
idem_key text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, idem_key)
);
CREATE TABLE ledger_entry (
id bigserial PRIMARY KEY,
txn_id bigint NOT NULL REFERENCES ledger_txn(id),
account_id text NOT NULL, -- 'customer:123:available'
currency char(3) NOT NULL,
amount_minor bigint NOT NULL, -- âm hoặc dương, đơn vị nhỏ nhất
created_at timestamptz NOT NULL DEFAULT now()
);
-- invariant: mỗi txn tổng amount_minor = 0
CREATE OR REPLACE FUNCTION ensure_balanced() RETURNS trigger AS $$
DECLARE s bigint;
BEGIN
SELECT sum(amount_minor) INTO s FROM ledger_entry WHERE txn_id = NEW.txn_id;
IF s <> 0 THEN
RAISE EXCEPTION 'ledger txn % imbalanced by %', NEW.txn_id, s;
END IF;
RETURN NULL;
END; $$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER ledger_balance_check
AFTER INSERT ON ledger_entry DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION ensure_balanced();
Số dư tài khoản là view: SELECT sum(amount_minor) FROM ledger_entry WHERE account_id = ?. Với volume cao, bạn cache số dư vào materialized table rebuild liên tục — nhưng nguồn truth luôn là phép tổng. Khi có tranh chấp, bạn có khả năng replay toàn bộ ledger để chứng minh từng xu chảy đúng đường.
5. Saga Pattern — khi một authorization là năm bước có thể fail ở bất kỳ đâu
Một payment không phải là một query. Nó là chuỗi bước: risk check → tokenize card → call acquirer authorize → write ledger → emit event. Mỗi bước có thể timeout, lỗi, hoặc trả về "maybe" (đặc biệt là acquirer). Và mỗi bước có một hành động đền bù nếu bước sau fail: cancel authorization, trả refund, emit compensating event. Đây là bản chất của Saga Pattern.
| Tiêu chí | Saga Choreography | Saga Orchestration |
|---|---|---|
| Cách điều khiển | Mỗi service nghe event và phản ứng | Một orchestrator trung tâm gọi từng service |
| Coupling | Thấp — services không biết nhau | Cao hơn — orchestrator biết toàn bộ flow |
| Observability | Khó — flow phân tán trong event log | Dễ — state machine tập trung |
| Compensation | Phức tạp — mỗi service tự ghi nhớ | Trực tiếp — orchestrator gọi hành động ngược |
| Phù hợp với | Flow đơn giản, <4 bước, team độc lập | Payment, booking, order — nhiều bước phải rollback |
| Ví dụ tool 2026 | MassTransit, NServiceBus, Kafka | Temporal, Cadence, Dapr Workflows, AWS Step Functions |
Với payment, orchestration là lựa chọn đúng gần như mọi lần. Flow có 5–10 bước, mỗi bước có compensation rõ ràng, observability là yêu cầu cứng với kế toán và compliance. Temporal (hoặc Dapr Workflows cho team nhẹ hơn) là công cụ chuẩn.
sequenceDiagram
autonumber
participant C as Client
participant A as Payment API
participant T as Temporal Worker
participant R as Risk Engine
participant V as Vault
participant P as PSP (Stripe)
participant L as Ledger
C->>A: POST /intents (Idempotency-Key)
A->>T: StartWorkflow(intentId)
T->>R: RiskCheck(card, user, ip)
R-->>T: score=0.2 approved
T->>V: TokenizeCard(PAN)
V-->>T: token=tok_abc
T->>P: Authorize(token, amount)
P-->>T: auth_id=ch_123 approved
T->>L: WriteAuthorizeEntries
T-->>A: Workflow complete
A-->>C: 201 Created (intent)
Note over T,P: Nếu bước nào fail,
compensation gọi ngược
Hình 3: Orchestrated saga cho authorize — mỗi bước có timeout, retry riêng, compensation rõ ràng
// Temporal workflow cho authorize intent — .NET SDK v2
[Workflow]
public class AuthorizeIntentWorkflow
{
[WorkflowRun]
public async Task<IntentResult> RunAsync(AuthorizeInput input)
{
// Mỗi activity có retry policy riêng; thất bại quá số lần sẽ raise lên workflow
var risk = await Workflow.ExecuteActivityAsync(
(IRiskActivities a) => a.ScoreAsync(input),
new() { StartToCloseTimeout = TimeSpan.FromSeconds(5),
RetryPolicy = new() { MaximumAttempts = 3 } });
if (risk.Decision == "deny")
return IntentResult.Declined("risk_block");
var token = await Workflow.ExecuteActivityAsync(
(IVaultActivities a) => a.TokenizeAsync(input.Card),
new() { StartToCloseTimeout = TimeSpan.FromSeconds(3) });
try
{
var auth = await Workflow.ExecuteActivityAsync(
(IPsPActivities a) => a.AuthorizeAsync(token, input.AmountMinor, input.Currency),
new() { StartToCloseTimeout = TimeSpan.FromSeconds(30),
RetryPolicy = new() { MaximumAttempts = 1 } }); // KHÔNG retry acquirer
await Workflow.ExecuteActivityAsync(
(ILedgerActivities a) => a.RecordAuthorizeAsync(input.IntentId, auth),
new() { StartToCloseTimeout = TimeSpan.FromSeconds(2) });
return IntentResult.Approved(auth.AuthId);
}
catch (ActivityFailureException)
{
// compensation — không gọi PSP vì có thể chưa chắc có auth; để recon lo
await Workflow.ExecuteActivityAsync(
(ILedgerActivities a) => a.MarkIntentFailedAsync(input.IntentId),
new() { StartToCloseTimeout = TimeSpan.FromSeconds(2) });
throw;
}
}
}
Tuyệt đối không retry tự động call authorize lên acquirer
Cảm giác tự nhiên khi activity fail là retry. Nhưng với authorize call, retry là nguy hiểm chết người: timeout không có nghĩa là fail — có thể tiền đã được giữ bên acquirer chỉ vì network đứt đoạn trả về. Retry sẽ tạo authorization thứ hai. Luật vàng: authorize/capture/refund call lên acquirer chỉ retry khi có idempotency-key do chính acquirer hỗ trợ (Stripe, Adyen có; một số PSP nội địa không có). Không có: chỉ gọi một lần, để reconciliation worker chịu trách nhiệm dò lại sau.
6. Transactional Outbox — cầu bảo đảm giữa DB commit và message publish
Một lỗi rất phổ biến: service ghi ledger thành công, rồi emit event "payment.succeeded" lên Kafka, xong mới trả về response cho client. Vấn đề: hai bước này không atomic. Nếu service crash giữa hai bước, ledger đã ghi nhưng event không bao giờ phát, và downstream (email confirmation, analytics, loyalty points) sẽ không chạy. Pattern sửa được gọi là Transactional Outbox.
flowchart LR
API["Payment API"] --> TX{"BEGIN TX"}
TX --> L[("ledger_entry")]
TX --> O[("outbox_event")]
TX --> C{"COMMIT"}
C --> R["Outbox Relay
(CDC hoặc poller)"]
R --> B[("Kafka / RabbitMQ")]
B --> D1["Email Service"]
B --> D2["Loyalty Service"]
B --> D3["Analytics"]
Hình 4: Outbox pattern — ledger và event cùng commit trong một transaction, relay đẩy ra bus sau
Cơ chế rất đơn giản mà vô cùng bền: ledger và event được ghi trong cùng một transaction SQL. COMMIT đồng thời cả hai; nếu crash, cả hai rollback. Một worker relay riêng đọc outbox_event và publish sang bus, đánh dấu đã publish. Bus chịu trách nhiệm at-least-once; consumer phải idempotent.
-- Outbox table
CREATE TABLE outbox_event (
id bigserial PRIMARY KEY,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
INDEX unpublished ON outbox_event (created_at) WHERE published_at IS NULL
);
-- Relay worker (.NET BackgroundService) — đọc batch 100, publish, mark
while (!stoppingToken.IsCancellationRequested)
{
using var tx = await db.BeginTransactionAsync();
var batch = await db.QueryAsync<OutboxRow>(
"SELECT * FROM outbox_event WHERE published_at IS NULL " +
"ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100");
if (!batch.Any()) { await Task.Delay(200); continue; }
foreach (var row in batch)
await producer.ProduceAsync("payment.events", row.ToKafkaMessage());
await db.ExecuteAsync(
"UPDATE outbox_event SET published_at = now() WHERE id = ANY(@ids)",
new { ids = batch.Select(b => b.Id).ToArray() });
await tx.CommitAsync();
}
FOR UPDATE SKIP LOCKED là chi tiết quan trọng — cho phép chạy nhiều relay worker song song mà không đụng hàng. CDC-based outbox (Debezium đọc WAL của Postgres rồi push Kafka) là một biến thể cao cấp hơn, phù hợp khi throughput >10k event/s.
7. Webhook từ acquirer — tất cả đều là at-least-once, signature và replay-protection
Phần lớn payment flow thật sự hoàn tất qua webhook, không qua response HTTP ngay. Authorize có thể thành công đồng bộ, nhưng 3DS challenge, capture async, refund, chargeback — tất cả về qua webhook. Webhook listener là subsystem cực kỳ rủi ro nếu xem nhẹ: ba lỗi phổ biến chết người.
| Lỗi phổ biến | Triệu chứng | Hậu quả | Cách phòng |
|---|---|---|---|
| Không verify signature | Chấp nhận request giả danh PSP | Attacker tự "confirm" payment giả | HMAC check với shared secret, reject <tolerant window |
| Không xử lý duplicate | PSP retry 2–3 lần, ghi nhiều lần | Double ledger entry, bookkeeping sai | Idempotent theo event_id của PSP |
| Trả 2xx quá sớm | PSP tưởng xử lý xong, thực tế chưa | Mất event khi worker crash giữa chừng | Persist vào queue nội bộ trước, ack sau |
| Không handle out-of-order | succeeded đến trước created | State machine reject event hợp lệ | Buffer và resolve theo event_type precedence |
| Xử lý inline chậm | Timeout phía PSP, retry storm | Webhook queue sâu vài chục nghìn | Nhận + persist + 200 ngay, xử lý bất đồng bộ |
// Webhook handler đúng chuẩn — .NET 10 Minimal API
app.MapPost("/webhooks/stripe", async (
HttpRequest httpReq,
[FromServices] IWebhookVerifier verifier,
[FromServices] IWebhookQueue queue,
CancellationToken ct) =>
{
using var reader = new StreamReader(httpReq.Body);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = httpReq.Headers["Stripe-Signature"].ToString();
// 1. Verify HMAC với tolerance 5 phút chống replay
if (!verifier.VerifyAndCheckTimestamp(rawBody, signature, TimeSpan.FromMinutes(5)))
return Results.Unauthorized();
var evt = JsonSerializer.Deserialize<StripeEvent>(rawBody)!;
// 2. Idempotent theo event.id do Stripe cấp — duplicate thì 200 ngay
if (!await queue.EnqueueIfNewAsync(evt.Id, evt.Type, rawBody, ct))
return Results.Ok(); // already seen, acknowledge để Stripe ngừng retry
// 3. Trả 200 ngay, worker xử lý async
return Results.Ok();
});
Nguyên tắc chịu đựng event trễ: tất cả state machine phải chấp nhận mọi thứ tự xuất hiện. Nếu payment_intent.succeeded đến trước payment_intent.created, không được reject — chỉ cần đánh dấu chờ, và khi event trước đến thì reconcile. PSP lớn có guarantee at-least-once nhưng không có guarantee total ordering.
8. Reconciliation — nightly worker nhìn thấy mọi thứ webhook bỏ sót
Dù webhook và saga đã cố gắng hết sức, vẫn tồn tại một lớp sự kiện không bao giờ về đến hệ thống: event bị nuốt do PSP thay đổi định dạng, webhook retry đã hết số lần, network partition kéo dài nhiều giờ. Đây là lý do mọi payment system nghiêm chỉnh đều có reconciliation worker chạy hàng đêm, đối chiếu ledger nội bộ với settlement report do acquirer gửi.
/v1/balance_transactions cho toàn bộ ngày T-1, ghi vào bảng staging psp_settlement_raw.(psp_ref, type, amount_minor, currency, occurred_at).psp_ref. Ba loại mismatch: (a) ledger có, settlement không — có thể phantom auth; (b) settlement có, ledger không — webhook mất; (c) amount lệch — risk change hoặc partial capture.psp_ref, nếu xác nhận hợp lệ thì ghi bổ sung ledger entry với txn type recon_backfill. Ghi metric recon.backfilled_total.recon_exception và PagerDuty kế toán. SLA: clear mọi exception trong 48 giờ.Reconciliation không phải phụ trợ — nó là lớp phòng thủ thứ ba
Idempotency chặn double-charge tại thời điểm request, Temporal saga đảm bảo workflow không bị bỏ dở, reconciliation đảm bảo số dư cuối ngày đúng bằng mọi giá. Ba lớp này độc lập, phòng thủ lỗi ở ba thời điểm khác nhau. Bỏ bất kỳ lớp nào đều dẫn đến ngày nào đó kế toán phải ngồi đếm tay.
9. 3DS 2.x và SCA — flow async cần state machine riêng
Từ PSD2 (châu Âu) và tương đương ở nhiều nước, Strong Customer Authentication (SCA) qua 3DS 2.x đã không còn là option. Flow này biến authorize từ "gọi API và nhận kết quả" thành "khởi tạo challenge, redirect user, chờ browser về, xử lý kết quả". Một state machine riêng là bắt buộc.
stateDiagram-v2
[*] --> Requires_PM: tạo intent
Requires_PM --> Requires_Action: attach card, PSP trả về requires_action
Requires_Action --> Processing: user complete 3DS challenge, browser return
Processing --> Succeeded: acquirer xác nhận authorize
Processing --> Failed: acquirer từ chối hoặc 3DS timeout
Requires_Action --> Failed: user đóng browser quá 10 phút
Succeeded --> Captured: capture sau T+0
Captured --> Refunded: refund một phần hoặc toàn bộ
Captured --> Disputed: chargeback
Disputed --> Captured: dispute_won
Disputed --> Refunded: dispute_lost
Hình 5: State machine intent bao gồm nhánh 3DS async, dispute và refund
Bốn nguyên tắc ổn trong production khi triển khai 3DS:
- Timeout user challenge phía hệ thống. Nếu intent ở
requires_actionquá 15 phút, auto-cancel để không giữ authorization của acquirer, tránh bị charge phí. - Không trust redirect client. Browser về có thể là forged hoặc replayed; kết quả 3DS lấy từ webhook async của PSP, không từ URL client.
- Lưu 3DS result vào ledger. Cột
sca_outcometrongledger_txngiúp audit và chứng minh exemption (low value, recurring) khi cần. - Fallback cho low-risk exemption. Tỷ lệ authorization tăng đáng kể khi bạn dùng TRA (Transaction Risk Analysis) exemption đúng cách — cần tích hợp risk engine từ trước.
10. Observability cho payment — metric nào phải có dashboard riêng
Observability của payment system khác với service thông thường ở một điểm: mọi metric đều quy ra được thành tiền. p99 latency không chỉ là UX — nó quyết định bao nhiêu khách bỏ giỏ hàng. Auth rate không chỉ là "ok hay không" — nó là phần trăm doanh thu bạn đang mất vì acquirer từ chối. Dashboard payment chuẩn phải có đủ các metric dưới đây, phân cắt theo PSP, theo card scheme, theo BIN, theo quốc gia.
| Metric | Ý nghĩa | SLO gợi ý 2026 | Phân cắt |
|---|---|---|---|
| authorization_rate | % intent approved / tổng intent | ≥ 92% cho non-3DS, ≥ 87% cho 3DS | PSP, scheme, BIN, country |
| capture_latency_p99 | p99 thời gian từ request đến capture | <5s (non-3DS), <30s (3DS) | PSP, amount bucket |
| webhook_lag_seconds | Lag giữa PSP event và ledger update | <60s p99, <600s p99.9 | event_type |
| recon_mismatch_count | Số dòng mismatch sau nightly | <10/ngày tự khỏi, 0 escalate | mismatch_type |
| idempotency_replay_rate | % request trả response cũ | <1% bình thường; spike = client bug | endpoint, tenant |
| fraud_block_rate | % intent bị risk engine chặn | Cân bằng với chargeback rate | risk model version |
| chargeback_rate | % txn thành chargeback | <0.9% — vượt ngưỡng là mất tư cách merchant | scheme, MCC |
Stack observability khuyến nghị cho .NET 10: OpenTelemetry cho tracing và metric, Tempo hoặc Jaeger cho distributed trace, Loki cho log có cấu trúc, Prometheus + Grafana cho dashboard. Quan trọng nhất: mỗi metric phải trace được về một ledger entry. Trace ID gắn vào header webhook gửi đến PSP (nơi PSP hỗ trợ) để khi investigate incident ta có đường truy nguyên xuyên biên giới.
11. Bảo mật và compliance — PCI DSS v4, tokenization, và luật chơi không được đùa
Payment system động đến PAN (card number), CVV, biến bạn thành đối tượng scope của PCI DSS v4.0.1 — quy định áp dụng đầy đủ từ tháng 3/2025. Cách giảm scope duy nhất, và cũng là cách đúng để team nhỏ làm payment, là không bao giờ chạm PAN.
- Tokenization tại biên. SDK frontend của PSP (Stripe Elements, Adyen Web Components) nhận PAN từ user rồi trao đổi trực tiếp với PSP lấy token. Server của bạn chỉ thấy token — scope PCI rơi xuống mức SAQ A, giảm từ ~400 control xuống ~30.
- Vault nếu phải lưu. Cần charge khách định kỳ không có sự có mặt, dùng customer vault của PSP thay vì tự lưu. Token vault chỉ có PSP decrypt, bạn chỉ giữ
customer_id. - Encryption at rest + in transit mọi chỗ. TLS 1.3 bắt buộc cho mọi kết nối đến PSP; DB column chứa dữ liệu nhạy cảm (billing address, last-4, fingerprint) mã hóa bằng KMS-managed key với quarterly rotation.
- Key management tách khỏi service. Secret không nằm trong appsettings; dùng Azure Key Vault, AWS KMS, HashiCorp Vault. Audit access log phải lưu ≥ 1 năm.
- Separation of duties. Người deploy code không được đồng thời là người duyệt refund thủ công trong admin tool. Tách role rõ để vượt qua được ISO 27001 và SOC 2.
Điều ngành hay bỏ sót: BIN-based routing và network tokens
PCI DSS v4 khuyến khích dùng network token (Visa VTS, Mastercard MDES) thay vì PAN-based token của PSP — tăng auth rate lên 3–5% và loại bỏ nguy cơ token hết hạn khi khách đổi thẻ. Song song, BIN-based routing giúp bạn chọn acquirer tối ưu theo quốc gia/scheme của thẻ, tăng auth rate thêm 1–2%. Hai tối ưu này "ẩn" nhưng cộng lại là hàng triệu USD doanh thu/năm cho merchant cỡ trung.
12. Production checklist — 20 mục không được thiếu trước khi go-live
Go-live payment system không giống go-live service thông thường. Một sự cố ngày đầu có thể gây chargeback đủ để mất quyền merchant. Checklist dưới đây là giao điểm của nhiều post-mortem công khai (Stripe, GoCardless, Monzo) và kinh nghiệm triển khai payment nội địa Việt Nam.
| Nhóm | Mục bắt buộc | Ghi chú |
|---|---|---|
| Correctness | Idempotency với body-hash và scope theo tenant | Redis + Postgres 2 lớp |
| Ledger double-entry, balanced trigger | Sum = 0 enforce ở DB | |
| Saga orchestration cho mọi flow > 2 bước | Temporal hoặc Dapr Workflows | |
| Transactional outbox cho mọi event phát ra ngoài | SKIP LOCKED cho relay song song | |
| Resilience | Circuit breaker cho mọi call đến PSP | Polly v8, ngưỡng 50% fail/30s |
| Timeout nghiêm khắc (không default 100s) | ≤30s cho authorize, ≤5s cho tokenize | |
| Không retry acquirer khi không có idem-key phía acquirer | Để recon xử lý | |
| Dead letter queue cho mọi consumer | Alert khi depth > ngưỡng | |
| Observability | OTel tracing xuyên webhook | W3C Trace Context header |
| Dashboard auth rate, capture latency, webhook lag | Phân cắt PSP/country/BIN | |
| Runbook cho mọi recon exception type | Kế toán đọc được | |
| PagerDuty rung khi mismatch > 10/ngày | Auto-ticket | |
| Security | Tokenization biên — không chạm PAN | PCI scope xuống SAQ A |
| HMAC webhook với tolerance ≤5 phút | Chống replay | |
| Secret trong Key Vault / KMS, rotate quarterly | Không commit vào appsettings | |
| Audit log mọi refund thủ công ≥ 1 năm | Immutable, WORM storage | |
| Compliance & Go-live | Load test với failure injection (toxiproxy) | Test PSP 500/slow/partition |
| Chaos drill hàng tháng (Redis down, DB failover) | Game day script | |
| Separation of duties deploy vs refund | ISO 27001 / SOC 2 | |
| Kill switch cho từng PSP | Auto-fallback sang PSP dự phòng |
13. Kết luận — payment là nơi kỷ luật kỹ thuật gặp kỷ luật kinh doanh
Mọi pattern trong bài này — idempotency, ledger double-entry, saga, outbox, webhook at-least-once, reconciliation, 3DS state machine, observability chuyên biệt — đều tồn tại vì trong payment, một phần triệu lỗi cũng trở thành tiền thật đi sai chỗ. Chúng không phải là "best practice tùy chọn" mà là sàn tối thiểu; team bỏ qua bất kỳ mục nào cũng sẽ trả giá bằng incident đau thương, chỉ là sớm hay muộn.
Điểm khích lệ: toàn bộ kỹ thuật trong bài đã được tooling hóa tốt cho stack .NET 10. Temporal SDK cho saga, Npgsql cho serializable transaction, Polly v8 cho resilience, OpenTelemetry cho observability, Stripe/Adyen SDK native .NET cho PSP integration. Team kỹ thuật vừa có công cụ, vừa có playbook công khai; thứ còn thiếu là kỷ luật triển khai từng lớp đúng chỗ. Và đó là điểm mà một senior engineer có thể tạo ra giá trị rõ nhất cho doanh nghiệp: biến tập hợp pattern phức tạp này thành một hệ thống đơn giản để vận hành, dễ debug lúc 2h sáng, và không bao giờ làm khách hàng hoặc kế toán bất ngờ.
14. Tham khảo
- Stripe Engineering — Designing robust and predictable APIs with idempotency
- Stripe Docs — Payment Intents API và lifecycle states
- Chris Richardson — Saga Pattern trong microservices
- Microservices.io — Transactional Outbox Pattern
- Temporal .NET SDK — workflow và activity API
- PCI SSC — PCI DSS v4.0.1 Requirements and Security Assessment Procedures
- Adyen — 3D Secure 2 và Strong Customer Authentication
- Martin Fowler — Patterns of Distributed Systems
- OpenTelemetry — .NET instrumentation
- Polly v8 — Resilience strategies cho .NET
Feature Flags & Progressive Delivery 2026 - Tách Deploy khỏi Release với OpenFeature, Canary và A/B Test cho .NET 10 và Vue
Modular Monolith với .NET 10 - Kiến trúc trung dung giữa Monolith và Microservices với Vertical Slice, Wolverine và Bounded Context
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.