Danh sách bài viết

Bài 71: Payment Stripe Integration — PaymentIntent + Webhook

Bài 71 của series Rust RESTful API — bài CODE thực tế LỚN nối tiếp B70 (4 endpoint users register + profile + verify-email với Argon2id password hash OWASP 2024 + migration 13 email_verification_tokens + Entity/DTO security pattern B45 lock continued): tích hợp Stripe PaymentIntent flow đầy đủ vào Shop API. Thêm 2 endpoint mới POST /api/v1/payments/orders/{order_id}/intent (server gọi Stripe API tạo PaymentIntent + return client_secret cho frontend Stripe Elements confirm 3DS nếu cần) + POST /webhooks/stripe đặt ngoài /api/v1 theo third-party convention (verify HMAC-SHA256 signature qua Webhook::construct_event helper + parse Event + dispatch handler theo type); lock decision Shop API Stripe PaymentIntent (không Charge API legacy) vì PaymentIntent built-in SCA compliance + 3DS challenge tự động + retry safe; cài stripe = "0.36" community crate với feature runtime-tokio-hyper-rustls (skip native-tls); env config thêm STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET qua AppConfig MANDATORY (KHÔNG hard-code, lock B70 security continued); pattern idempotent webhook handler: dedup theo event.id trong bảng mới stripe_webhook_events 7 column (event_id PRIMARY KEY + event_type + received_at + processed_at + status enum 3 value received/processed/failed + error_message + raw_payload JSONB) + 2 index audit trail; 2 event handler ban đầu — PaymentIntentSucceeded update payments.status = success + orders.status = paid, PaymentIntentPaymentFailed update payments.status = failed; extend shop-db::payments module thêm 3 function create_stripe_payment ON CONFLICT (order_id) DO UPDATE UPSERT pattern lock B54 (1 order = 1 payment) + update_status_by_intent dùng payment_payload @> $1::jsonb JSONB query lock B60 continued (jsonb_path_ops index match nhanh) + find_by_intent_id reuse pattern; webhook signature verify pattern (lock B37 continued): parse Stripe-Signature header format t=<timestamp>,v1=<hmac_hex> + reject nếu timestamp > 5 phút (replay attack window) + compute HMAC-SHA256(secret, <timestamp>.<body>) + constant-time compare với v1; trả 400 BadRequest cho signature fail (KHÔNG 401 — avoid leak info); Stripe retry policy 3 days exponential backoff nếu KHÔNG nhận 2xx → handler MANDATORY idempotent (dedup PRIMARY KEY UNIQUE auto); metadata order_id đính kèm PaymentIntent giúp trace order khi webhook fire (Stripe gửi metadata về trong event payload); currency lock VND Shop API + amount integer (VND KHÔNG có sub-unit, Stripe yêu cầu smallest currency unit); URL /webhooks/stripe đặt NGOÀI /api/v1 theo third-party convention (Stripe gọi trực tiếp không version); test end-to-end qua Stripe CLI stripe listen --forward-to localhost:3000/webhooks/stripe + stripe trigger payment_intent.succeeded; foundation cho B72 (Service Layer trait abstraction refactor handler → service), B73-B75 (refactor remaining handler), G14 (analytics dashboard payment), G18 (Stripe production setup live key + 3DS SCA challenge full).

16/06/2026
14 phút đọc
1 lượt xem
1

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-rust crate v0.36+ (community Stripe Rust SDK) với feature runtime-tokio-hyper-rustls align tokio + rustls workspace lock.
  • Implement POST /api/v1/payments/orders/{order_id}/intent gọi Stripe API tạo PaymentIntent + return client_secret cho frontend Stripe Elements.
  • Implement POST /webhooks/stripe với signature verify HMAC-SHA256 (lock B37 continued — webhook signature verify pattern).
  • Pattern idempotent webhook handler: dedup qua event.id PRIMARY KEY + bảng audit trail stripe_webhook_events 7 column + 2 index.
  • Update payments.status từ webhook event qua 2 handler PaymentIntentSucceeded + PaymentIntentPaymentFailed; transition orders.status pending → paid khi success.
  • Query payment_payload @> {"payment_intent_id": ...} (lock B60 continued — JSONB @> containment với jsonb_path_ops index match nhanh).
  • Foundation cho B72 (Service Layer trait abstraction), G14 (analytics dashboard payment), G18 (Stripe production setup live key + 3DS SCA challenge full).
