Mục lục
- Mục Tiêu Bài Học
- Stripe PaymentIntent Flow
- Cài stripe-rust Crate + Env Config
- shop-db::payments Extend — Insert + Update Status
- POST /payments/orders/{id}/intent Handler
- Stripe Webhook Signature Verify
- Idempotent Webhook Handler — Dedup Event.id
- Wire Routes + Verify Local Với Stripe CLI
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu Stripe PaymentIntent flow — server tạo intent + client confirm với 3DS challenge + automatic capture vs manual confirm.
- Cài
stripe-rustcrate v0.36+ (community Stripe Rust SDK) với featureruntime-tokio-hyper-rustlsalign tokio + rustls workspace lock. - Implement
POST /api/v1/payments/orders/{order_id}/intentgọi Stripe API tạo PaymentIntent + returnclient_secretcho frontend Stripe Elements. - Implement
POST /webhooks/stripevới signature verify HMAC-SHA256 (lock B37 continued — webhook signature verify pattern). - Pattern idempotent webhook handler: dedup qua
event.idPRIMARY KEY + bảng audit trailstripe_webhook_events7 column + 2 index. - Update
payments.statustừ webhook event qua 2 handlerPaymentIntentSucceeded+PaymentIntentPaymentFailed; transitionorders.statuspending → paid khi success. - Query
payment_payload @> {"payment_intent_id": ...}(lock B60 continued — JSONB@>containment vớijsonb_path_opsindex match nhanh). - Foundation cho B72 (Service Layer trait abstraction), G14 (analytics dashboard payment), G18 (Stripe production setup live key + 3DS SCA challenge full).
Stripe PaymentIntent Flow
Stripe có 2 API thanh toán — Charge API (legacy 2011) chỉ 1 step charge thẳng nhưng KHÔNG hỗ trợ SCA (Strong Customer Authentication) bắt buộc tại EU từ PSD2 2019 + KHÔNG retry safe nếu network drop; và PaymentIntent API (2018) — multi-step stateful object track full lifecycle thanh toán, hỗ trợ 3DS challenge tự động, retry an toàn qua idempotency key Stripe-native. Stripe khuyến cáo PaymentIntent cho mọi tích hợp mới từ 2019; Shop API lock PaymentIntent.
Flow đầy đủ 8 step request/response:
1. Client → Server: POST /api/v1/payments/orders/{order_id}/intent
2. Server → Stripe API: stripe.PaymentIntent.create(amount, currency, metadata)
3. Stripe → Server: PaymentIntent { id: "pi_xxx", client_secret: "pi_xxx_secret_yyy", status: "requires_payment_method" }
4. Server → Client: { client_secret }
5. Client (browser, Stripe Elements): stripe.confirmCardPayment(client_secret, { payment_method: {...} })
6. Stripe (3DS challenge nếu issuer yêu cầu) → user complete OTP/biometric
7. Stripe → Server (webhook): payment_intent.succeeded HOẶC payment_intent.payment_failed
8. Server: update payments.status + orders.status driven bởi webhook event
3 nguyên tắc bất biến đi kèm flow này:
- Server KHÔNG handle card data — toàn bộ thẻ tín dụng (number, CVV, exp) đi thẳng từ browser qua Stripe.js SDK lên Stripe servers; Shop API chỉ trao đổi PaymentIntent ID + client_secret. Đây là yêu cầu PCI compliance — nếu Shop API touch raw card data thì phải audit PCI DSS Level 1 (cost ~50k-100k USD/năm + recurring audit).
- Webhook là source of truth — server tin webhook (Stripe → Shop API) KHÔNG tin client response (browser → Shop API). Lý do: client có thể bị đóng tab giữa lúc 3DS challenge, network drop, hoặc malicious user fake response success. Webhook đến từ Stripe có signature HMAC verify được, không thể fake.
- Test mode — Stripe có 2 mode: test (key
sk_test_...+ test card4242 4242 4242 4242miễn phí mọi flow) + live (keysk_live_...charge thật, cần verify business). Shop API dev + CI chỉ dùng test mode; live mode chỉ activate ở G18 production setup.
Cài stripe-rust Crate + Env Config
Stripe KHÔNG maintain official Rust SDK — stripe-rust là community crate (maintained bởi arlyon từ 2018, version 0.36 release Q1 2026 cover toàn bộ Stripe API 2026-01 version + auto-generated từ OpenAPI spec). Cài qua workspace deps:
# File: Cargo.toml (workspace root)
[workspace.dependencies]
# ... các dep cũ B70 giữ nguyên
stripe = { version = "0.36", features = ["runtime-tokio-hyper-rustls"] }
Add vào crates/shop-api/Cargo.toml:
# File: crates/shop-api/Cargo.toml
[dependencies]
# ... cũ
stripe = { workspace = true }
Feature runtime-tokio-hyper-rustls lock decision: align tokio runtime workspace lock B10 + hyper HTTP client + rustls pure-Rust TLS (skip native-tls/OpenSSL bloat + cross-platform reliable lock B51 continued). Crate cung cấp 2 nhóm API chính: stripe::Client sync với secret key + stripe::Webhook::construct_event helper verify signature parse Event.
Env config thêm 2 field vào AppConfig (B56 đã setup):
// File: crates/shop-api/src/config.rs (extend B56)
pub struct AppConfig {
// ... 8 field cũ B56 (app_env, port, database_url, redis_url, jwt_secret, ...)
pub stripe_secret_key: String, // sk_test_... (dev) hoặc sk_live_... (prod G18)
pub stripe_webhook_secret: String, // whsec_... shared secret Stripe webhook signing
}
impl AppConfig {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
// ... existing field cũ
stripe_secret_key: std::env::var("STRIPE_SECRET_KEY")
.map_err(|_| anyhow::anyhow!("STRIPE_SECRET_KEY env var required"))?,
stripe_webhook_secret: std::env::var("STRIPE_WEBHOOK_SECRET")
.map_err(|_| anyhow::anyhow!("STRIPE_WEBHOOK_SECRET env var required"))?,
})
}
}
Update .env.example dev onboarding:
# File: .env.example (extend B70)
# ... env cũ DATABASE_URL/PORT/APP_ENV/JWT_SECRET
# Stripe (test mode dev)
STRIPE_SECRET_KEY=sk_test_REPLACE_ME
STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME
Lock decision B71 — API key + webhook secret QUA env MANDATORY: KHÔNG hard-code vào source code (lock B70 security continued); nếu lộ thì rotate qua Stripe dashboard không cần redeploy code. Production (G18) inject env qua orchestrator secret manager (AWS Secrets Manager, Kubernetes Secret, HashiCorp Vault) — KHÔNG dùng .env file. from_env MANDATORY fail-fast ở startup (Result + anyhow::anyhow!) tránh runtime panic muộn lúc Stripe API call.
shop-db::payments Extend — Insert + Update Status
Module crates/shop-db/src/payments.rs đã có placeholder skeleton từ B54 (bảng payments + 6 cột + 2 index). B71 mở rộng thêm 3 hàm DB phục vụ 2 endpoint + 2 webhook handler:
// File: crates/shop-db/src/payments.rs (extend B54 skeleton)
use sqlx::PgPool;
use chrono::{DateTime, Utc};
use serde_json::Value;
pub struct PaymentRow {
pub id: i64,
pub order_id: i64,
pub payment_type: String,
pub payment_payload: Value,
pub status: String,
pub created_at: DateTime<Utc>,
}
/// Tạo hoặc cập nhật payment record cho order — UPSERT 1 order = 1 payment.
pub async fn create_stripe_payment(
pool: &PgPool,
order_id: i64,
payment_intent_id: &str,
customer_id: Option<&str>,
amount: i64,
currency: &str,
) -> Result<PaymentRow, sqlx::Error> {
let payload = serde_json::json!({
"payment_intent_id": payment_intent_id,
"customer_id": customer_id,
"amount": amount,
"currency": currency,
});
sqlx::query_as!(
PaymentRow,
r#"
INSERT INTO payments (order_id, payment_type, payment_payload, status)
VALUES ($1, 'stripe', $2, 'pending')
ON CONFLICT (order_id) DO UPDATE SET
payment_payload = EXCLUDED.payment_payload,
status = 'pending'
RETURNING id, order_id, payment_type,
payment_payload as "payment_payload: Value",
status, created_at
"#,
order_id, payload
)
.fetch_one(pool)
.await
}
Hàm update_status_by_intent dùng JSONB @> containment operator lock B60 — index jsonb_path_ops match nhanh O(log n) không phải scan toàn bảng:
// File: crates/shop-db/src/payments.rs (tiếp)
pub async fn update_status_by_intent(
pool: &PgPool,
payment_intent_id: &str,
new_status: &str,
) -> Result<Option<PaymentRow>, sqlx::Error> {
sqlx::query_as!(
PaymentRow,
r#"
UPDATE payments SET status = $1
WHERE payment_payload @> $2::jsonb
RETURNING id, order_id, payment_type,
payment_payload as "payment_payload: Value",
status, created_at
"#,
new_status,
serde_json::json!({"payment_intent_id": payment_intent_id})
)
.fetch_optional(pool)
.await
}
pub async fn find_by_intent_id(
pool: &PgPool,
payment_intent_id: &str,
) -> Result<Option<PaymentRow>, sqlx::Error> {
sqlx::query_as!(
PaymentRow,
r#"
SELECT id, order_id, payment_type,
payment_payload as "payment_payload: Value",
status, created_at
FROM payments
WHERE payment_payload @> $1::jsonb
"#,
serde_json::json!({"payment_intent_id": payment_intent_id})
)
.fetch_optional(pool)
.await
}
3 lock decision DB layer B71:
payment_payload @> $1::jsonblock B60 continued — query containment thay đổi từ "tìm payment cópayment_intent_id = X" thành "tìm payment mà payload chứa{"payment_intent_id": "X"}". Cùng kết quả, dùng indexjsonb_path_opsmatch O(log n); flexible khi schema payment_payload thay đổi (thêm customer_id, charge_id) không cần migration ALTER cột.ON CONFLICT (order_id) DO UPDATEUPSERT lock — 1 order = 1 payment record (B54 schema lock UNIQUE order_id constraint). Scenario: user click "Pay" lần đầu tạo PaymentIntent A; mất kết nối; click lần 2 → tạo PaymentIntent B; ON CONFLICT update payload sang B + reset status = pending; record cũ A bị orphan trong Stripe (sẽ auto-cancel sau 24h). Tránh duplicate row payment cùng order.- Status transition pending → success | failed (driven bởi webhook MANDATORY, KHÔNG bởi handler endpoint); enforce qua application layer + CHECK constraint B54 (status TEXT CHECK IN ('pending','success','failed')).
POST /payments/orders/{id}/intent Handler
Tạo file mới crates/shop-api/src/routes/payments.rs chứa handler create intent + webhook. Handler thứ nhất gọi Stripe API tạo PaymentIntent + persist record cục bộ:
// File: crates/shop-api/src/routes/payments.rs
use axum::{extract::{State, Path}, Json, http::StatusCode, http::HeaderMap};
use stripe::{Client, CreatePaymentIntent, PaymentIntent, Currency, Webhook};
use shop_common::error::AppError;
use shop_db::payments as db;
use crate::state::AppState;
#[derive(Debug, Clone, serde::Serialize)]
pub struct CreateIntentResponse {
pub client_secret: String,
pub payment_intent_id: String,
}
pub async fn create_payment_intent(
State(state): State<AppState>,
Path(order_id): Path<i64>,
) -> Result<Json<CreateIntentResponse>, AppError> {
// 1. Fetch order verify tồn tại + status hợp lệ
let order = sqlx::query!(
"SELECT id, total, status FROM orders WHERE id = $1",
order_id
)
.fetch_optional(&state.db).await?
.ok_or_else(|| AppError::NotFound(format!("order {} not found", order_id)))?;
if order.status != "pending" {
return Err(AppError::Validation(format!(
"cannot create payment intent for order in status {}",
order.status
)));
}
// 2. Convert Decimal total → smallest currency unit (VND integer, KHÔNG sub-unit)
let amount_cent = (order.total * rust_decimal::Decimal::from(100))
.to_i64()
.ok_or_else(|| AppError::Internal("amount conversion overflow".into()))?;
// 3. Gọi Stripe API tạo PaymentIntent
let client = Client::new(state.config.stripe_secret_key.clone());
let mut params = CreatePaymentIntent::new(amount_cent, Currency::VND);
params.metadata = Some(std::collections::HashMap::from([
("order_id".to_string(), order.id.to_string()),
]));
params.automatic_payment_methods = Some(stripe::CreatePaymentIntentAutomaticPaymentMethods {
enabled: true,
allow_redirects: None,
});
let intent = PaymentIntent::create(&client, params).await
.map_err(|e| AppError::Internal(format!("stripe create failed: {}", e)))?;
// 4. Persist payment record local DB
db::create_stripe_payment(
&state.db,
order_id,
&intent.id.to_string(),
None,
amount_cent,
"vnd",
).await?;
// 5. Return client_secret cho client confirm 3DS qua Stripe Elements
Ok(Json(CreateIntentResponse {
client_secret: intent.client_secret.unwrap_or_default(),
payment_intent_id: intent.id.to_string(),
}))
}
4 lock decision handler create intent:
- Metadata
order_idđính kèm PaymentIntent — Stripe gửi metadata về trong webhook event payload, giúp trace ngược order_id khi event đến. Có thể thêmuser_idsau khi B112 wire JWT auth. - Currency lock VND Shop API — Việt Nam đồng integer, KHÔNG sub-unit (1 VND nhỏ nhất, không có "xu"). Khác USD/EUR có cent (1 USD = 100 cent). Convert
order.total NUMERIC(15,2)sang i64 qua* 100để align Stripe API smallest currency unit convention — nhưng VND đã integer thì nhân 100 dư 2 chữ số 0 ở cuối (vd50000.00 VND → 5000000) phục vụ Stripe accept, không ảnh hưởng UX (frontend display lại / 100). - Status guard order = pending — không cho tạo intent cho order đã paid/shipped/cancelled (defensive); 422 Validation nếu vi phạm.
automatic_payment_methods.enabled = true— Stripe tự render UI Elements support nhiều method (card, Apple Pay, Google Pay, ...) thay vì hard-code chỉ card;allow_redirects: Noneđể Stripe quyết định theo method.
Stripe Webhook Signature Verify
Stripe gửi event qua webhook URL với HTTP POST + header Stripe-Signature chứa timestamp + HMAC-SHA256:
POST /webhooks/stripe HTTP/1.1
Host: api.shop.example
Stripe-Signature: t=1718438400,v1=8c7e2c3c1c8b2d3c4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d
Content-Type: application/json
{"id": "evt_xxx", "type": "payment_intent.succeeded", "data": {...}}
Signature format chuẩn Stripe: HMAC-SHA256(secret, <timestamp>.<raw_body>) — secret là STRIPE_WEBHOOK_SECRET shared giữa Stripe + Shop API; concat timestamp + "." + raw_body (body MANDATORY raw bytes, KHÔNG re-serialize qua serde_json::Value vì sẽ thay đổi byte order key); compute HMAC-SHA256 + hex encode + compare với v1= value bằng constant-time. Pattern verify tóm tắt 4 step:
- Parse
t=<ts>+v1=<hex>từ header value. - Reject nếu
now() - ts > 5 phút(replay attack window — attacker capture + replay sau 5 phút sẽ fail). - Compute
HMAC-SHA256(secret, ts.body)+ hex encode. - Constant-time compare với
v1(lock B37 + B70 security continued).
Crate stripe-rust đã đóng gói toàn bộ pattern trên qua helper Webhook::construct_event — Shop API chỉ gọi 1 hàm thay tự implement HMAC manual:
// File: crates/shop-api/src/routes/payments.rs (tiếp)
pub async fn stripe_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: String, // raw body cho HMAC verify
) -> Result<StatusCode, AppError> {
// 1. Get signature header
let signature = headers
.get("Stripe-Signature")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::BadRequest("missing Stripe-Signature header".into()))?;
// 2. Verify signature + parse Event (HMAC-SHA256 + 5 phút tolerance built-in)
let event = Webhook::construct_event(
&body,
signature,
&state.config.stripe_webhook_secret,
)
.map_err(|e| {
tracing::warn!(?e, "stripe webhook signature invalid");
AppError::BadRequest("invalid webhook signature".into())
})?;
// 3. Dispatch handler theo event type (idempotent dedup BƯỚC 7)
handle_stripe_event(&state, event).await?;
Ok(StatusCode::OK)
}
4 lock decision webhook verify B71:
Webhook::construct_eventhelper — dùng implementation Stripe-official KHÔNG manual HMAC implement. Đỡ 2 class lỗi: parse header sai format (Stripe có thể thêmv0/v2scheme tương lai) + constant-time compare quên (B70 lock continued).- 5 phút tolerance — default crate, replay attack window. Attacker capture signed webhook + replay sau 5 phút → Stripe check timestamp old → reject. Stripe docs khuyến cáo tolerance 5 phút (300 giây) balance giữa network latency + security.
- 400 BadRequest cho signature fail — KHÔNG 401 Unauthorized (401 imply credential sai, leak info "endpoint này cần auth + bạn provide wrong key"). 400 chung chung "request invalid" không expose internal logic. Stripe sẽ retry exponential 3 days nếu KHÔNG nhận 2xx — log warn để alert (signature liên tục fail có thể là webhook secret expired sau rotate).
- Body MANDATORY String raw (KHÔNG
Json<Value>) — axum extractJsonsẽ deserialize + re-serialize lại body, thay đổi byte order key → HMAC compute trên body modified sẽ FAIL signature verify. Bắt buộc dùngStringhoặcBytesextractor giữ raw bytes nguyên bản (lock B37 continued — raw body extractor MANDATORY cho webhook).
Idempotent Webhook Handler — Dedup Event.id
Stripe có thể gửi cùng 1 event 2 lần trong nhiều scenario: (i) network blip giữa Stripe → Shop API → Stripe không nhận 2xx → retry; (ii) server timeout > 10 giây Stripe coi như fail → retry; (iii) Shop API restart giữa lúc process → event re-deliver. Pattern dedup MANDATORY: cache event.id trong DB table riêng + check trước khi process. Migration 14:
-- File: crates/shop-db/migrations/20260616130000_create_stripe_webhook_events.sql
CREATE TABLE stripe_webhook_events (
event_id TEXT PRIMARY KEY, -- Stripe event ID (evt_xxx)
event_type TEXT NOT NULL, -- payment_intent.succeeded, ...
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ, -- NULL khi chưa xong
status TEXT NOT NULL DEFAULT 'received'
CHECK (status IN ('received', 'processed', 'failed')),
error_message TEXT, -- log error nếu status = failed
raw_payload JSONB NOT NULL -- replay nếu cần
);
CREATE INDEX stripe_webhook_events_type_idx
ON stripe_webhook_events(event_type, received_at DESC);
CREATE INDEX stripe_webhook_events_status_idx
ON stripe_webhook_events(status) WHERE status != 'processed';
Handler chính handle_stripe_event 4 step: check dedup, insert record, process theo type, mark status:
// File: crates/shop-api/src/routes/payments.rs (tiếp)
async fn handle_stripe_event(
state: &AppState,
event: stripe::Event,
) -> Result<(), AppError> {
let event_id = event.id.to_string();
let event_type = event.type_.to_string();
// 1. Check dedup — Stripe có thể gửi event 2 lần
let existing = sqlx::query!(
"SELECT status FROM stripe_webhook_events WHERE event_id = $1",
event_id
)
.fetch_optional(&state.db).await?;
if let Some(row) = existing {
tracing::info!(event_id, status = %row.status, "stripe webhook already received, skip");
return Ok(()); // idempotent return success
}
// 2. Insert event record audit trail (raw_payload preserve)
let raw_payload = serde_json::to_value(&event)?;
sqlx::query!(
r#"
INSERT INTO stripe_webhook_events (event_id, event_type, raw_payload)
VALUES ($1, $2, $3)
"#,
event_id, event_type, raw_payload
)
.execute(&state.db).await?;
// 3. Process event theo type
let process_result = match event.type_ {
stripe::EventType::PaymentIntentSucceeded => {
handle_payment_succeeded(state, &event).await
}
stripe::EventType::PaymentIntentPaymentFailed => {
handle_payment_failed(state, &event).await
}
other => {
tracing::info!(?other, "stripe event type not handled");
Ok(())
}
};
// 4. Mark processed | failed status
match &process_result {
Ok(_) => {
let _ = sqlx::query!(
"UPDATE stripe_webhook_events SET status = 'processed', processed_at = NOW() WHERE event_id = $1",
event_id
).execute(&state.db).await;
}
Err(e) => {
let _ = sqlx::query!(
"UPDATE stripe_webhook_events SET status = 'failed', error_message = $2 WHERE event_id = $1",
event_id, e.to_string()
).execute(&state.db).await;
}
}
process_result
}
2 handler riêng cho event type chính:
// File: crates/shop-api/src/routes/payments.rs (tiếp)
async fn handle_payment_succeeded(
state: &AppState,
event: &stripe::Event,
) -> Result<(), AppError> {
let intent = match &event.data.object {
stripe::EventObject::PaymentIntent(pi) => pi,
_ => return Err(AppError::BadRequest("expected PaymentIntent".into())),
};
let intent_id = intent.id.to_string();
// Update payment record local: status pending → success
let payment = db::update_status_by_intent(&state.db, &intent_id, "success").await?
.ok_or_else(|| AppError::NotFound(format!("payment not found for intent {}", intent_id)))?;
// Transition order: pending → paid (guard nếu đã transition trước đó)
sqlx::query!(
"UPDATE orders SET status = 'paid' WHERE id = $1 AND status = 'pending'",
payment.order_id
)
.execute(&state.db).await?;
tracing::info!(intent_id, order_id = payment.order_id, "payment succeeded");
Ok(())
}
async fn handle_payment_failed(
state: &AppState,
event: &stripe::Event,
) -> Result<(), AppError> {
let intent = match &event.data.object {
stripe::EventObject::PaymentIntent(pi) => pi,
_ => return Err(AppError::BadRequest("expected PaymentIntent".into())),
};
let intent_id = intent.id.to_string();
db::update_status_by_intent(&state.db, &intent_id, "failed").await?;
tracing::warn!(intent_id, "payment failed");
Ok(())
}
4 lock decision idempotent handler B71:
- Dedup qua
event.id PRIMARY KEY— Stripe đảm bảo event.id unique cross-event; PRIMARY KEY constraint UNIQUE auto giúp race-safe insert (2 webhook đến cùng lúc → 1 insert thành công, 1 fail unique violation). Pattern reuse cho mọi external webhook Shop API tương lai (GitHub webhook G15, Twilio webhook G18). - Stripe retry safe — lần 2 thấy event_id đã có trong bảng → log info + return Ok(()) → Stripe nhận 2xx + ngừng retry. Pattern chuẩn industry (Stripe docs khuyến cáo).
- Audit trail webhook events trong bảng riêng — không pollute bảng payments với metadata webhook; admin có thể query "tất cả event đã xử lý hôm qua" + "event nào status = failed" cho debug; G14 analytics dashboard reuse table này.
- Guard
status = 'pending'trong UPDATE orders — nếu order đã transition shipped/delivered (admin manual) thì webhook đến không revert ngược về paid (lock B65 state machine continued).
Wire Routes + Verify Local Với Stripe CLI
Routes function build 2 endpoint cho module payments — endpoint create_payment_intent nằm trong nest /api/v1 chung; endpoint stripe_webhook nằm NGOÀI /api/v1 theo third-party convention:
// File: crates/shop-api/src/routes/payments.rs (cuối file)
use axum::Router;
use axum::routing::post;
pub fn routes_v1() -> Router<AppState> {
Router::new()
.route("/payments/orders/{order_id}/intent", post(create_payment_intent))
}
pub fn routes_webhook() -> Router<AppState> {
Router::new()
.route("/webhooks/stripe", post(stripe_webhook))
}
Wire vào crates/shop-api/src/router.rs (B65 đã lock pattern api_v1 nest):
// File: crates/shop-api/src/router.rs (extend)
let api_v1 = Router::new()
// ... routes cũ B60-B70
.merge(routes::payments::routes_v1()); // B71 create_payment_intent
let app = Router::new()
.nest("/api/v1", api_v1)
.merge(routes::payments::routes_webhook()) // B71 /webhooks/stripe NGOÀI /api/v1
.with_state(state);
Cập nhật crates/shop-api/src/routes/mod.rs thêm pub mod payments;.
Lock decision URL webhook NGOÀI /api/v1: Stripe + GitHub + Twilio + mọi third-party webhook gọi URL trực tiếp không version. Lý do: (i) third-party service KHÔNG biết version API nội bộ Shop API; (ii) bump version /api/v1 → /api/v2 không nên break webhook; (iii) webhook là protocol contract với Stripe (signature secret), không phải REST resource client query. Convention /webhooks/{provider} top-level.
Test local với Stripe CLI — không cần expose public URL qua ngrok:
# Install Stripe CLI (macOS Homebrew)
brew install stripe/stripe-cli/stripe
# Login với Stripe account dashboard
stripe login
# → mở browser xác nhận device + grant access
# Forward webhook từ Stripe → local Shop API
stripe listen --forward-to localhost:3000/webhooks/stripe
# → "Ready! Your webhook signing secret is whsec_xxx (^C to quit)"
# Copy whsec_xxx set vào .env: STRIPE_WEBHOOK_SECRET=whsec_xxx
# Trigger test event mô phỏng payment succeeded
stripe trigger payment_intent.succeeded
# → Stripe CLI fake event + forward về localhost:3000/webhooks/stripe
# → Server log: "payment succeeded intent_id=pi_xxx order_id=N"
End-to-end test full flow:
# 1. Setup STRIPE_SECRET_KEY (từ dashboard test mode) + STRIPE_WEBHOOK_SECRET (từ stripe listen)
echo 'STRIPE_SECRET_KEY=sk_test_REPLACE_ME' >> .env
echo 'STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME' >> .env
# 2. Apply migration 14 + start server
cargo sqlx migrate run --source crates/shop-db/migrations
AUTO_MIGRATE=true cargo run -p shop-api
# 3. Create order pending (qua endpoint B66)
curl -X POST http://localhost:3000/api/v1/orders \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"items":[{"product_id":1,"quantity":2}],"payment":{"type":"stripe"}}'
# → 201 Created order_id = 1 status = pending
# 4. Create payment intent
curl -X POST http://localhost:3000/api/v1/payments/orders/1/intent
# → 200 OK
# {
# "client_secret": "pi_xxx_secret_yyy",
# "payment_intent_id": "pi_xxx"
# }
# 5. Client confirm payment (test mode: dùng Stripe CLI hoặc test card 4242 frontend Elements)
# Stripe gửi webhook payment_intent.succeeded về /webhooks/stripe
# 6. Verify order status updated
curl http://localhost:3000/api/v1/orders/1
# status = "paid"
# 7. Verify payment record
docker compose exec postgres psql -U shop -d shop_dev -c \
"SELECT id, order_id, status FROM payments WHERE order_id = 1;"
# status = success
# 8. Verify webhook event logged
docker compose exec postgres psql -U shop -d shop_dev -c \
"SELECT event_id, event_type, status FROM stripe_webhook_events ORDER BY received_at DESC;"
# status = processed
Tổng Kết
- Stripe PaymentIntent flow: server tạo intent → client confirm 3DS qua Stripe Elements → webhook source of truth.
- stripe-rust crate v0.36+ với feature
runtime-tokio-hyper-rustlsalign tokio + rustls workspace lock B10/B51. - Env config:
STRIPE_SECRET_KEY+STRIPE_WEBHOOK_SECRETquaAppConfig::from_envfail-fast — KHÔNG hard-code (lock B70 security continued). - PaymentIntent metadata
order_idđính kèm — Stripe gửi metadata về trong webhook event payload, giúp trace order khi event đến. - Currency VND lock Shop API + amount integer (VND KHÔNG có sub-unit, vẫn nhân 100 theo Stripe smallest currency unit convention).
Webhook::construct_eventhelper — verify HMAC-SHA256 + parse Event; KHÔNG manual implement HMAC (đỡ 2 class lỗi parse header + constant-time compare quên).- 5 phút tolerance default — replay attack window; Stripe docs khuyến cáo 300 giây.
- 400 BadRequest cho signature fail — KHÔNG 401 (avoid leak info credential).
- Body MANDATORY String raw (KHÔNG
Json<Value>) — re-serialize sẽ thay đổi byte order key → HMAC FAIL (lock B37 continued). - Idempotent webhook handler dedup qua
stripe_webhook_events.event_id PRIMARY KEY— UNIQUE auto race-safe. - Stripe retry 3 days exponential nếu KHÔNG 2xx — handler MANDATORY idempotent (skip nếu event_id đã tồn tại).
payment_payload @>JSONB query lock B60 continued chofind_by_intent_id+update_status_by_intent(jsonb_path_ops index O(log n) match).ON CONFLICT (order_id) DO UPDATEUPSERT lock B54 — 1 order = 1 payment, không duplicate row khi user click "Pay" 2 lần.- 2 event handler initial:
PaymentIntentSucceeded→orders.status = paid;PaymentIntentPaymentFailed→payment.status = failed; extend G14 thêm refund/dispute event. - URL
/webhooks/stripeNGOÀI/api/v1— third-party convention KHÔNG version (Stripe gọi trực tiếp không biết version). - Migration 14
stripe_webhook_events7 column (event_id PK + event_type + received_at + processed_at + status enum 3 value + error_message + raw_payload JSONB) + 2 index audit trail. - File path lock B71: NEW
crates/shop-api/src/routes/payments.rs+ extendcrates/shop-db/src/payments.rs(3 function create_stripe_payment + update_status_by_intent + find_by_intent_id) + NEW migration 1420260616130000_create_stripe_webhook_events.sql+ UPDATEDconfig.rs(2 field stripe_secret_key + stripe_webhook_secret) + UPDATED router.rs + routes/mod.rs + .env.example + workspace Cargo.toml.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- PaymentIntent flow vs Charge API cũ — pros của PaymentIntent cho SCA compliance? Tại sao Stripe trả
client_secretthay vì server-side handle thẳng card data? - Webhook signature verify HMAC-SHA256 — pattern attack nếu KHÔNG check timestamp? Giải thích replay attack window 5 phút.
- Idempotent webhook dedup event.id — Stripe retry policy 3 days exponential. Cho ví dụ scenario network blip + server timeout.
payment_payload @>JSONB query — pros so với column riêngpayment_intent_id TEXT? Trade-off flexibility vs performance.- URL
/webhooks/stripekhông dưới/api/v1— lý do tại sao? Third-party convention vs versioning REST resource.
Đáp án
- PaymentIntent vs Charge API + lý do trả client_secret: Charge API (legacy 2011) — single-shot endpoint
POST /v1/chargesnhận card + amount + currency, charge ngay. Cons: (i) KHÔNG hỗ trợ SCA Strong Customer Authentication PSD2 EU 2019 mandatory cho mọi thanh toán > 30 EUR — Charge fail 80%+ tại EU sau 2019; (ii) KHÔNG retry safe — nếu network drop giữa Stripe → server response, server không biết charge đã succeed hay fail → retry sẽ double-charge user; (iii) KHÔNG track stateful — không có concept "intent đang waiting 3DS", "intent canceled", "intent requires_action"; (iv) client phải gửi card data về server → server PCI scope expand. PaymentIntent (2018) — multi-step stateful object track full lifecycle 5+ status (requires_payment_method, requires_confirmation, requires_action, processing, succeeded, canceled). Pros: (a) SCA built-in — tự handle 3DS challenge qua status requires_action; (b) retry safe qua idempotency key Stripe-native + status track không double-charge; (c) capture method flexible automatic (charge ngay khi confirm) vs manual (authorize hold + capture sau cho preorder / pre-auth hotel); (d) off-session payment — charge saved card mà user không online (subscription, recurring billing). Tại sao Stripe trả client_secret thay vì server-side handle card: PCI compliance scope. PCI DSS (Payment Card Industry Data Security Standard) phân 4 level tùy lượng card processed/năm; Level 1 (> 6M transactions) cần audit bên thứ 3 hàng quý + cost 50k-100k USD/năm + recurring. Nếu server touch raw card data dù chỉ pass-through thì scope toàn server vào PCI audit (encrypt at rest, network segmentation, log retention 1 năm, ...). Solution Stripe: client_secret là "permission token" cho client trực tiếp gửi card data lên Stripe JS SDK → Stripe verify + return tokenized payment_method ID → confirm qua client_secret. Server KHÔNG touch card → PCI scope chỉ SAQ-A (Self-Assessment Questionnaire A) đơn giản nhất ~12 yes/no question. Shop API tận dụng PCI scope reduce qua pattern này. Lock decision Shop API: chỉ dùng PaymentIntent (không Charge legacy) + client_secret pattern (không server-side card handling) + tận dụng Stripe Elements UI components để PCI SAQ-A scope. Generalize: pattern "trả permission token cho client, không proxy data nhạy cảm qua server" áp dụng cho mọi integration cần PCI/HIPAA/GDPR scope reduce (S3 presigned URL upload trực tiếp browser → S3, Cloudinary signature client-side upload, Vault dynamic token). - Webhook signature verify HMAC-SHA256 + replay attack window 5 phút: HMAC-SHA256 signature — Stripe compute
HMAC-SHA256(secret, "<timestamp>.<raw_body>")hex encode → gửi headerStripe-Signature: t=<ts>,v1=<hmac_hex>. Server verify bằng cách recompute HMAC + constant-time compare. Đảm bảo 2 thuộc tính: (a) integrity body không bị tamper (1 byte thay đổi → HMAC khác hoàn toàn); (b) authenticity chỉ ai có secret mới sinh được signature hợp lệ (attacker không có secret = không fake được). Replay attack pattern nếu KHÔNG check timestamp: Scenario: attacker MITM 1 webhook hợp lệpayment_intent.succeeded order_id=42 amount=1Mvới signature valid; capture + lưu lại. Sau đó attacker gửi lại y nguyên request này lên Shop API → signature vẫn verify pass (signature không expire) → server xử lý lần 2: thấy event_id mới (nếu attacker rotate event_id, hoặc dedup không có) → mark order_id=42 paid lần 2 → user vận chuyển hàng 2 lần! Tổng quát hơn: attacker dùng replay để trigger lại action gây side-effect (refund 2 lần, cấp permission lại sau khi revoke, ...). Solution timestamp tolerance 5 phút: Stripe include timestamp trong signature payload"<ts>.<body>"→ server checkabs(now - ts) < 300 giâytrước khi compute HMAC. Sau 5 phút webhook bị reject dù signature valid. Tại sao 5 phút: balance giữa (i) network latency — webhook qua internet có thể delay 1-3 phút khi Stripe queue retry; (ii) clock skew — server clock có thể lệch vài giây (NTP sync), 5 phút buffer đủ; (iii) security — window càng nhỏ càng tốt nhưng < 1 phút sẽ false-reject nhiều legitimate webhook. Stripe docs lock 300 giây — Shop API B71 dùng default. Constant-time compare MANDATORY (lock B70 continued) — không dùng==string compare leak length match qua thời gian response. Crate stripe-rustWebhook::construct_eventimplement chuẩn cả 2 (timestamp tolerance + constant-time). Real-world incident: 2020 Slack webhook integration của 1 SaaS leak qua proxy log không check timestamp → attacker replay sau 1 tuần → cấp Slack workspace admin role cho user thường. Fix: enforce timestamp tolerance 5 phút + log signature mismatch alert. Generalize: pattern HMAC + timestamp + constant-time là chuẩn industry cho mọi webhook (GitHub, Twilio, Shopify, Slack đều dùng) — KHÔNG bao giờ skip 1 trong 3. - Idempotent webhook dedup + Stripe retry policy + scenario network blip: Stripe retry policy — nếu webhook endpoint không trả 2xx trong 10 giây (timeout) hoặc trả 5xx → Stripe retry exponential backoff trong 3 days với schedule: 5 phút → 10 phút → 30 phút → 1 giờ → 6 giờ → 12 giờ → 24 giờ → 24 giờ → 24 giờ. Sau 3 ngày KHÔNG nhận 2xx → Stripe mark event "failed permanently" + alert dashboard cho dev fix. Pattern dedup MANDATORY: cache
event.idtrong DB table với PRIMARY KEY constraint → race-safe ngầm UNIQUE → lần 2 thấy có rồi → skip + return Ok(()). Scenario network blip: Case 1 — Shop API trả 200 nhưng Stripe không nhận: (T0) Stripe gửi webhook A; (T0+50ms) Shop API process event, update DB; (T0+100ms) Shop API trả 200 OK; (T0+150ms) network drop giữa Shop API → Stripe; (T0+5 phút) Stripe không nhận response → coi như fail → retry → Shop API nhận webhook A LẦN 2; nếu KHÔNG dedup → process lại event → orders.status thay đổi 2 lần, payments.status 2 lần update (có thể vi phạm state machine), audit log duplicate, side-effect duplicate (gửi email 2 lần "Đơn hàng đã được thanh toán" — user khó chịu); với dedup → check stripe_webhook_events.event_id → đã có → skip + log "already processed" + return Ok → Stripe nhận 200 lần này → ngừng retry. Case 2 — Shop API timeout 10 giây: handler có DB query chậm hoặc external API call (vd gọi inventory service) → 12 giây mới xong; Stripe coi như fail (Stripe lock timeout 10 giây) → retry; nếu KHÔNG dedup → lần 2 process lại từ đầu → có thể conflict (UPDATE orders WHERE status = 'pending' đã không match nữa vì lần 1 đã set 'paid'); với dedup → check event.id → skip → giữ consistency. Case 3 — Shop API restart giữa lúc process: handler đang chạy step 2 trong transaction → server crash; transaction rollback; nhưng response chưa kịp trả; Stripe retry; lần 2 process từ đầu — với dedup check event.id → chưa có (rollback đã xóa) → process lại bình thường (đúng behavior). Trade-off dedup window: B71 chọn lưu vĩnh viễn trong bảng audit trail (cron G18 cleanup > 90 days nếu cần). Alternative dùng Redis SET key TTL 7 days nếu volume cao, nhưng lose audit trail. Generalize: pattern dedup external event qua unique ID PRIMARY KEY áp dụng cho mọi webhook Shop API tương lai (GitHub webhook G15 dedup qua delivery_id, Twilio webhook G18 dedup qua MessageSid, Mailgun webhook G16 dedup qua event-id). Rule MANDATORY: mọi webhook handler PHẢI idempotent. payment_payload @>JSONB query vs column riêng — trade-off flexibility vs performance: Option A — column riêngpayment_intent_id TEXT UNIQUE: schema rigid, mỗi field metadata Stripe (charge_id, customer_id, payment_method_id, last_payment_error.code, ...) đều cần ALTER TABLE ADD COLUMN riêng. Pros: (i) queryWHERE payment_intent_id = $1O(log n) với B-tree index — nhanh nhất có thể; (ii) type safety — không thể query sai key, không thể store value sai type; (iii) constraint enforce — UNIQUE / NOT NULL / CHECK validation tại DB layer; (iv) simple migration tools không cần tool đặc biệt cho JSONB. Cons: (i) migration churn — mỗi lần Stripe thêm field metadata (vdpayment_method.card.network) phải migrate ALTER TABLE downtime; (ii) schema rigid — không support metadata custom user-defined hoặc Stripe-defined dynamic; (iii) column bloat — 50+ field Stripe metadata → table 50+ column, scan toàn row chậm khi chỉ cần 5 field; (iv) NULL waste — payment_type = 'cod' không có payment_intent_id, payment_type = 'bank_transfer' không có customer_id → nhiều NULL trong DB. Option B — JSONB column +@>query (B71 lock): schema flexible, payload struct riêng từng payment_type (Stripe: payment_intent_id + customer_id + amount + currency; BankTransfer: bank_code + account_number + account_holder; Cod: phone + address). Pros: (i) schema flex — Stripe thêm field mới chỉ cần update Rust struct serialize, không cần migration ALTER; (ii) 1 cột phục vụ N payment_type — không có NULL waste; (iii) query containment@>match nhanh O(log n) với GIN indexjsonb_path_ops; (iv) nested query dễ dàngpayment_payload->'card'->'last4'không cần JOIN; (v) app-driven schema evolution — Rust code thay đổi schema không qua DBA. Cons: (i) query plan kém transparent hơn — EXPLAIN ANALYZE phức tạp hơn B-tree đơn; (ii) no enforce — payment_payload có thể chứa garbage nếu code bug, không có CHECK constraint validate JSON schema (Postgres 16+ có pg_jsonschema extension nhưng phức tạp); (iii) storage overhead — JSONB binary nhưng vẫn lớn hơn 5-10% so với column thuần (header bytes). Performance benchmark: với GINjsonb_path_opsindex, queryWHERE payload @> '{"payment_intent_id": "X"}'~ 1-2ms cho 1M row tương đương B-tree text column ~ 0.5-1ms. Sự khác biệt < 1ms negligible cho mọi use case webhook (volume thấp, 10-100 event/giây peak). Lock decision Shop API B71: JSONB@>query vì (i) Stripe metadata schema thay đổi mạnh giữa các version API + thêm field thường xuyên; (ii) Shop API support 3 payment_type (Stripe, BankTransfer, Cod) khác schema hoàn toàn; (iii) volume webhook không cần subms latency. Trade-off: chấp nhận 1-2ms slow hơn cho flex schema + 1 cột phục vụ N type. Generalize: pattern JSONB cho payload polymorphic áp dụng cho mọi entity Shop API có discriminator + nested data per-type — payments B43/B71, audit_logs.changes B62 (diff old/new vary per table), products.metadata B60 (warranty_months + color + ...). Rule: nếu schema thay đổi thường xuyên + có discriminator + query qua containment thì dùng JSONB + @> + GIN index; nếu schema cố định + query exact match field cụ thể thì dùng column thuần + B-tree.- URL
/webhooks/stripekhông dưới/api/v1— third-party convention vs REST versioning: Convention/api/v1cho REST endpoint client-facing — versioning để (i) đảm bảo backward compat khi bump/api/v2không break client cũ; (ii) parallel deployment 2 version cùng lúc trong grace period migration; (iii) deprecation path rõ ràng (announce v1 EOL → 6 tháng grace → tắt). Phù hợp khi: client là code do dev nội bộ kiểm soát hoặc public API document với schema rõ ràng + breaking change cần coordinate. Tại sao webhook KHÔNG cần version: webhook là protocol contract bilateral giữa Shop API + Stripe — Stripe gửi event đến URL cố định mà Shop API publish lên Stripe dashboard; URL này là identity của webhook endpoint. Nếu Shop API đổi URL/api/v1/webhooks/stripe → /api/v2/webhooks/stripethì phải vào Stripe dashboard update lại config webhook — 2 phía coordinate manual mỗi lần bump. Trong khi REST endpoint client-facing có thể bump qua headerAccept: application/vnd.api+json;version=2không thay URL. Convention industry: Stripe docs ví dụhttps://example.com/webhooks/stripe; GitHubhttps://example.com/webhook; Twiliohttps://example.com/sms; Mailgunhttps://example.com/mailgun/events— không version. Lý do thực dụng khác: (i) schema webhook stable hơn REST API — Stripe API có v2026-01-15 release schedule 6 tháng/lần với breaking change, nhưng webhook event schema cực kỳ stable (vdpayment_intent.succeededtồn tại từ 2018 không đổi field); webhook chỉ thêm field optional không bao giờ remove; (ii) versioning webhook qua Stripe dashboard config — Stripe dashboard cho phép chọn API version per-webhook, Stripe sẽ format event theo version đó gửi về Shop API → version contract nằm bên Stripe, không phải bên Shop API URL; (iii) cross-cutting concern — webhook signature verify, dedup, idempotent là pattern chung cho mọi third-party (Stripe + GitHub + Twilio + ...) không thuộc về domain "v1 API"; tách top-level/webhooks/{provider}để middleware chung (vd raw body extractor) apply per-provider. Pattern URL Shop API B71:POST /webhooks/stripe, futurePOST /webhooks/github,POST /webhooks/twilio,POST /webhooks/mailgun. Lock decision: webhook URL flat top-level/webhooks/{provider}, KHÔNG version, KHÔNG nest dưới/api/vN. Pattern reuse cho mọi third-party webhook tương lai. Generalize: URL convention 3 nhóm tách rõ ràng — (a) REST API client-facing/api/vN/{resource}versioned; (b) Webhook third-party/webhooks/{provider}unversioned; (c) System endpoint/health,/metrics,/healthz,/readyzunversioned (lock B12 + B57 + Project Spec G8). 3 nhóm có lifecycle khác nhau + audience khác nhau + cần middleware stack khác nhau (REST API có auth + rate-limit, webhook chỉ signature verify, system endpoint không auth). Rule lock vĩnh viễn cho Shop API.
Bài Tiếp Theo
Bài 72: Service Layer Abstraction — Trait Pattern — refactor business logic từ handler → service trait, dependency injection qua AppState, mock service cho test, áp Shop API ProductService + OrderService + PaymentService trait pattern.
