Bỏ qua đến nội dung
Quay lại

Thiết kế hệ thống thanh toán với sổ cái kép

Đã đăng:  at  10:00 SA

1. Bài toán và phạm vi

Ta cần một nền tảng thanh toán cho merchant và khách hàng. Nền tảng nhận payment intent từ thẻ và ngân hàng, ghi nhận nghĩa vụ của nền tảng với từng bên, refund charge đã capture, payout cho merchant và theo dõi dispute. Merchant portal cùng đội vận hành nội bộ cần lịch sử bền vững, có thể giải thích mọi khoản tiền mà không phụ thuộc vào các trường số dư có thể bị thay đổi.

Bài viết này bao quát command API, idempotency, sổ cái kép, tích hợp provider, reconciliation và các quyết định chính về consistency cũng như xử lý lỗi. Mô hình capacity có số liệu bên dưới là giả định minh họa, không phải tuyên bố về một hệ thống thực tế.

Yêu cầu

Yêu cầu chức năng gồm:

Yêu cầu phi chức năng nghiêm ngặt hơn CRUD thông thường:

“Exactly once” mô tả invariant của sổ cái, không mô tả việc giao request trên mạng. Request và lời gọi provider có thể được giao at-least-once. Unique command key, immutable transaction, provider idempotency key và reconciliation phối hợp để ngăn financial effect trùng lặp và xử lý các kết quả ban đầu chưa rõ.

2. Mô hình capacity

Các số liệu sau là [ASSUMPTION: giả định minh họa]:

Phép tính:

Mô hình dành thêm 25% command so với customer action cho merchant automation. Read được mô hình hóa ở tỷ lệ 8:1 so với write vì dashboard, receipt và truy vấn reconciliation đọc lại lịch sử nhiều lần. Ở planning peak, đó là khoảng 800 read request/giây nếu mọi read đều đi qua service.

Storage cũng là giả định minh họa. Một payment row hoặc journal-line row, gồm index và metadata, trung bình 700 byte:

Ở 100 command/giây với request 3 KB, peak ingress là 2.4 Mb/s. Ở 800 read/giây với response 10 KB, peak egress là 64 Mb/s. Các số này chưa gồm provider webhook và export; theo giả định sizing, cần tối thiểu 1 Gb/s cho mỗi production zone.

3. Thiết kế API

Mọi endpoint dùng TLS và OAuth2 authorization cho user hoặc service. Idempotency-Key bắt buộc với command và được scope theo merchant cùng endpoint. Server lưu request hash, status, response body và resource ID trong 30 ngày. Dùng lại key với request body khác sẽ trả 409.

Các ví dụ dùng identifier và amount mang tính minh họa.

Tạo và capture charge

POST /v1/charges
Authorization: Bearer <token>
Idempotency-Key: ch_merchant_20260815_001
Content-Type: application/json

{"amount":12500,"currency":"USD","merchant_id":"m_42","payment_method_token":"pm_tok_9","capture":true}
{"id":"ch_901","status":"succeeded","amount":12500,"currency":"USD","ledger_transaction_id":"ltx_7001","provider_payment_id":"pp_88"}

Amount là integer theo minor unit của currency. Service kiểm tra merchant ownership, currency và tokenization, rồi chỉ ghi local effect sau khi đã correlate an toàn kết quả từ provider. Nếu timeout khiến outcome của provider chưa rõ, service trả 202 với status: "pending"; client poll GET /v1/charges/{id}.

Refund

POST /v1/charges/ch_901/refunds
Idempotency-Key: rf_merchant_20260815_001
Content-Type: application/json

{"amount":3000,"reason":"customer_request"}
{"id":"rf_301","status":"succeeded","amount":3000,"charge_id":"ch_901","ledger_transaction_id":"ltx_7010"}

Service xác minh charge đã capture, tổng refund không vượt quá amount đã capture và command key chưa tạo refund trước đó.

Payout

POST /v1/merchants/m_42/payouts
Idempotency-Key: po_merchant_20260815_001
Content-Type: application/json

{"amount":8000,"currency":"USD","destination_token":"bank_tok_2"}
{"id":"po_501","status":"processing","amount":8000,"currency":"USD","ledger_transaction_id":"ltx_7020"}

Payout authorization kiểm tra available balance và giới hạn risk hoặc velocity trong cùng database transaction. Provider execution có thể giữ trạng thái processing; webhook hoặc settlement file chuyển trạng thái thành paid hoặc failed.

Các endpoint khác gồm GET /v1/ledger/accounts/{id}/entries?cursor=..., POST /v1/provider-events để nhận signed webhook, POST /v1/reconciliation/runs và POST /v1/disputes/{id}/accept hoặc /contest. Webhook ingestion xác thực chữ ký provider và deduplicate theo provider event ID.

4. Data model

Schema kiểu PostgreSQL dưới đây thể hiện các invariant quan trọng. Amount là integer theo minor unit; không dùng floating point cho tiền.

CREATE TABLE idempotency_keys (
  merchant_id bigint NOT NULL,
  endpoint text NOT NULL,
  idem_key text NOT NULL,
  request_hash bytea NOT NULL,
  status text NOT NULL,
  response_json jsonb,
  resource_id bigint,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (merchant_id, endpoint, idem_key)
);

