Files
raisfast/docs/payment-design.md
T
2026-05-13 10:38:14 +08:00

28 KiB

Payment Integration Design

Core Principle

Payment and Wallet are independent modules. The only relationship:

Provider webhook → payment_orders.status = paid
                 → INSERT wallet_transactions (credit)

Both operations happen in one DB transaction. That's it.

Payment does not know wallet internals. Wallet does not know which provider the money came from. They communicate through wallet_transactions.reference_type = "payment" + reference_id = payment_order.document_id.

Architecture

┌────────────┐     ┌──────────────┐     ┌─────────────────┐     ┌───────────┐
│  Handler    │────>│   Service    │────>│ PaymentProvider  │────>│  Gateway  │
│ (thin API)  │     │ (business)   │     │    (trait)       │     │ (Stripe   │
└────────────┘     └──────┬───────┘     └─────────────────┘     │  PayPal   │
                          │                                     │  WxPay    │
                    ┌─────▼──────┐                               │  Alipay)  │
                    │  Model     │                               └───────────┘
                    │ (sqlx DB)  │
                    └────────────┘

Database Schema

payment_channels

CREATE TABLE IF NOT EXISTS payment_channels (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    document_id TEXT NOT NULL UNIQUE,
    provider TEXT NOT NULL,                    -- stripe/paypal/wxpay/alipay
    name TEXT NOT NULL,                        -- "Stripe USD"
    is_live INTEGER NOT NULL DEFAULT 0,
    credentials TEXT NOT NULL,                 -- AES-256-GCM encrypted JSON
    webhook_secret TEXT,                       -- AES-256-GCM encrypted
    settings TEXT,                             -- JSON: { currencies:["USD","CNY"], methods:["card","alipay_qr"], min_amount:100, max_amount:10000000 }
    is_active INTEGER NOT NULL DEFAULT 1,
    sort_order INTEGER NOT NULL DEFAULT 0,
    version INTEGER NOT NULL DEFAULT 1,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    UNIQUE(provider, name)
);

payment_orders

CREATE TABLE IF NOT EXISTS payment_orders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    document_id TEXT NOT NULL UNIQUE,

    user_id INTEGER NOT NULL REFERENCES users(id),
    order_id TEXT,                             -- references orders.document_id (loose coupling)
    title TEXT NOT NULL,

    amount INTEGER NOT NULL CHECK(amount > 0), -- smallest unit (cents)
    currency TEXT NOT NULL DEFAULT 'USD',

    channel_id INTEGER NOT NULL REFERENCES payment_channels(id),
    provider TEXT NOT NULL,
    provider_order_id TEXT,                    -- Stripe: pi_xxx, Alipay: trade_no
    provider_method TEXT,                      -- card/alipay_qr/wechat_jsapi/paypal

    status TEXT NOT NULL DEFAULT 'pending',

    reference_type TEXT,                       -- order/subscription/wallet_topup
    reference_id TEXT,                         -- business entity ID
    return_url TEXT,

    idempotency_key TEXT NOT NULL UNIQUE,

    version INTEGER NOT NULL DEFAULT 1,        -- optimistic lock for concurrent refund

    provider_data TEXT,                        -- JSON: last provider response
    client_ip TEXT,
    metadata TEXT,

    paid_at TEXT,
    cancelled_at TEXT,
    expired_at TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_payment_orders_user ON payment_orders(user_id);
CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON payment_orders(status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_provider ON payment_orders(provider_order_id);
CREATE INDEX IF NOT EXISTS idx_payment_orders_order_id ON payment_orders(order_id);

payment_transactions (immutable ledger, append-only)

Records every external provider event. Separate from wallet_transactions — this is the provider-side ledger.

CREATE TABLE IF NOT EXISTS payment_transactions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    document_id TEXT NOT NULL UNIQUE,

    payment_order_id INTEGER NOT NULL REFERENCES payment_orders(id),
    order_id TEXT,                             -- denormalized from payment_orders.order_id for direct query
    user_id INTEGER NOT NULL REFERENCES users(id),

    tx_type TEXT NOT NULL,                     -- charge / refund
    amount INTEGER NOT NULL CHECK(amount > 0),
    currency TEXT NOT NULL,

    provider_tx_id TEXT NOT NULL UNIQUE,       -- Stripe: ch_xxx / re_xxx, prevents duplicate webhook inserts
    status TEXT NOT NULL DEFAULT 'pending',    -- pending/succeeded/failed

    raw_payload TEXT,                          -- JSON: original webhook body

    created_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_payment_tx_order ON payment_transactions(payment_order_id);
CREATE INDEX IF NOT EXISTS idx_payment_tx_order_id ON payment_transactions(order_id);

payment_refunds

CREATE TABLE IF NOT EXISTS payment_refunds (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    document_id TEXT NOT NULL UNIQUE,

    payment_order_id INTEGER NOT NULL REFERENCES payment_orders(id),
    order_id TEXT,                             -- denormalized from payment_orders.order_id for direct query
    user_id INTEGER NOT NULL REFERENCES users(id),

    amount INTEGER NOT NULL CHECK(amount > 0),
    currency TEXT NOT NULL,
    reason TEXT,                               -- user_request/duplicate/fraud/other

    provider_refund_id TEXT,                   -- Stripe: re_xxx
    status TEXT NOT NULL DEFAULT 'pending',    -- pending/processing/succeeded/failed

    payment_tx_id INTEGER REFERENCES payment_transactions(id),

    metadata TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_payment_refunds_order ON payment_refunds(payment_order_id);
CREATE INDEX IF NOT EXISTS idx_payment_refunds_order_id ON payment_refunds(order_id);

State Machine

                    ┌──────────┐
                    │ pending  │ ← create order
                    └────┬──┬──┘
                         │  │
         user cancel     │  │  provider confirms (webhook)
              ┌──────────┘  │
              ▼             ▼
        ┌──────────┐  ┌──────────┐
        │cancelled │  │   paid   │
        └──────────┘  └────┬─────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
         full refund   partial     expired
              │        refund          │
              ▼            │           ▼
        ┌──────────┐      │     ┌─────────┐
        │ refunded │◄─────┘     │ expired │
        └──────────┘            └─────────┘

Sequence Diagrams

1. Create Payment

Client              Handler             Service              Provider           DB
  │                    │                    │                    │               │
  │ POST /orders       │                    │                    │               │
  │───────────────────>│                    │                    │               │
  │                    │ create_order()     │                    │               │
  │                    │───────────────────>│                    │               │
  │                    │                    │                    │  INSERT order │
  │                    │                    │                    │  status=pending
  │                    │                    │                    │<──────────────│
  │                    │                    │  create()          │               │
  │                    │                    │──────────────────>│               │
  │                    │                    │  ProviderResponse  │               │
  │                    │                    │<─────────────────│               │
  │                    │                    │                    │  UPDATE       │
  │                    │                    │                    │  provider_id  │
  │                    │                    │                    │──────────────>│
  │  {redirect_url,   │                    │                    │               │
  │   qr_code,        │                    │                    │               │
  │   client_secret}  │                    │                    │               │
  │<──────────────────│<───────────────────│                    │               │
  │                    │                    │                    │               │

2. Webhook Callback (Provider → RaisFast)

Provider          Handler             Service              Wallet           DB
  │                  │                    │                    │             │
  │ POST /callback   │                    │                    │             │
  │─────────────────>│                    │                    │             │
  │                  │ handle_callback()  │                    │             │
  │                  │───────────────────>│                    │             │
  │                  │                    │                    │             │
  │                  │                    │ ┌─ verify signature ──────────┐  │
  │                  │                    │ │ invalid → 400              │  │
  │                  │                    │ └────────────────────────────┘  │
  │                  │                    │                    │             │
  │                  │                    │ ┌─ find order ────────────────┐  │
  │                  │                    │ │ not found → 400            │  │
  │                  │                    │ └────────────────────────────┘  │
  │                  │                    │                    │             │
  │                  │                    │ ┌─ idempotency check ────────┐  │
  │                  │                    │ │ already paid → 200 OK      │  │
  │                  │                    │ └────────────────────────────┘  │
  │                  │                    │                    │             │
  │                  │                    │ ┌─ amount check ─────────────┐  │
  │                  │                    │ │ mismatch → 400 + alert     │  │
  │                  │                    │ └────────────────────────────┘  │
  │                  │                    │                    │             │
  │                  │                    │ ── atomic transaction ──────────>│
  │                  │                    │                    │             │
  │                  │                    │ 1. payment_orders  │             │
  │                  │                    │    status = paid   │             │
  │                  │                    │                    │             │
  │                  │                    │ 2. payment_transactions INSERT  │
  │                  │                    │    tx_type=charge  │             │
  │                  │                    │                    │             │
  │                  │                    │ 3. wallet credit   │             │
  │                  │                    │───────────────────>│             │
  │                  │                    │    wallet_tx INSERT│             │
  │                  │                    │<───────────────────│             │
  │                  │                    │                    │             │
  │                  │                    │ ── commit ──────────────────────>│
  │                  │                    │                    │             │
  │  200 OK          │                    │                    │             │
  │<─────────────────│<───────────────────│                    │             │

3. Refund

Admin             Handler             Service              Provider          DB
  │                  │                    │                    │             │
  │ POST /refund     │                    │                    │             │
  │─────────────────>│                    │                    │             │
  │                  │ refund_order()     │                    │             │
  │                  │───────────────────>│                    │             │
  │                  │                    │                    │             │
  │                  │                    │ ┌─ validate ──────────────────┐  │
  │                  │                    │ │ order not paid → 400       │  │
  │                  │                    │ │ refund > paid → 400        │  │
  │                  │                    │ │ version mismatch → 409     │  │
  │                  │                    │ └────────────────────────────┘  │
  │                  │                    │                    │             │
  │                  │                    │ refund()           │             │
  │                  │                    │───────────────────>│             │
  │                  │                    │ RefundResponse     │             │
  │                  │                    │<───────────────────│             │
  │                  │                    │                    │             │
  │                  │                    │ ── atomic transaction ──────────>│
  │                  │                    │                    │             │
  │                  │                    │ 1. payment_orders  │             │
  │                  │                    │    update status   │             │
  │                  │                    │                    │             │
  │                  │                    │ 2. payment_transactions INSERT  │
  │                  │                    │    tx_type=refund  │             │
  │                  │                    │                    │             │
  │                  │                    │ 3. payment_refunds INSERT       │
  │                  │                    │                    │             │
  │                  │                    │ 4. wallet debit    │             │
  │                  │                    │───────────────────>│             │
  │                  │                    │    wallet_tx INSERT│             │
  │                  │                    │<───────────────────│             │
  │                  │                    │                    │             │
  │                  │                    │ ── commit ──────────────────────>│
  │  {refund_id}     │                    │                    │             │
  │<─────────────────│<───────────────────│                    │             │

Connection Point: Payment ↔ Wallet

Payment callback handler does exactly two things that touch wallet:

On paid (credit):

crate::in_transaction!(pool, tx, {
    // 1. payment: update order + record transaction
    tx_update_order_paid(&mut tx, order.id, &callback.provider_tx_id).await?;
    tx_insert_payment_tx(&mut tx, order.id, "charge", order.amount, &callback).await?;

    // 2. wallet: insert credit record
    crate::services::wallet::credit_wallet(
        wallet_repo, &pool,
        order.user_id, &order.currency, order.amount,
        WalletTxType::Recharge,
        &format!("PAY-{}", order.document_id),
        Some(WalletReferenceType::Payment),
        Some(&order.document_id),
        None,
    ).await?;
});

On refund (debit):

crate::in_transaction!(pool, tx, {
    // 1. payment: update order + record refund (with version check)
    let order = tx_lock_order_for_update(&mut tx, order.id, order.version).await?; // optimistic lock
    if order.refund_amount + refund_amount > order.amount {
        return Err(AppError::BadRequest("refund exceeds payment amount"));
    }
    tx_insert_payment_refund(&mut tx, order.id, refund_amount, &result).await?;
    tx_insert_payment_tx(&mut tx, order.id, "refund", refund_amount, &result).await?;
    tx_update_order_status(&mut tx, order.id, is_full ? "refunded" : order.status).await?;

    // 2. wallet: insert debit record
    crate::services::wallet::debit_wallet(
        wallet_repo, &pool,
        order.user_id, &order.currency, refund_amount,
        WalletTxType::Refund,
        &format!("REFUND-{}", order.document_id),
        Some(WalletReferenceType::PaymentRefund),
        Some(&order.document_id),
        None,
    ).await?;
});

Wallet sees a credit/debit like any other. It doesn't care about payment providers. Payment doesn't touch wallet balance directly — it goes through wallet::credit_wallet / wallet::debit_wallet which handle optimistic locking, currency validation, frozen checks, and idempotency.

Enum Definitions

New enums (payment module)

define_enum!(
    PaymentProviderName {
        Stripe = "stripe",
        Paypal = "paypal",
        Wxpay = "wxpay",
        Alipay = "alipay",
    }
);

define_enum!(
    PaymentStatus {
        Pending = "pending",
        Paid = "paid",
        Failed = "failed",
        Cancelled = "cancelled",
        Refunded = "refunded",
        Expired = "expired",
    }
);

define_enum!(
    PaymentTxType {
        Charge = "charge",
        Refund = "refund",
    }
);

define_enum!(
    PaymentRefundStatus {
        Pending = "pending",
        Processing = "processing",
        Succeeded = "succeeded",
        Failed = "failed",
    }
);

define_enum!(
    PaymentMethod {
        Card = "card",
        AlipayQr = "alipay_qr",
        AlipayWap = "alipay_wap",
        AlipayPage = "alipay_page",
        WechatJsapi = "wechat_jsapi",
        WechatNative = "wechat_native",
        WechatH5 = "wechat_h5",
        Paypal = "paypal",
    }
);

Existing enum additions

// Add to WalletReferenceType:
Payment = "payment",
PaymentRefund = "payment_refund",

Provider Trait

#[async_trait]
pub trait PaymentProvider: Send + Sync {
    fn name(&self) -> &str;

    async fn create(
        &self,
        channel: &PaymentChannel,
        order: &PaymentOrder,
        return_url: Option<&str>,
    ) -> AppResult<ProviderResponse>;

    async fn query(
        &self,
        channel: &PaymentChannel,
        provider_order_id: &str,
    ) -> AppResult<ProviderStatus>;

    async fn cancel(
        &self,
        channel: &PaymentChannel,
        provider_order_id: &str,
    ) -> AppResult<()>;

    async fn refund(
        &self,
        channel: &PaymentChannel,
        provider_order_id: &str,
        amount: i64,
        reason: Option<&str>,
    ) -> AppResult<RefundResponse>;

    async fn verify_callback(
        &self,
        channel: &PaymentChannel,
        headers: &HeaderMap,
        body: &[u8],
    ) -> AppResult<CallbackData>;
}

pub struct ProviderResponse {
    pub provider_order_id: String,
    pub redirect_url: Option<String>,
    pub qr_code: Option<String>,
    pub client_secret: Option<String>,
}

pub struct ProviderStatus {
    pub status: PaymentStatus,
    pub provider_tx_id: Option<String>,
    pub paid_at: Option<String>,
}

pub struct RefundResponse {
    pub provider_refund_id: String,
}

pub struct CallbackData {
    pub provider_order_id: String,
    pub status: PaymentStatus,
    pub amount: i64,
    pub provider_tx_id: Option<String>,
    pub paid_at: Option<String>,
}

File Structure

src/payment/
  mod.rs              -- pub mod + factory
  provider.rs         -- trait + shared structs
  crypto.rs           -- AES-256-GCM for credentials
  model.rs            -- DB queries (all 4 tables)
  service.rs          -- create_order / handle_callback / refund_order
  handler.rs          -- HTTP handlers
  dto.rs              -- request/response types

  providers/
    mod.rs            -- #[cfg(feature)] re-exports
    stripe.rs         -- impl PaymentProvider
    paypal.rs         -- impl PaymentProvider
    wxpay.rs          -- impl PaymentProvider
    alipay.rs         -- impl PaymentProvider

Feature Gates

payment-stripe  = ["async-stripe"]
payment-paypal  = []
payment-wxpay   = []
payment-alipay  = []
payment-all     = ["payment-stripe", "payment-paypal", "payment-wxpay", "payment-alipay"]

API

# Public (authenticated)
POST   /api/v1/payment/orders                        # Create → redirect_url / qr_code
GET    /api/v1/payment/orders/:id                    # Query
POST   /api/v1/payment/orders/:id/cancel             # Cancel pending
GET    /api/v1/payment/orders/:id/transactions       # List provider events
GET    /api/v1/payment/orders/:id/refunds            # List refunds

# Webhook (no auth, provider signature verified)
POST   /api/v1/payment/callback/:channel_doc_id      # Unified callback

# Admin
GET/POST/PUT/DELETE  /api/v1/admin/payment/channels[/:id]
GET                  /api/v1/admin/payment/orders[/:id]    # List/filter + detail
POST                 /api/v1/admin/payment/orders/:id/refund
GET                  /api/v1/admin/payment/transactions     # All provider events
GET                  /api/v1/admin/payment/refunds          # All refunds

Credential Formats (AES-256-GCM encrypted in DB)

// Stripe
{ "secret_key": "sk_live_...", "publishable_key": "pk_live_..." }

// PayPal
{ "client_id": "...", "client_secret": "...", "sandbox": false }

// WxPay
{ "app_id": "...", "mch_id": "...", "api_key": "...",
  "cert_pem": "base64...", "cert_key": "base64..." }

// Alipay
{ "app_id": "...", "private_key": "...", "alipay_public_key": "...",
  "is_sandbox": false }

Channel Validation

payment_channels.settings stores constraints enforced at service layer:

{
  "currencies": ["USD", "EUR"],
  "methods": ["card", "alipay_qr"],
  "min_amount": 100,
  "max_amount": 10000000
}
// service.rs — validate before creating order
fn validate_channel(channel: &PaymentChannel, currency: &str, method: Option<&str>, amount: i64) -> AppResult<()> {
    let settings: ChannelSettings = parse_settings(&channel.settings)?;
    if !settings.currencies.contains(&currency.to_uppercase()) {
        return Err(AppError::BadRequest("currency not supported by this channel"));
    }
    if let Some(m) = method {
        if !settings.methods.contains(&m.to_string()) {
            return Err(AppError::BadRequest("payment method not supported by this channel"));
        }
    }
    if let Some(min) = settings.min_amount {
        if amount < min { return Err(AppError::BadRequest("amount below minimum")); }
    }
    if let Some(max) = settings.max_amount {
        if amount > max { return Err(AppError::BadRequest("amount exceeds maximum")); }
    }
    Ok(())
}

Reconciliation

No dedicated table. A worker runs daily via the existing worker system:

  1. Query all payment_orders with status = paid from the previous day
  2. For each, call provider.query() to get the latest status from the gateway
  3. Compare: status, amount, provider_tx_id
  4. Mismatches written to audit_log with action: "payment_reconcile_mismatch", including both local and provider data
  5. Admin dashboard queries audit_log filtered by action to display discrepancies

This is intentionally lightweight — payment_transactions.raw_payload already stores every provider event, so the worker only needs to detect drift, not rebuild state.

Security Checklist

  • Credentials AES-256-GCM encrypted at rest (env key PAYMENT_ENCRYPT_KEY)
  • Webhook signature verification per-provider (HMAC-SHA256 for Stripe/WxPay, RSA2 for Alipay)
  • Idempotency key on payment_orders (double-pay prevention)
  • Amount verification on callback (provider amount must match order amount)
  • Optimistic locking on wallet balance (existing)
  • Optimistic locking on payment_orders.version for concurrent refund protection
  • Atomic order + payment_tx + wallet_tx updates in single DB transaction
  • Raw provider payloads stored immutably in payment_transactions.raw_payload
  • payment_transactions.provider_tx_id UNIQUE prevents duplicate webhook processing
  • Rate limiting on callback endpoint (existing middleware)
  • Partial refund tracking (payment_refunds accumulates, cannot exceed order amount)

Milestones

Phase Scope Days
P0 Schema + trait + model + service + handler 2
P1 Stripe (async-stripe) 2
P2 Alipay (RSA2 + QR/Page/WAP) 4
P3 WxPay (HMAC-SHA256 + JSAPI/Native/H5) 4
P4 PayPal (OAuth2 + Orders API) 3
P5 Admin UI + SDK 3

Total: ~18 days. Stripe usable after P0+P1 (4 days).

Future: Plugin-based Payment Providers

Currently all providers are Rust built-in implementations. Future channels (GrabPay, MercadoPago, crypto, etc.) can be added via plugins with minimal core changes:

What plugins need from the sandbox (3 new Host APIs):

  • Host.cryptoHmac(algo, key, data) — HMAC-SHA256/SHA512
  • Host.cryptoRsaSign(algo, key, data) / Host.cryptoRsaVerify(...) — RSA2
  • Host.getSecret(key) / Host.setSecret(key, value) — encrypted KV storage

Architecture (callback goes through core for atomicity):

Plugin: verify_callback() → CallbackData (signature verify + parse only)
Core:   atomic_confirm_payment() → update order + wallet_tx in one transaction

Plugins only handle crypto + data parsing. The atomic transaction (order status + wallet credit) is always handled by core to guarantee consistency. No plugin can bypass this.