Implement compression quota refunds and admin manual subscription
This commit is contained in:
738
src/api/billing.rs
Normal file
738
src/api/billing.rs
Normal file
@@ -0,0 +1,738 @@
|
||||
use crate::api::context;
|
||||
use crate::api::envelope::Envelope;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::billing;
|
||||
use crate::services::idempotency;
|
||||
use crate::services::settings;
|
||||
use crate::state::AppState;
|
||||
|
||||
use axum::extract::{ConnectInfo, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/billing/plans", get(list_plans))
|
||||
.route("/billing/subscription", get(get_subscription))
|
||||
.route("/billing/usage", get(get_usage))
|
||||
.route("/billing/invoices", get(list_invoices))
|
||||
.route("/billing/checkout", post(create_checkout))
|
||||
.route("/billing/portal", post(create_portal))
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
struct PlanView {
|
||||
id: Uuid,
|
||||
code: String,
|
||||
name: String,
|
||||
currency: String,
|
||||
amount_cents: i32,
|
||||
interval: String,
|
||||
included_units_per_period: i32,
|
||||
max_file_size_mb: i32,
|
||||
max_files_per_batch: i32,
|
||||
retention_days: i32,
|
||||
features: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PlansResponse {
|
||||
plans: Vec<PlanView>,
|
||||
}
|
||||
|
||||
async fn list_plans(State(state): State<AppState>) -> Result<Json<Envelope<PlansResponse>>, AppError> {
|
||||
let plans = sqlx::query_as::<_, PlanView>(
|
||||
r#"
|
||||
SELECT
|
||||
id, code, name, currency, amount_cents, interval,
|
||||
included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days,
|
||||
features
|
||||
FROM plans
|
||||
WHERE is_active = true
|
||||
ORDER BY amount_cents ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: PlansResponse { plans },
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SubscriptionPlanView {
|
||||
id: Uuid,
|
||||
code: String,
|
||||
name: String,
|
||||
currency: String,
|
||||
amount_cents: i32,
|
||||
interval: String,
|
||||
included_units_per_period: i32,
|
||||
max_file_size_mb: i32,
|
||||
max_files_per_batch: i32,
|
||||
retention_days: i32,
|
||||
features: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SubscriptionView {
|
||||
status: String,
|
||||
current_period_start: DateTime<Utc>,
|
||||
current_period_end: DateTime<Utc>,
|
||||
cancel_at_period_end: bool,
|
||||
plan: SubscriptionPlanView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SubscriptionResponse {
|
||||
subscription: SubscriptionView,
|
||||
}
|
||||
|
||||
async fn get_subscription(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Envelope<SubscriptionResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct SubRow {
|
||||
status: String,
|
||||
current_period_start: DateTime<Utc>,
|
||||
current_period_end: DateTime<Utc>,
|
||||
cancel_at_period_end: bool,
|
||||
plan_id: Uuid,
|
||||
plan_code: String,
|
||||
plan_name: String,
|
||||
currency: String,
|
||||
amount_cents: i32,
|
||||
interval: String,
|
||||
included_units_per_period: i32,
|
||||
max_file_size_mb: i32,
|
||||
max_files_per_batch: i32,
|
||||
retention_days: i32,
|
||||
features: serde_json::Value,
|
||||
}
|
||||
|
||||
let sub = sqlx::query_as::<_, SubRow>(
|
||||
r#"
|
||||
SELECT
|
||||
s.status::text AS status,
|
||||
s.current_period_start,
|
||||
s.current_period_end,
|
||||
s.cancel_at_period_end,
|
||||
p.id AS plan_id,
|
||||
p.code AS plan_code,
|
||||
p.name AS plan_name,
|
||||
p.currency,
|
||||
p.amount_cents,
|
||||
p.interval,
|
||||
p.included_units_per_period,
|
||||
p.max_file_size_mb,
|
||||
p.max_files_per_batch,
|
||||
p.retention_days,
|
||||
p.features
|
||||
FROM subscriptions s
|
||||
JOIN plans p ON p.id = s.plan_id
|
||||
WHERE s.user_id = $1
|
||||
AND s.status IN ('active', 'trialing', 'past_due', 'canceled', 'incomplete')
|
||||
ORDER BY s.current_period_end DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?;
|
||||
|
||||
let (status, period_start, period_end, cancel_at_period_end, plan) = if let Some(sub) = sub {
|
||||
(
|
||||
sub.status,
|
||||
sub.current_period_start,
|
||||
sub.current_period_end,
|
||||
sub.cancel_at_period_end,
|
||||
SubscriptionPlanView {
|
||||
id: sub.plan_id,
|
||||
code: sub.plan_code,
|
||||
name: sub.plan_name,
|
||||
currency: sub.currency,
|
||||
amount_cents: sub.amount_cents,
|
||||
interval: sub.interval,
|
||||
included_units_per_period: sub.included_units_per_period,
|
||||
max_file_size_mb: sub.max_file_size_mb,
|
||||
max_files_per_batch: sub.max_files_per_batch,
|
||||
retention_days: sub.retention_days,
|
||||
features: sub.features,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let plan: PlanView = sqlx::query_as::<_, PlanView>(
|
||||
r#"
|
||||
SELECT
|
||||
id, code, name, currency, amount_cents, interval,
|
||||
included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days,
|
||||
features
|
||||
FROM plans
|
||||
WHERE code = 'free'
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "未找到 Free 套餐").with_source(err))?;
|
||||
|
||||
let (start, end) = billing::current_month_period_utc8(Utc::now());
|
||||
(
|
||||
"free".to_string(),
|
||||
start,
|
||||
end,
|
||||
false,
|
||||
SubscriptionPlanView {
|
||||
id: plan.id,
|
||||
code: plan.code,
|
||||
name: plan.name,
|
||||
currency: plan.currency,
|
||||
amount_cents: plan.amount_cents,
|
||||
interval: plan.interval,
|
||||
included_units_per_period: plan.included_units_per_period,
|
||||
max_file_size_mb: plan.max_file_size_mb,
|
||||
max_files_per_batch: plan.max_files_per_batch,
|
||||
retention_days: plan.retention_days,
|
||||
features: plan.features,
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: SubscriptionResponse {
|
||||
subscription: SubscriptionView {
|
||||
status,
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
cancel_at_period_end,
|
||||
plan,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UsageResponse {
|
||||
period_start: DateTime<Utc>,
|
||||
period_end: DateTime<Utc>,
|
||||
used_units: i32,
|
||||
included_units: i32,
|
||||
bonus_units: i32,
|
||||
total_units: i32,
|
||||
remaining_units: i32,
|
||||
}
|
||||
|
||||
async fn get_usage(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Envelope<UsageResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
let billing = billing::get_user_billing(&state, user_id).await?;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct UsageRow {
|
||||
used_units: i32,
|
||||
bonus_units: i32,
|
||||
}
|
||||
|
||||
let usage = sqlx::query_as::<_, UsageRow>(
|
||||
r#"
|
||||
SELECT used_units, bonus_units
|
||||
FROM usage_periods
|
||||
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(billing.period_start)
|
||||
.bind(billing.period_end)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
|
||||
.unwrap_or(UsageRow {
|
||||
used_units: 0,
|
||||
bonus_units: 0,
|
||||
});
|
||||
|
||||
let included = billing.plan.included_units_per_period;
|
||||
let total = included + usage.bonus_units;
|
||||
let remaining = (total - usage.used_units).max(0);
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: UsageResponse {
|
||||
period_start: billing.period_start,
|
||||
period_end: billing.period_end,
|
||||
used_units: usage.used_units,
|
||||
included_units: included,
|
||||
bonus_units: usage.bonus_units,
|
||||
total_units: total,
|
||||
remaining_units: remaining,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PagingQuery {
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
struct InvoiceView {
|
||||
invoice_number: String,
|
||||
status: String,
|
||||
currency: String,
|
||||
total_amount_cents: i32,
|
||||
period_start: Option<DateTime<Utc>>,
|
||||
period_end: Option<DateTime<Utc>>,
|
||||
hosted_invoice_url: Option<String>,
|
||||
pdf_url: Option<String>,
|
||||
paid_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InvoicesResponse {
|
||||
invoices: Vec<InvoiceView>,
|
||||
page: u32,
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
async fn list_invoices(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Query(query): axum::extract::Query<PagingQuery>,
|
||||
) -> Result<Json<Envelope<InvoicesResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
let invoices = sqlx::query_as::<_, InvoiceView>(
|
||||
r#"
|
||||
SELECT
|
||||
invoice_number,
|
||||
status::text AS status,
|
||||
currency,
|
||||
total_amount_cents,
|
||||
period_start,
|
||||
period_end,
|
||||
hosted_invoice_url,
|
||||
pdf_url,
|
||||
paid_at,
|
||||
created_at
|
||||
FROM invoices
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询发票失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: InvoicesResponse {
|
||||
invoices,
|
||||
page,
|
||||
limit,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CheckoutRequest {
|
||||
plan_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CheckoutResponse {
|
||||
checkout_url: String,
|
||||
}
|
||||
|
||||
async fn create_checkout(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CheckoutRequest>,
|
||||
) -> Result<Json<Envelope<CheckoutResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
let idempotency_key = headers
|
||||
.get("idempotency-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
let request_hash = idempotency_key.as_ref().map(|_| {
|
||||
let plan = req.plan_id.to_string();
|
||||
idempotency::sha256_hex(&[b"billing_checkout", plan.as_bytes()])
|
||||
});
|
||||
|
||||
let mut idem_acquired = false;
|
||||
if let (Some(idem), Some(request_hash)) = (idempotency_key.as_deref(), request_hash.as_deref()) {
|
||||
match idempotency::begin(
|
||||
&state,
|
||||
idempotency::Scope::User(user_id),
|
||||
idem,
|
||||
request_hash,
|
||||
state.config.idempotency_ttl_hours as i64,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
idempotency::BeginResult::Replay { response_body, .. } => {
|
||||
let resp: CheckoutResponse = serde_json::from_value(response_body).map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
|
||||
})?;
|
||||
return Ok(Json(Envelope { success: true, data: resp }));
|
||||
}
|
||||
idempotency::BeginResult::InProgress => {
|
||||
if let Some((_status, body)) = idempotency::wait_for_replay(
|
||||
&state,
|
||||
idempotency::Scope::User(user_id),
|
||||
idem,
|
||||
request_hash,
|
||||
10_000,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let resp: CheckoutResponse = serde_json::from_value(body).map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
|
||||
})?;
|
||||
return Ok(Json(Envelope { success: true, data: resp }));
|
||||
}
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"请求正在处理中,请稍后重试",
|
||||
));
|
||||
}
|
||||
idempotency::BeginResult::Acquired { .. } => {
|
||||
idem_acquired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session_result: Result<String, AppError> = (async {
|
||||
let stripe_secret = settings::get_stripe_secret(&state)
|
||||
.await
|
||||
.map_err(|err| err.with_source("stripe secret not configured"))?;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct PlanStripeRow {
|
||||
stripe_price_id: Option<String>,
|
||||
amount_cents: i32,
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
let plan = sqlx::query_as::<_, PlanStripeRow>(
|
||||
r#"
|
||||
SELECT stripe_price_id, amount_cents, is_active
|
||||
FROM plans
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(req.plan_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?
|
||||
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "套餐不存在"))?;
|
||||
|
||||
if !plan.is_active {
|
||||
return Err(AppError::new(ErrorCode::Forbidden, "套餐不可用"));
|
||||
}
|
||||
let Some(price_id) = plan.stripe_price_id.filter(|v| !v.trim().is_empty()) else {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "该套餐不可订阅"));
|
||||
};
|
||||
if plan.amount_cents <= 0 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "该套餐不可订阅"));
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct UserStripeRow {
|
||||
email: String,
|
||||
billing_customer_id: Option<String>,
|
||||
}
|
||||
|
||||
let user = sqlx::query_as::<_, UserStripeRow>(
|
||||
"SELECT email, billing_customer_id FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
let customer_id = if let Some(cus) = user.billing_customer_id {
|
||||
cus
|
||||
} else {
|
||||
let cus = stripe_create_customer(&stripe_secret, &user.email, user_id).await?;
|
||||
let _ = sqlx::query("UPDATE users SET billing_customer_id = $2 WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.bind(&cus)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
cus
|
||||
};
|
||||
|
||||
let success_url =
|
||||
format!("{}/dashboard/billing?checkout=success", state.config.public_base_url);
|
||||
let cancel_url = format!("{}/pricing?checkout=cancel", state.config.public_base_url);
|
||||
|
||||
stripe_create_checkout_session(
|
||||
&stripe_secret,
|
||||
&customer_id,
|
||||
&price_id,
|
||||
&success_url,
|
||||
&cancel_url,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
match session_result {
|
||||
Ok(session) => {
|
||||
if let (Some(idem), Some(request_hash)) =
|
||||
(idempotency_key.as_deref(), request_hash.as_deref())
|
||||
{
|
||||
if idem_acquired {
|
||||
let _ = idempotency::complete(
|
||||
&state,
|
||||
idempotency::Scope::User(user_id),
|
||||
idem,
|
||||
request_hash,
|
||||
200,
|
||||
serde_json::to_value(&CheckoutResponse {
|
||||
checkout_url: session.clone(),
|
||||
})
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: CheckoutResponse {
|
||||
checkout_url: session,
|
||||
},
|
||||
}))
|
||||
}
|
||||
Err(err) => {
|
||||
if let (Some(idem), Some(request_hash)) =
|
||||
(idempotency_key.as_deref(), request_hash.as_deref())
|
||||
{
|
||||
if idem_acquired {
|
||||
let _ = idempotency::abort(
|
||||
&state,
|
||||
idempotency::Scope::User(user_id),
|
||||
idem,
|
||||
request_hash,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct PortalResponse {
|
||||
url: String,
|
||||
}
|
||||
|
||||
async fn create_portal(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Envelope<PortalResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
let stripe_secret = settings::get_stripe_secret(&state)
|
||||
.await
|
||||
.map_err(|err| err.with_source("stripe secret not configured"))?;
|
||||
|
||||
let customer_id: Option<String> =
|
||||
sqlx::query_scalar("SELECT billing_customer_id FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
let Some(customer_id) = customer_id.filter(|v| !v.trim().is_empty()) else {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "未找到 Stripe Customer"));
|
||||
};
|
||||
|
||||
let return_url = format!("{}/dashboard/billing", state.config.public_base_url);
|
||||
let url = stripe_create_portal_session(&stripe_secret, &customer_id, &return_url).await?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: PortalResponse { url },
|
||||
}))
|
||||
}
|
||||
|
||||
async fn stripe_create_customer(secret: &str, email: &str, user_id: Uuid) -> Result<String, AppError> {
|
||||
let resp: serde_json::Value = stripe_post_form(
|
||||
secret,
|
||||
"/v1/customers",
|
||||
vec![
|
||||
("email".to_string(), email.to_string()),
|
||||
("metadata[user_id]".to_string(), user_id.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id = resp
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe customer 创建失败"))?;
|
||||
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
async fn stripe_create_checkout_session(
|
||||
secret: &str,
|
||||
customer_id: &str,
|
||||
price_id: &str,
|
||||
success_url: &str,
|
||||
cancel_url: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<String, AppError> {
|
||||
let resp: serde_json::Value = stripe_post_form(
|
||||
secret,
|
||||
"/v1/checkout/sessions",
|
||||
vec![
|
||||
("mode".to_string(), "subscription".to_string()),
|
||||
("customer".to_string(), customer_id.to_string()),
|
||||
("line_items[0][price]".to_string(), price_id.to_string()),
|
||||
("line_items[0][quantity]".to_string(), "1".to_string()),
|
||||
("success_url".to_string(), success_url.to_string()),
|
||||
("cancel_url".to_string(), cancel_url.to_string()),
|
||||
("allow_promotion_codes".to_string(), "true".to_string()),
|
||||
("client_reference_id".to_string(), user_id.to_string()),
|
||||
("metadata[user_id]".to_string(), user_id.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let url = resp
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe checkout 创建失败"))?;
|
||||
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
async fn stripe_create_portal_session(
|
||||
secret: &str,
|
||||
customer_id: &str,
|
||||
return_url: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let resp: serde_json::Value = stripe_post_form(
|
||||
secret,
|
||||
"/v1/billing_portal/sessions",
|
||||
vec![
|
||||
("customer".to_string(), customer_id.to_string()),
|
||||
("return_url".to_string(), return_url.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let url = resp
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe portal 创建失败"))?;
|
||||
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
async fn stripe_post_form(
|
||||
secret: &str,
|
||||
path: &str,
|
||||
form: Vec<(String, String)>,
|
||||
) -> Result<serde_json::Value, AppError> {
|
||||
let url = format!("https://api.stripe.com{path}");
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(secret)
|
||||
.form(&form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 请求失败").with_source(err))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 响应读取失败").with_source(err))?;
|
||||
|
||||
if !status.is_success() {
|
||||
tracing::error!(status = %status, body = %body, "Stripe API error");
|
||||
return Err(AppError::new(ErrorCode::Internal, "Stripe API 调用失败"));
|
||||
}
|
||||
|
||||
serde_json::from_str(&body)
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 响应解析失败").with_source(err))
|
||||
}
|
||||
Reference in New Issue
Block a user