CREATE TABLE ledger_accounts (
  account_id bigint PRIMARY KEY,
  owner_type text NOT NULL,
  owner_id bigint NOT NULL,
  currency char(3) NOT NULL,
  account_type text NOT NULL,
  UNIQUE (owner_type, owner_id, currency, account_type)
);

CREATE TABLE ledger_transactions (
  transaction_id bigint PRIMARY KEY,
  command_id text NOT NULL UNIQUE,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE ledger_lines (
  line_id bigint PRIMARY KEY,
  transaction_id bigint NOT NULL REFERENCES ledger_transactions,
  account_id bigint NOT NULL REFERENCES ledger_accounts,
  amount_minor bigint NOT NULL,
  direction text NOT NULL CHECK (direction IN ('debit', 'credit')),
  created_at timestamptz NOT NULL DEFAULT now()
);

[PROPOSED DESIGN] Database transaction insert business record, idempotency record, ledger transaction và các line cùng nhau. Deferred constraint hoặc check tại thời điểm transaction phải đảm bảo debit bằng credit trong từng transaction. Unique command_id ngăn việc tạo ledger transaction thứ hai cho cùng accepted command. Available-balance check dùng row lock trên account liên quan hoặc serializable transaction; đây là lựa chọn triển khai, không phải tuyên bố về một database deployment cụ thể.

[PROPOSED DESIGN] Balance có thể được duy trì dưới dạng projection để đọc nhanh, nhưng không phải source of truth. Rebuild balance từ journal line phải cho cùng kết quả. Business path không update hoặc delete journal row. Audit record có thể lưu actor, request hash, provider reference và lý do của operational action.

5. Ledger posting

[PROPOSED DESIGN] Mỗi business event ánh xạ thành các posting cân bằng. Tên account dưới đây không mô tả hệ thống của một công ty cụ thể.

Account cụ thể và cách xử lý fee phụ thuộc vào hợp đồng kinh doanh và settlement model của provider. Invariant cốt lõi đơn giản hơn: mỗi transaction đã commit có ít nhất một debit và một credit, tất cả cùng currency, và tổng signed amount bằng 0.

6. Provider call và xử lý lỗi

[PROPOSED DESIGN] Không giữ database transaction mở trong lúc chờ provider. Command trước hết tạo local pending record, sau đó async worker thực hiện provider call. Worker gửi cùng provider idempotency key khi retry và lưu request cũng như response reference của provider.

Nếu provider trả thành công, worker post ledger transaction và đánh dấu resource là succeeded trong một local database transaction. Nếu call timeout, outcome là unknown: retry với cùng provider key, query provider nếu provider hỗ trợ, rồi chờ webhook hoặc reconciliation file. Không được mặc định timeout nghĩa là failure.

Timeout cần retry có giới hạn, kèm backoff và jitter. Circuit breaker có thể ngừng các provider call mới khi lỗi kéo dài; queue tạo backpressure (giới hạn tốc độ nhận việc để hệ thống không quá tải). Các cơ chế này bảo vệ service, nhưng không quyết định financial outcome. Dead-letter queue phù hợp cho message cần điều tra thủ công, nhưng replay vẫn phải idempotent.

API nên trả pending thay vì tự tạo success hoặc failure khi outcome của provider chưa rõ. Client có thể poll hoặc consume resource event. Provider webhook endpoint phải xác thực signature, persist raw event trước khi xử lý, deduplicate event ID và chịu được việc event đến không đúng thứ tự.

7. Reconciliation và dispute

[PROPOSED DESIGN] Reconciliation so sánh charge, refund, payout, fee và provider event nội bộ với provider report và bank statement. Kết quả nên phân biệt rõ matched, missing-internal, missing-provider, amount-mismatch và status-mismatch. Mismatch trở thành operational case; không được âm thầm sửa bằng cách thay đổi balance.

Reconciliation job phải chạy lại an toàn. Job lưu source file hoặc event identifier, comparison version, timestamp và ledger transaction dùng cho correction nếu có. Correction là compensating ledger transaction, không phải chỉnh sửa historical line.

Dispute là một case có status, evidence, deadline, provider reference và các ledger transaction liên kết. [PROPOSED DESIGN] Reserve hoặc reverse amount đang tranh chấp, thông báo cho operations và post compensating transaction khi case được giải quyết. Case state và ledger posting phải liên kết để operator không thể đánh dấu resolved mà không có financial effect có audit.

8. Ranh giới consistency và vận hành

[ANALYSIS] Local database là consistency boundary cho business record, idempotency key và ledger posting. Provider là external system có state riêng. Hai hệ thống không thể trở thành một atomic transaction nếu không dùng distributed transaction, vì vậy thiết kế dùng state machine, provider operation idempotent, webhook và reconciliation.

Metrics nên tách local acceptance latency khỏi provider completion latency. Các tín hiệu hữu ích gồm pending age, retry count, provider error rate, webhook lag, reconciliation mismatch, ledger-balance violation và payout failure. Alert nên gắn với SLO đã nêu và financial invariant, không chỉ với HTTP error rate.

Thiết kế này không hứa mọi network call đều thành công ngay. Nó làm cho uncertainty hiển thị rõ, ngăn posting trùng, giữ ledger có thể kiểm toán và cung cấp quy trình có kiểm soát để operations xử lý bất đồng với provider.


Chia sẻ bài viết này trên:

Bài trước
Thiết kế hệ thống thông báo đa kênh, bền vững
Bài tiếp theo
Rate Limiter Phân tán: Thiết kế và xử lý lỗi