2

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 card 4242 4242 4242 4242 miễn phí mọi flow) + live (key sk_live_... charge thật, cần verify business). Shop API dev + CI chỉ dùng test mode; live mode chỉ activate ở G18 production setup.
3

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.

4

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::jsonb lock 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 index jsonb_path_ops match 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 UPDATE UPSERT 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')).
5

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êm user_id sau 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 (vd 50000.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.
6

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_event helper — dùng implementation Stripe-official KHÔNG manual HMAC implement. Đỡ 2 class lỗi: parse header sai format (Stripe có thể thêm v0/v2 scheme 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 extract Json sẽ 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ùng String hoặc Bytes extractor giữ raw bytes nguyên bản (lock B37 continued — raw body extractor MANDATORY cho webhook).
7

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).
8

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
9

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-rustls align tokio + rustls workspace lock B10/B51.
  • Env config: STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET qua AppConfig::from_env fail-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_event helper — 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 cho find_by_intent_id + update_status_by_intent (jsonb_path_ops index O(log n) match).
  • ON CONFLICT (order_id) DO UPDATE UPSERT lock B54 — 1 order = 1 payment, không duplicate row khi user click "Pay" 2 lần.
  • 2 event handler initial: PaymentIntentSucceededorders.status = paid; PaymentIntentPaymentFailedpayment.status = failed; extend G14 thêm refund/dispute event.
  • URL /webhooks/stripe NGOÀ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_events 7 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 + extend crates/shop-db/src/payments.rs (3 function create_stripe_payment + update_status_by_intent + find_by_intent_id) + NEW migration 14 20260616130000_create_stripe_webhook_events.sql + UPDATED config.rs (2 field stripe_secret_key + stripe_webhook_secret) + UPDATED router.rs + routes/mod.rs + .env.example + workspace Cargo.toml.
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. PaymentIntent flow vs Charge API cũ — pros của PaymentIntent cho SCA compliance? Tại sao Stripe trả client_secret thay vì server-side handle thẳng card data?
  2. Webhook signature verify HMAC-SHA256 — pattern attack nếu KHÔNG check timestamp? Giải thích replay attack window 5 phút.
  3. Idempotent webhook dedup event.id — Stripe retry policy 3 days exponential. Cho ví dụ scenario network blip + server timeout.
  4. payment_payload @> JSONB query — pros so với column riêng payment_intent_id TEXT? Trade-off flexibility vs performance.
  5. URL /webhooks/stripe không dưới /api/v1 — lý do tại sao? Third-party convention vs versioning REST resource.
Đáp án
  1. PaymentIntent vs Charge API + lý do trả client_secret: Charge API (legacy 2011) — single-shot endpoint POST /v1/charges nhậ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).
  2. 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 header Stripe-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=1M vớ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 check abs(now - ts) < 300 giây trướ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-rust Webhook::construct_event implement 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.
  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.id trong 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.
  4. payment_payload @> JSONB query vs column riêng — trade-off flexibility vs performance: Option A — column riêng payment_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) query WHERE payment_intent_id = $1 O(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 (vd payment_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 index jsonb_path_ops; (iv) nested query dễ dàng payment_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 GIN jsonb_path_ops index, query WHERE 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.
  5. URL /webhooks/stripe không dưới /api/v1 — third-party convention vs REST versioning: Convention /api/v1 cho REST endpoint client-facing — versioning để (i) đảm bảo backward compat khi bump /api/v2 khô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/stripe thì 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 header Accept: application/vnd.api+json;version=2 không thay URL. Convention industry: Stripe docs ví dụ https://example.com/webhooks/stripe; GitHub https://example.com/webhook; Twilio https://example.com/sms; Mailgun https://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 (vd payment_intent.succeeded tồ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, future POST /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, /readyz unversioned (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.
11

Bài Tiếp Theo

— refactor business logic từ handler → service trait, dependency injection qua AppState, mock service cho test, áp Shop API ProductService + OrderService + PaymentService trait pattern.