mirror of
https://github.com/RaisFast/raisfast.git
synced 2026-09-24 08:02:36 +00:00
638 lines
28 KiB
Markdown
638 lines
28 KiB
Markdown
# 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
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
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.
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
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):**
|
|
|
|
```rust
|
|
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):**
|
|
|
|
```rust
|
|
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)
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
// Add to WalletReferenceType:
|
|
Payment = "payment",
|
|
PaymentRefund = "payment_refund",
|
|
```
|
|
|
|
## Provider Trait
|
|
|
|
```rust
|
|
#[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
|
|
|
|
```toml
|
|
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)
|
|
|
|
```json
|
|
// 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:
|
|
|
|
```json
|
|
{
|
|
"currencies": ["USD", "EUR"],
|
|
"methods": ["card", "alipay_qr"],
|
|
"min_amount": 100,
|
|
"max_amount": 10000000
|
|
}
|
|
```
|
|
|
|
```rust
|
|
// 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(¤cy.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
|
|
|
|
- [x] Credentials AES-256-GCM encrypted at rest (env key `PAYMENT_ENCRYPT_KEY`)
|
|
- [x] Webhook signature verification per-provider (HMAC-SHA256 for Stripe/WxPay, RSA2 for Alipay)
|
|
- [x] Idempotency key on payment_orders (double-pay prevention)
|
|
- [x] Amount verification on callback (provider amount must match order amount)
|
|
- [x] Optimistic locking on wallet balance (existing)
|
|
- [x] Optimistic locking on payment_orders.version for concurrent refund protection
|
|
- [x] Atomic order + payment_tx + wallet_tx updates in single DB transaction
|
|
- [x] Raw provider payloads stored immutably in payment_transactions.raw_payload
|
|
- [x] payment_transactions.provider_tx_id UNIQUE prevents duplicate webhook processing
|
|
- [x] Rate limiting on callback endpoint (existing middleware)
|
|
- [x] 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.
|