feat: allow wallet fallback after plan quota exhaustion

This commit is contained in:
elky
2026-10-09 21:21:53 +08:00
parent bad13237d2
commit 63520c8c27
39 changed files with 1474 additions and 190 deletions
@@ -1349,6 +1349,7 @@ CREATE TABLE IF NOT EXISTS public.user_preferences (
email_notifications boolean DEFAULT true,
usage_alerts boolean DEFAULT true,
announcement_notifications boolean DEFAULT true,
allow_wallet_overage boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
@@ -191,6 +191,7 @@ CREATE TABLE IF NOT EXISTS public.user_preferences (
email_notifications boolean DEFAULT true NOT NULL,
usage_alerts boolean DEFAULT true NOT NULL,
announcement_notifications boolean DEFAULT true NOT NULL,
allow_wallet_overage boolean DEFAULT false NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
);
@@ -788,6 +788,11 @@ name = "announcement_notifications"
type = "bool"
default = true
[[table.user_preferences.columns]]
name = "allow_wallet_overage"
type = "bool"
default = false
[[table.user_preferences.columns]]
name = "created_at"
type = "unix_seconds"
@@ -37,6 +37,7 @@ mod overview_migration_safety;
mod policy_nulls;
mod provider_expenses;
mod scoped_provider_creation;
mod user_preferences;
/// A clean PostgreSQL database is bootstrapped from the schema snapshot first;
/// migrations after the privacy/security frontier are intentionally left
@@ -1599,6 +1600,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20261001000000,
20261004000000,
20261007000000,
20261009000000,
]
);
}
@@ -116,6 +116,7 @@ WHERE version=20260919000000;
20261001000000,
20261004000000,
20261007000000,
20261009000000,
]
);
assert_eq!(
@@ -0,0 +1,93 @@
use aether_data_contracts::repository::users::StoredUserPreferenceRecord;
use aether_data_postgres::SqlxUserReadRepository;
use sqlx::{query, query_scalar, PgPool};
use super::{ManagedPostgresServer, POSTGRES_MIGRATOR};
#[tokio::test]
async fn wallet_overage_preference_upgrade_defaults_off_and_round_trips() {
let Some(server) = ManagedPostgresServer::try_start()
.await
.expect("postgres should start")
else {
return;
};
let pool = PgPool::connect(server.database_url())
.await
.expect("database should connect");
sqlx::raw_sql(
r#"
CREATE TABLE providers (id text PRIMARY KEY, name text NOT NULL);
CREATE TABLE user_preferences (
id text PRIMARY KEY, user_id text UNIQUE NOT NULL,
avatar_url text, bio text, default_provider_id text,
theme text NOT NULL DEFAULT 'light', language text NOT NULL DEFAULT 'zh-CN',
timezone text NOT NULL DEFAULT 'Asia/Shanghai',
email_notifications boolean NOT NULL DEFAULT true,
usage_alerts boolean NOT NULL DEFAULT true,
announcement_notifications boolean NOT NULL DEFAULT true,
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
);
INSERT INTO user_preferences (id, user_id, theme) VALUES ('legacy-pref', 'legacy-user', 'dark');
"#,
)
.execute(&pool)
.await
.expect("legacy preferences should seed");
let migration = POSTGRES_MIGRATOR
.iter()
.find(|migration| migration.version == 20261009000000)
.expect("wallet overage migration should be embedded");
for _ in 0..2 {
sqlx::raw_sql(&migration.sql)
.execute(&pool)
.await
.expect("migration should apply idempotently");
}
let repository = SqlxUserReadRepository::new(pool.clone());
let mut preferences = repository
.read_user_preferences("legacy-user")
.await
.expect("legacy preferences should read")
.expect("legacy preferences should exist");
assert!(!preferences.allow_wallet_overage);
assert_eq!(preferences.theme, "dark");
for enabled in [true, false] {
preferences.allow_wallet_overage = enabled;
assert_eq!(
repository
.write_user_preferences(&preferences)
.await
.expect("preferences should update"),
Some(preferences.clone())
);
assert_eq!(
repository
.read_user_preferences("legacy-user")
.await
.expect("preferences should read"),
Some(preferences.clone())
);
}
query("INSERT INTO user_preferences (id, user_id) VALUES ('new-pref', 'new-user')")
.execute(&pool)
.await
.expect("new preferences should insert");
let default: bool = query_scalar(
"SELECT allow_wallet_overage FROM user_preferences WHERE user_id = 'new-user'",
)
.fetch_one(&pool)
.await
.expect("new default should read");
assert!(!default);
let mut enabled = StoredUserPreferenceRecord::default_for_user("enabled-user");
enabled.allow_wallet_overage = true;
assert_eq!(
repository
.write_user_preferences(&enabled)
.await
.expect("new enabled preferences should insert"),
Some(enabled)
);
pool.close().await;
}
@@ -92,14 +92,12 @@ fn billing_plan_from_input(
fn daily_quota_availability_from_entitlements(
entitlements: impl IntoIterator<Item = UserPlanEntitlementRecord>,
billing_plans: &BTreeMap<String, BillingPlanRecord>,
now: u64,
) -> UserDailyQuotaAvailabilityRecord {
let mut has_active_daily_quota = false;
let mut total_quota_usd = 0.0;
let used_usd = 0.0;
let mut remaining_usd = 0.0;
let mut allow_wallet_overage = true;
for entitlement in entitlements {
if entitlement.status != "active"
|| entitlement.starts_at_unix_secs > now
@@ -110,9 +108,6 @@ fn daily_quota_availability_from_entitlements(
let Some(items) = entitlement.entitlements_snapshot.as_array() else {
continue;
};
let current_allow_wallet_overage = billing_plans
.get(&entitlement.plan_id)
.and_then(|plan| daily_quota_wallet_overage_policy(&plan.entitlements_json));
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
@@ -127,11 +122,6 @@ fn daily_quota_availability_from_entitlements(
has_active_daily_quota = true;
total_quota_usd += daily_quota_usd;
remaining_usd += daily_quota_usd;
allow_wallet_overage &= current_allow_wallet_overage.unwrap_or_else(|| {
item.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
});
}
}
UserDailyQuotaAvailabilityRecord {
@@ -139,21 +129,11 @@ fn daily_quota_availability_from_entitlements(
total_quota_usd,
used_usd,
remaining_usd,
allow_wallet_overage,
// The gateway combines this quota with the user's stored preference.
allow_wallet_overage: false,
}
}
fn daily_quota_wallet_overage_policy(entitlements: &serde_json::Value) -> Option<bool> {
entitlements.as_array()?.iter().find_map(|item| {
(item.get("type").and_then(serde_json::Value::as_str) == Some("daily_quota"))
.then(|| {
item.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
})
.flatten()
})
}
#[async_trait]
impl BillingReadRepository for InMemoryBillingReadRepository {
async fn find_model_context(
@@ -578,13 +558,8 @@ impl BillingReadRepository for InMemoryBillingReadRepository {
.filter(|item| item.user_id == user_id)
.cloned()
.collect::<Vec<_>>();
let billing_plans = self
.billing_plans_by_id
.read()
.expect("billing repository lock");
Ok(Some(daily_quota_availability_from_entitlements(
entitlements,
&billing_plans,
now,
)))
}
@@ -3226,6 +3226,7 @@ mod tests {
email_notifications: true,
usage_alerts: true,
announcement_notifications: true,
allow_wallet_overage: true,
};
let repository = InMemoryUserReadRepository::seed_auth_users([admin.clone()])
.with_export_users([export_row])
@@ -4029,6 +4030,7 @@ mod tests {
email_notifications: false,
usage_alerts: true,
announcement_notifications: false,
allow_wallet_overage: true,
};
assert!(repository