mirror of
https://github.com/RaisFast/raisfast.git
synced 2026-09-23 16:02:25 +00:00
6.6 KiB
6.6 KiB
Payment Routing Strategy
Status: Draft
Last updated: 2026-05-14
Background
Currently CreatePaymentOrderRequest requires the frontend to pass channel_id, meaning the frontend must decide which payment channel to use. This design adds automatic channel routing based on client context (country / language / IP), while keeping manual selection as an option.
Goals
- Plan B — Backend recommends channels via a new endpoint, frontend presents choices to the user
- Plan A — When
channel_idis not provided, backend auto-selects the best channel - Record client context (IP, language, country, user agent) on each
PaymentOrderfor analytics - Zero new tables — use existing
payment_channels.settingsJSON for routing metadata
Channel Settings Convention
Each PaymentChannel.settings JSON may include routing fields:
{
"product_id": "prod_xxx",
"countries": ["CN", "HK"],
"currencies": ["CNY", "HKD"],
"languages": ["zh", "zh-CN"],
"priority": 100
}
| Field | Type | Description |
|---|---|---|
product_id |
string | Provider-specific product ID (already used by Creem, Dodo) |
countries |
string[] | ISO 3166-1 alpha-2 codes. ["*"] = global fallback |
currencies |
string[] | ISO 4217 currency codes this channel supports |
languages |
string[] | BCP 47 language tags (prefix-matched, e.g. "zh" matches "zh-CN") |
priority |
integer | Higher = preferred. Default 0 if omitted |
Example configurations:
// Alipay — China focused
{
"countries": ["CN"],
"currencies": ["CNY"],
"languages": ["zh"],
"priority": 100
}
// Stripe — Global
{
"countries": ["US", "GB", "DE", "FR", "JP", "AU"],
"currencies": ["USD", "EUR", "GBP", "JPY"],
"languages": ["en", "de", "fr", "ja"],
"priority": 50
}
// Dodo Payments — Global fallback
{
"product_id": "prod_yyy",
"countries": ["*"],
"currencies": ["USD", "EUR", "GBP"],
"priority": 1
}
Schema Changes
payment_orders — 4 new columns
| Column | SQLite | PostgreSQL | MySQL |
|---|---|---|---|
client_language |
TEXT | TEXT | VARCHAR(50) |
client_country |
TEXT | TEXT | VARCHAR(2) |
client_user_agent |
TEXT | TEXT | VARCHAR(512) |
channel_selected_by |
TEXT | TEXT | VARCHAR(20) |
channel_selected_by values:
"manual"— frontend explicitly chosechannel_id"auto"— backend routing selected the channel"fallback"— routing had no match, used first active channel
New API Endpoints
GET /payment/channels/available
Returns ranked channels matching the client's context.
Query params:
| Param | Required | Description |
|---|---|---|
order_id |
yes | Order to pay for (used to determine currency) |
country |
no | ISO 3166-1 alpha-2. If omitted, inferred from IP or Accept-Language |
language |
no | BCP 47 tag. If omitted, read from Accept-Language header |
Response:
{
"success": true,
"data": {
"recommended_channel_id": "ch_alipay",
"channels": [
{
"channel_id": "ch_alipay",
"provider": "alipay",
"name": "Alipay",
"is_recommended": true,
"sort_order": 0
},
{
"channel_id": "ch_wechat",
"provider": "wechat",
"name": "WeChat Pay",
"is_recommended": false,
"sort_order": 1
}
]
}
}
POST /payment/orders (modified)
channel_id becomes optional.
- If provided → Plan B (manual), uses specified channel, sets
channel_selected_by = "manual" - If not provided → Plan A (auto), backend routes, sets
channel_selected_by = "auto"
Request:
{
"order_id": "ord_xxx",
"channel_id": "ch_alipay",
"country": "CN",
"language": "zh-CN",
"return_url": "https://...",
"metadata": "{}"
}
Response (unchanged, plus new fields):
{
"success": true,
"data": {
"id": "pay_xxx",
"channel_id": "ch_alipay",
"provider": "alipay",
"client_ip": "120.xxx.xxx.xxx",
"client_language": "zh-CN",
"client_country": "CN",
"client_user_agent": "Mozilla/5.0...",
"channel_selected_by": "auto",
"redirect_url": "https://alipay.com/...",
"..."
}
}
Routing Algorithm
Input: channels[], currency, country?, language?
Output: RankedChannel[]
1. Filter is_active = 1
2. Currency hard match (channel must support order's currency)
3. Country match:
- Exact match (countries contains country) → keep priority
- Wildcard (countries contains "*") → priority / 10
- No match → exclude
4. Language bonus:
- Exact match → effective_priority += 50
- Prefix match → effective_priority += 25
- No match → +0
5. Sort by effective_priority DESC, then sort_order ASC
6. Return ordered list
Example — user from China, CNY:
| Channel | countries | priority | language match | effective | Rank |
|---|---|---|---|---|---|
| Alipay | ["CN"] | 100 | zh exact | 150 | 1st |
| ["CN"] | 90 | zh exact | 140 | 2nd | |
| Dodo | ["*"] | 1 | no | 0.1 | 3rd (fallback) |
| Stripe | ["US","GB",...] | 50 | — | excluded | — |
Files to Change
| File | Change |
|---|---|
migrations/sqlite/schema.sqlite.sql |
Add 4 columns to payment_orders |
migrations/postgres/schema.postgres.sql |
Add 4 columns to payment_orders |
migrations/mysql/schema.mysql.sql |
Add 4 columns to payment_orders |
src/payment/routing.rs |
New — routing algorithm |
src/payment/mod.rs |
Add pub mod routing |
src/models/payment_order.rs |
Add 4 fields to struct + impl_from_row + insert |
src/dto/payment.rs |
channel_id optional + new fields + AvailableChannelResponse |
src/services/payment.rs |
New select_channels(), list_available_channels(), modify create_payment_order |
src/handlers/payment.rs |
New handler, modify create handler to extract language/UA, add route |
Files NOT Changed
- Provider implementations (dodo/stripe/alipay/wechat/creem) — no impact
- Workers (retry/expire/reconcile) — read
channel_idas int, unaffected - Webhook callback flow — no routing involved
PaymentChannelRepositorytrait —find_all_activeis sufficient
Future Enhancements
- GeoIP — Add
maxminddbcrate + GeoLite2 database for IP → country inference. Avoids requiring frontend to passcountry. - Amount-based routing — Some channels have min/max amounts. Add
min_amount/max_amountto settings. - Routing analytics — Track conversion rates per channel/country to auto-tune priority.
- A/B testing — Split traffic across channels with similar priority to measure performance.