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

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.

0Số lần double-charge chấp nhận được
≥99.99%Authorization success rate mục tiêu production
<5sp99 end-to-end auth qua 3DS
T+0Reconciliation chuẩn với acquirer

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 ChoreographySaga Orchestration
Cách điều khiểnMỗi service nghe event và phản ứngMột orchestrator trung tâm gọi từng service
CouplingThấp — services không biết nhauCao hơn — orchestrator biết toàn bộ flow
ObservabilityKhó — flow phân tán trong event logDễ — state machine tập trung
CompensationPhứ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ớiFlow đơn giản, <4 bước, team độc lậpPayment, booking, order — nhiều bước phải rollback
Ví dụ tool 2026MassTransit, NServiceBus, KafkaTemporal, 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ếnTriệu chứngHậu quảCách phòng
Không verify signatureChấp nhận request giả danh PSPAttacker tự "confirm" payment giảHMAC check với shared secret, reject <tolerant window
Không xử lý duplicatePSP retry 2–3 lần, ghi nhiều lầnDouble ledger entry, bookkeeping saiIdempotent theo event_id của PSP
Trả 2xx quá sớmPSP tưởng xử lý xong, thực tế chưaMất event khi worker crash giữa chừngPersist vào queue nội bộ trước, ack sau
Không handle out-of-ordersucceeded đến trước createdState machine reject event hợp lệBuffer và resolve theo event_type precedence
Xử lý inline chậmTimeout phía PSP, retry stormWebhook queue sâu vài chục nghìnNhậ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.

23:30 — fetch settlement
Worker kéo settlement file hoặc gọi API /v1/balance_transactions cho toàn bộ ngày T-1, ghi vào bảng staging psp_settlement_raw.
23:45 — normalize
Chuẩn hóa format (Stripe, Adyen, VNPay mỗi nơi một kiểu) về common schema: (psp_ref, type, amount_minor, currency, occurred_at).
00:00 — diff
LEFT JOIN ledger với settlement theo 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.
00:30 — auto-heal
Với loại (b), query lại PSP bằng 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.
01:00 — cảnh báo
Mismatch còn lại sau auto-heal được ghi vào 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_action quá 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_outcome trong ledger_txn giú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ĩaSLO gợi ý 2026Phân cắt
authorization_rate% intent approved / tổng intent≥ 92% cho non-3DS, ≥ 87% cho 3DSPSP, scheme, BIN, country
capture_latency_p99p99 thời gian từ request đến capture<5s (non-3DS), <30s (3DS)PSP, amount bucket
webhook_lag_secondsLag giữa PSP event và ledger update<60s p99, <600s p99.9event_type
recon_mismatch_countSố dòng mismatch sau nightly<10/ngày tự khỏi, 0 escalatemismatch_type
idempotency_replay_rate% request trả response cũ<1% bình thường; spike = client bugendpoint, tenant
fraud_block_rate% intent bị risk engine chặnCân bằng với chargeback raterisk model version
chargeback_rate% txn thành chargeback<0.9% — vượt ngưỡng là mất tư cách merchantscheme, 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ómMục bắt buộcGhi chú
CorrectnessIdempotency với body-hash và scope theo tenantRedis + Postgres 2 lớp
Ledger double-entry, balanced triggerSum = 0 enforce ở DB
Saga orchestration cho mọi flow > 2 bướcTemporal hoặc Dapr Workflows
Transactional outbox cho mọi event phát ra ngoàiSKIP LOCKED cho relay song song
ResilienceCircuit breaker cho mọi call đến PSPPolly 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 consumerAlert khi depth > ngưỡng
ObservabilityOTel tracing xuyên webhookW3C Trace Context header
Dashboard auth rate, capture latency, webhook lagPhân cắt PSP/country/BIN
Runbook cho mọi recon exception typeKế toán đọc được
PagerDuty rung khi mismatch > 10/ngàyAuto-ticket
SecurityTokenization biên — không chạm PANPCI scope xuống SAQ A
HMAC webhook với tolerance ≤5 phútChống replay
Secret trong Key Vault / KMS, rotate quarterlyKhông commit vào appsettings
Audit log mọi refund thủ công ≥ 1 nămImmutable, WORM storage
Compliance & Go-liveLoad 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 refundISO 27001 / SOC 2
Kill switch cho từng PSPAuto-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