mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 19:29:50 +08:00
feat: allow wallet fallback after plan quota exhaustion
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user