fix(billing): enforce one effective subscription per user
This commit is contained in:
@@ -212,7 +212,7 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
{{ subBusy ? '提交中…' : '立即开通' }}
|
{{ subBusy ? '提交中…' : '立即开通' }}
|
||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-slate-500">会取消该用户当前有效订阅,并按月数顺延。</span>
|
<span class="text-xs text-slate-500">会替换当前本地套餐;存在未取消 Stripe 订阅时将拒绝操作。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="subMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
<div v-if="subMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||||
|
|||||||
65
migrations/023_cross_provider_subscription_invariant.sql
Normal file
65
migrations/023_cross_provider_subscription_invariant.sql
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
provider,
|
||||||
|
status::text AS previous_status,
|
||||||
|
current_period_end,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY user_id
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN provider = 'stripe' THEN 0 ELSE 1 END,
|
||||||
|
current_period_end DESC,
|
||||||
|
updated_at DESC,
|
||||||
|
id DESC
|
||||||
|
) AS position
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status IN ('active', 'trialing', 'past_due')
|
||||||
|
), duplicates AS (
|
||||||
|
SELECT *
|
||||||
|
FROM ranked
|
||||||
|
WHERE position > 1
|
||||||
|
)
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
user_id, action, resource_type, resource_id, details
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
'migration_subscription_dedup',
|
||||||
|
'subscription',
|
||||||
|
id,
|
||||||
|
jsonb_build_object(
|
||||||
|
'migration', '023_cross_provider_subscription_invariant',
|
||||||
|
'provider', provider,
|
||||||
|
'previous_status', previous_status,
|
||||||
|
'current_period_end', current_period_end,
|
||||||
|
'reason', 'cross_provider_single_effective_subscription'
|
||||||
|
)
|
||||||
|
FROM duplicates;
|
||||||
|
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY user_id
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN provider = 'stripe' THEN 0 ELSE 1 END,
|
||||||
|
current_period_end DESC,
|
||||||
|
updated_at DESC,
|
||||||
|
id DESC
|
||||||
|
) AS position
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status IN ('active', 'trialing', 'past_due')
|
||||||
|
)
|
||||||
|
UPDATE subscriptions AS subscription
|
||||||
|
SET status = 'canceled',
|
||||||
|
cancel_at_period_end = false,
|
||||||
|
canceled_at = COALESCE(subscription.canceled_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM ranked
|
||||||
|
WHERE ranked.position > 1
|
||||||
|
AND subscription.id = ranked.id;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_subscriptions_user_effective_unique
|
||||||
|
ON subscriptions(user_id)
|
||||||
|
WHERE status IN ('active', 'trialing', 'past_due');
|
||||||
244
src/api/admin.rs
244
src/api/admin.rs
@@ -959,28 +959,92 @@ async fn create_manual_subscription(
|
|||||||
return Err(AppError::new(ErrorCode::Forbidden, "套餐不可用"));
|
return Err(AppError::new(ErrorCode::Forbidden, "套餐不可用"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (subscription_id, period_start, period_end) = persist_manual_subscription(
|
||||||
|
&state.db,
|
||||||
|
admin_id,
|
||||||
|
user_id,
|
||||||
|
plan.id,
|
||||||
|
months,
|
||||||
|
req.note.as_deref(),
|
||||||
|
ip,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: ManualSubscriptionResponse {
|
||||||
|
message: "套餐已开通".to_string(),
|
||||||
|
subscription_id,
|
||||||
|
user_id,
|
||||||
|
plan_id: plan.id,
|
||||||
|
plan_name: plan.name,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
status: "active".to_string(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_manual_subscription(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
admin_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
plan_id: Uuid,
|
||||||
|
months: i32,
|
||||||
|
note: Option<&str>,
|
||||||
|
ip: IpAddr,
|
||||||
|
) -> Result<(Uuid, DateTime<Utc>, DateTime<Utc>), AppError> {
|
||||||
let period_start = Utc::now();
|
let period_start = Utc::now();
|
||||||
let period_end = add_months_utc8(period_start, months)?;
|
let period_end = add_months_utc8(period_start, months)?;
|
||||||
|
let mut tx = pool
|
||||||
let mut tx = state
|
|
||||||
.db
|
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||||
|
|
||||||
let _ = sqlx::query(
|
let _: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE id = $1 FOR UPDATE")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定订阅用户失败").with_source(err))?;
|
||||||
|
|
||||||
|
let has_open_stripe: bool = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND provider = 'stripe'
|
||||||
|
AND status <> 'canceled'
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "检查 Stripe 订阅失败").with_source(err))?;
|
||||||
|
if has_open_stripe {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::Forbidden,
|
||||||
|
"用户存在未取消的 Stripe 订阅,不能直接替换为手工套餐",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE subscriptions
|
UPDATE subscriptions
|
||||||
SET status = 'canceled',
|
SET status = 'canceled',
|
||||||
cancel_at_period_end = false,
|
cancel_at_period_end = false,
|
||||||
canceled_at = NOW(),
|
canceled_at = NOW(),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE user_id = $1 AND status IN ('active', 'trialing', 'past_due')
|
WHERE user_id = $1
|
||||||
|
AND provider <> 'stripe'
|
||||||
|
AND status IN ('active', 'trialing', 'past_due')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await;
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "关闭原本地订阅失败").with_source(err))?;
|
||||||
|
|
||||||
let subscription_id: Uuid = sqlx::query_scalar(
|
let subscription_id: Uuid = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
@@ -999,7 +1063,7 @@ async fn create_manual_subscription(
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(plan.id)
|
.bind(plan_id)
|
||||||
.bind(period_start)
|
.bind(period_start)
|
||||||
.bind(period_end)
|
.bind(period_end)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
@@ -1031,9 +1095,9 @@ async fn create_manual_subscription(
|
|||||||
.bind(subscription_id)
|
.bind(subscription_id)
|
||||||
.bind(serde_json::json!({
|
.bind(serde_json::json!({
|
||||||
"target_user_id": user_id,
|
"target_user_id": user_id,
|
||||||
"plan_id": plan.id,
|
"plan_id": plan_id,
|
||||||
"months": months,
|
"months": months,
|
||||||
"note": req.note,
|
"note": note,
|
||||||
}))
|
}))
|
||||||
.bind(ip.to_string())
|
.bind(ip.to_string())
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
@@ -1044,19 +1108,7 @@ async fn create_manual_subscription(
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||||
|
|
||||||
Ok(Json(Envelope {
|
Ok((subscription_id, period_start, period_end))
|
||||||
success: true,
|
|
||||||
data: ManualSubscriptionResponse {
|
|
||||||
message: "套餐已开通".to_string(),
|
|
||||||
subscription_id,
|
|
||||||
user_id,
|
|
||||||
plan_id: plan.id,
|
|
||||||
plan_name: plan.name,
|
|
||||||
period_start,
|
|
||||||
period_end,
|
|
||||||
status: "active".to_string(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_months_utc8(start: DateTime<Utc>, months: i32) -> Result<DateTime<Utc>, AppError> {
|
fn add_months_utc8(start: DateTime<Utc>, months: i32) -> Result<DateTime<Utc>, AppError> {
|
||||||
@@ -1740,6 +1792,7 @@ async fn audit_config_action(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn secret_masking_never_splits_utf8() {
|
fn secret_masking_never_splits_utf8() {
|
||||||
@@ -1747,4 +1800,151 @@ mod tests {
|
|||||||
assert_eq!(mask_secret("中文密钥测试内容"), "中文密钥测试内容");
|
assert_eq!(mask_secret("中文密钥测试内容"), "中文密钥测试内容");
|
||||||
assert_eq!(mask_secret("🔑🔑🔑🔑🔑🔑🔑🔑more"), "🔑🔑🔑🔑🔑🔑🔑🔑...");
|
assert_eq!(mask_secret("🔑🔑🔑🔑🔑🔑🔑🔑more"), "🔑🔑🔑🔑🔑🔑🔑🔑...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires IMAGEFORGE_TEST_DATABASE_URL"]
|
||||||
|
async fn manual_subscriptions_are_serialized_and_cannot_replace_stripe() {
|
||||||
|
let database_url = std::env::var("IMAGEFORGE_TEST_DATABASE_URL")
|
||||||
|
.expect("IMAGEFORGE_TEST_DATABASE_URL is required");
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(32)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await
|
||||||
|
.expect("connect test database");
|
||||||
|
sqlx::migrate!()
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("apply test migrations");
|
||||||
|
|
||||||
|
let marker = Uuid::new_v4().simple().to_string();
|
||||||
|
let admin_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (email, username, password_hash, role, email_verified_at)
|
||||||
|
VALUES ($1, $2, 'test', 'admin', NOW())
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(format!("admin-{marker}@example.test"))
|
||||||
|
.bind(format!("admin-{marker}"))
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert test admin");
|
||||||
|
let user_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (email, username, password_hash, email_verified_at)
|
||||||
|
VALUES ($1, $2, 'test', NOW())
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(format!("user-{marker}@example.test"))
|
||||||
|
.bind(format!("user-{marker}"))
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert test user");
|
||||||
|
let plan_id: Uuid = sqlx::query_scalar("SELECT id FROM plans WHERE code = 'pro_monthly'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("load test plan");
|
||||||
|
let ip: IpAddr = "127.0.0.1".parse().unwrap();
|
||||||
|
|
||||||
|
let mut joins = Vec::new();
|
||||||
|
for _ in 0..20 {
|
||||||
|
let pool = pool.clone();
|
||||||
|
joins.push(tokio::spawn(async move {
|
||||||
|
persist_manual_subscription(
|
||||||
|
&pool,
|
||||||
|
admin_id,
|
||||||
|
user_id,
|
||||||
|
plan_id,
|
||||||
|
1,
|
||||||
|
Some("concurrency-test"),
|
||||||
|
ip,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for join in joins {
|
||||||
|
join.await
|
||||||
|
.expect("manual subscription task panicked")
|
||||||
|
.expect("manual subscription failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let effective_manual: i64 = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND provider = 'manual'
|
||||||
|
AND status IN ('active', 'trialing', 'past_due')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("count effective manual subscriptions");
|
||||||
|
assert_eq!(effective_manual, 1);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE user_id = $1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("cancel test manual subscription");
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO subscriptions (
|
||||||
|
user_id, plan_id, status, current_period_start, current_period_end,
|
||||||
|
provider, provider_customer_id, provider_subscription_id
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, 'active', NOW(), NOW() + INTERVAL '1 month',
|
||||||
|
'stripe', $3, $4
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(plan_id)
|
||||||
|
.bind(format!("cus_{marker}"))
|
||||||
|
.bind(format!("sub_{marker}"))
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert Stripe subscription");
|
||||||
|
|
||||||
|
let error = persist_manual_subscription(
|
||||||
|
&pool,
|
||||||
|
admin_id,
|
||||||
|
user_id,
|
||||||
|
plan_id,
|
||||||
|
1,
|
||||||
|
Some("must-not-replace-stripe"),
|
||||||
|
ip,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("manual subscription replaced Stripe");
|
||||||
|
assert_eq!(error.code, ErrorCode::Forbidden);
|
||||||
|
let effective_subscriptions: i64 = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND status IN ('active', 'trialing', 'past_due')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("count effective subscriptions");
|
||||||
|
assert_eq!(effective_subscriptions, 1);
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM audit_logs WHERE details->>'target_user_id' = $1")
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("clean test audit logs");
|
||||||
|
sqlx::query("DELETE FROM users WHERE id = ANY($1)")
|
||||||
|
.bind(vec![user_id, admin_id])
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("clean test users");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,23 +457,30 @@ async fn create_checkout_for_user(
|
|||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定用户失败").with_source(err))?
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定用户失败").with_source(err))?
|
||||||
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在"))?;
|
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在"))?;
|
||||||
|
|
||||||
let has_open_subscription: bool = sqlx::query_scalar(
|
let open_subscription_provider: Option<String> = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
SELECT EXISTS(
|
SELECT provider
|
||||||
SELECT 1 FROM subscriptions
|
FROM subscriptions
|
||||||
WHERE user_id = $1 AND provider = 'stripe' AND status <> 'canceled'
|
WHERE user_id = $1
|
||||||
)
|
AND (
|
||||||
|
(provider = 'stripe' AND status <> 'canceled')
|
||||||
|
OR status IN ('active', 'trialing', 'past_due')
|
||||||
|
)
|
||||||
|
ORDER BY CASE WHEN provider = 'stripe' THEN 0 ELSE 1 END
|
||||||
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅状态失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅状态失败").with_source(err))?;
|
||||||
if has_open_subscription {
|
if let Some(provider) = open_subscription_provider {
|
||||||
return Err(AppError::new(
|
let message = if provider == "stripe" {
|
||||||
ErrorCode::IdempotencyConflict,
|
"已有 Stripe 订阅,请通过账单门户升级、降级或续费"
|
||||||
"已有 Stripe 订阅,请通过账单门户升级、降级或续费",
|
} else {
|
||||||
));
|
"当前已有有效套餐,请在套餐结束后创建 Stripe 订阅"
|
||||||
|
};
|
||||||
|
return Err(AppError::new(ErrorCode::IdempotencyConflict, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|||||||
@@ -642,12 +642,14 @@ async fn write_subscription(
|
|||||||
if subscription.status != "canceled" {
|
if subscription.status != "canceled" {
|
||||||
let conflicting: Option<String> = sqlx::query_scalar(
|
let conflicting: Option<String> = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
SELECT provider_subscription_id
|
SELECT provider || ':' || COALESCE(provider_subscription_id, id::text)
|
||||||
FROM subscriptions
|
FROM subscriptions
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND provider = 'stripe'
|
AND status IN ('active', 'trialing', 'past_due')
|
||||||
AND status <> 'canceled'
|
AND NOT (
|
||||||
AND provider_subscription_id <> $2
|
provider = 'stripe'
|
||||||
|
AND provider_subscription_id = $2
|
||||||
|
)
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -661,7 +663,7 @@ async fn write_subscription(
|
|||||||
if conflicting.is_some() {
|
if conflicting.is_some() {
|
||||||
return Err(AppError::new(
|
return Err(AppError::new(
|
||||||
ErrorCode::StorageUnavailable,
|
ErrorCode::StorageUnavailable,
|
||||||
"用户已有其他未取消 Stripe 订阅,事件等待人工对账",
|
"用户已有其他有效订阅,Stripe 事件等待人工对账",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1910,6 +1912,42 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("cancel primary subscription");
|
.expect("cancel primary subscription");
|
||||||
|
|
||||||
|
let manual_subscription_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO subscriptions (
|
||||||
|
user_id, plan_id, status, current_period_start, current_period_end, provider
|
||||||
|
) VALUES ($1, $2, 'active', NOW(), NOW() + INTERVAL '1 month', 'manual')
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(plan_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert manual subscription before delayed Stripe event");
|
||||||
|
let delayed_active_error = apply_test_event(&state, &secondary)
|
||||||
|
.await
|
||||||
|
.expect_err("delayed active Stripe event created cross-provider double entitlement");
|
||||||
|
assert_eq!(delayed_active_error.code, ErrorCode::StorageUnavailable);
|
||||||
|
let effective_after_delay: i64 = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*) FROM subscriptions
|
||||||
|
WHERE user_id = $1 AND status IN ('active', 'trialing', 'past_due')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("count effective subscriptions after delayed Stripe event");
|
||||||
|
assert_eq!(effective_after_delay, 1);
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(manual_subscription_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("cancel delayed-event manual fixture");
|
||||||
|
|
||||||
let concurrent_invoice_id = format!("inv_{marker}_concurrent");
|
let concurrent_invoice_id = format!("inv_{marker}_concurrent");
|
||||||
let concurrent_invoice_number = format!("INV-{marker}-C");
|
let concurrent_invoice_number = format!("INV-{marker}-C");
|
||||||
let concurrent_invoice_events = [
|
let concurrent_invoice_events = [
|
||||||
|
|||||||
Reference in New Issue
Block a user