Skip to content

Commit a70d4b0

Browse files
fix: resole comments
1 parent 321808e commit a70d4b0

File tree

6 files changed

+68
-26
lines changed

6 files changed

+68
-26
lines changed

crates/api/src/routes/credits.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use axum::{
66
};
77
use serde::{Deserialize, Serialize};
88
use services::subscription::ports::{CreditsSummary, SubscriptionError};
9-
use url::Url;
9+
use url::{Host, Url};
1010
use utoipa::ToSchema;
1111

1212
/// Request to create a credit purchase checkout session
@@ -34,10 +34,12 @@ fn validate_redirect_url(url_str: &str, field_name: &str) -> Result<(), ApiError
3434
match url.scheme() {
3535
"https" => Ok(()),
3636
"http" => {
37-
let host_ok = url
38-
.host_str()
39-
.map(|h| h == "localhost" || h == "127.0.0.1")
40-
.unwrap_or(false);
37+
let host_ok = match url.host() {
38+
Some(Host::Domain(d)) => d == "localhost",
39+
Some(Host::Ipv4(ip)) => ip.is_loopback(),
40+
Some(Host::Ipv6(ip)) => ip.is_loopback(),
41+
_ => false,
42+
};
4143
if host_ok {
4244
Ok(())
4345
} else {

crates/api/src/routes/subscriptions.rs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use axum::{
99
};
1010
use serde::{Deserialize, Serialize};
1111
use services::subscription::ports::{SubscriptionError, SubscriptionPlan, SubscriptionWithPlan};
12-
use url::Url;
12+
use url::{Host, Url};
1313
use utoipa::ToSchema;
1414

1515
/// Request to create a new subscription
@@ -34,7 +34,7 @@ fn default_provider() -> String {
3434
}
3535

3636
/// Validates that a URL is valid and secure for Stripe checkout/portal redirects.
37-
/// Requires https for production. Allows http only for localhost/127.0.0.1 (development).
37+
/// Requires https for production. Allows http only for loopback (localhost, 127.0.0.1, [::1]).
3838
fn validate_redirect_url(url_str: &str, field_name: &str) -> Result<(), ApiError> {
3939
let url = Url::parse(url_str).map_err(|_| {
4040
ApiError::bad_request(format!(
@@ -45,22 +45,23 @@ fn validate_redirect_url(url_str: &str, field_name: &str) -> Result<(), ApiError
4545
match url.scheme() {
4646
"https" => Ok(()),
4747
"http" => {
48-
// Allow http only for local development (localhost, 127.0.0.1)
49-
let host_ok = url
50-
.host_str()
51-
.map(|h| h == "localhost" || h == "127.0.0.1")
52-
.unwrap_or(false);
48+
let host_ok = match url.host() {
49+
Some(Host::Domain(d)) => d == "localhost",
50+
Some(Host::Ipv4(ip)) => ip.is_loopback(),
51+
Some(Host::Ipv6(ip)) => ip.is_loopback(),
52+
_ => false,
53+
};
5354
if host_ok {
5455
Ok(())
5556
} else {
5657
Err(ApiError::bad_request(format!(
57-
"Invalid {}: URL must use https for non-localhost addresses (http is only allowed for localhost/127.0.0.1 during development)",
58+
"Invalid {}: URL must use https for non-localhost addresses (http is only allowed for localhost/127.0.0.1/[::1] during development)",
5859
field_name
5960
)))
6061
}
6162
}
6263
_ => Err(ApiError::bad_request(format!(
63-
"Invalid {}: URL scheme must be https (or http for localhost/127.0.0.1 only)",
64+
"Invalid {}: URL scheme must be https (or http for loopback only)",
6465
field_name
6566
))),
6667
}

crates/database/src/migrations/sql/V25__add_user_credits.sql

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
-- Table for purchased credits balance per user
1+
-- Table for purchased credits balance per user.
2+
-- Unit: nano-USD (1e-9 USD; 1_000_000_000 = $1). Same unit as cost_nano_usd and monthly_credits.
23
CREATE TABLE user_credits (
34
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
45
balance BIGINT NOT NULL DEFAULT 0 CHECK (balance >= 0),
@@ -12,7 +13,8 @@ CREATE TRIGGER update_user_credits_updated_at
1213
FOR EACH ROW
1314
EXECUTE FUNCTION update_updated_at_column();
1415

15-
-- Table for credit transaction audit (purchases, grants, admin adjustments)
16+
-- Table for credit transaction audit (purchases, grants, admin adjustments).
17+
-- amount is in nano-USD (same unit as user_credits.balance).
1618
CREATE TABLE credit_transactions (
1719
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
1820
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

crates/services/src/subscription/ports.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,11 +367,18 @@ pub trait SubscriptionService: Send + Sync {
367367
async fn get_credits(&self, user_id: UserId) -> Result<CreditsSummary, SubscriptionError>;
368368
}
369369

370-
/// Summary of user's credits (balance, used, effective limit)
370+
/// Summary of user's credits (balance, used, effective limit).
371+
///
372+
/// **Unit: nano-USD** (1 credit = 1e-9 USD; 1_000_000_000 = $1). All fields use this unit so they
373+
/// can be compared: usage is recorded as `cost_nano_usd`, plan limits and purchased balance
374+
/// are in the same unit.
371375
#[derive(Debug, Clone, Serialize, Deserialize)]
372376
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
373377
pub struct CreditsSummary {
378+
/// Purchased credits balance (nano-USD).
374379
pub balance: i64,
380+
/// Usage in the current period (sum of cost_nano_usd).
375381
pub used_credits: i64,
382+
/// Effective limit: plan limit + balance (nano-USD).
376383
pub effective_max_credits: i64,
377384
}

crates/services/src/subscription/service.rs

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ struct CachedCreditLimit {
4747
const TTL_CACHE_SECS: u64 = 600; // 10 minutes
4848
/// Default monthly credits when plan has no monthly_credits config. 1 USD in nano-dollars ($1 = 1_000_000_000).
4949
const DEFAULT_MONTHLY_CREDITS_NANO_USD: u64 = 1_000_000_000;
50+
/// When falling back from monthly_tokens: 1.5 USD per M tokens. M = 1 million tokens.
51+
const TOKENS_TO_CREDITS_PER_M: u64 = 1_000_000;
52+
/// 1.5 USD in nano-USD. Used for monthly_tokens fallback: limit_nano_usd = (monthly_tokens / M) * this.
53+
const NANO_USD_PER_1_5_USD: u64 = 1_500_000_000;
5054

5155
pub struct SubscriptionServiceImpl {
5256
db_pool: deadpool_postgres::Pool,
@@ -1310,6 +1314,20 @@ impl SubscriptionService for SubscriptionServiceImpl {
13101314
.and_then(|c| c.subscription_plans)
13111315
.unwrap_or_default();
13121316

1317+
// Use monthly_credits when set (nano USD); else monthly_tokens → nano USD at 1.5 USD per M tokens. Never fail for missing config.
1318+
let plan_limit_max = |config: &SubscriptionPlanConfig| {
1319+
if let Some(ref lim) = config.monthly_credits {
1320+
return lim.max;
1321+
}
1322+
if let Some(ref lim) = config.monthly_tokens {
1323+
// fallback: 1.5 USD per M tokens => limit_nano_usd = (monthly_tokens / M) * 1.5 * 1e9
1324+
let nano_usd = (lim.max as u128 * NANO_USD_PER_1_5_USD as u128
1325+
/ TOKENS_TO_CREDITS_PER_M as u128)
1326+
.min(u64::MAX as u128) as u64;
1327+
return nano_usd;
1328+
}
1329+
DEFAULT_MONTHLY_CREDITS_NANO_USD
1330+
};
13131331
let (plan_credits, period_start, period_end) = match self
13141332
.subscription_repo
13151333
.get_active_subscription(user_id)
@@ -1324,8 +1342,7 @@ impl SubscriptionService for SubscriptionServiceImpl {
13241342
);
13251343
let plan_credits = subscription_plans
13261344
.get(&plan_name)
1327-
.and_then(|c| c.monthly_credits.as_ref())
1328-
.map(|l| l.max)
1345+
.map(plan_limit_max)
13291346
.unwrap_or(DEFAULT_MONTHLY_CREDITS_NANO_USD);
13301347
let period_end = sub.current_period_end;
13311348
let period_start = sub_one_month_same_day(period_end);
@@ -1334,8 +1351,7 @@ impl SubscriptionService for SubscriptionServiceImpl {
13341351
None => {
13351352
let plan_credits = subscription_plans
13361353
.get("free")
1337-
.and_then(|c| c.monthly_credits.as_ref())
1338-
.map(|l| l.max)
1354+
.map(plan_limit_max)
13391355
.unwrap_or(DEFAULT_MONTHLY_CREDITS_NANO_USD);
13401356
let (period_start, period_end) = current_calendar_month_period(Utc::now());
13411357
(plan_credits, period_start, period_end)
@@ -1547,6 +1563,7 @@ impl SubscriptionService for SubscriptionServiceImpl {
15471563
let customer_id = self.get_or_create_stripe_customer(user_id, None).await?;
15481564

15491565
let base_client = self.get_stripe_client();
1566+
// Hour-granular idempotency: same user+credits within the same clock hour reuses Stripe session (avoids duplicate checkouts; retry after hour gets new session).
15501567
let idempotency_key = format!(
15511568
"credit_checkout_{}_{}_{}",
15521569
user_id,
@@ -1603,6 +1620,20 @@ impl SubscriptionService for SubscriptionServiceImpl {
16031620
.and_then(|c| c.subscription_plans)
16041621
.unwrap_or_default();
16051622

1623+
// Use monthly_credits when set (nano USD); else monthly_tokens → nano USD at 1.5 USD per M tokens. Never fail for missing config.
1624+
let plan_limit_max = |config: &SubscriptionPlanConfig| {
1625+
if let Some(ref lim) = config.monthly_credits {
1626+
return lim.max;
1627+
}
1628+
if let Some(ref lim) = config.monthly_tokens {
1629+
// fallback: 1.5 USD per M tokens => limit_nano_usd = (monthly_tokens / M) * 1.5 * 1e9
1630+
let nano_usd = (lim.max as u128 * NANO_USD_PER_1_5_USD as u128
1631+
/ TOKENS_TO_CREDITS_PER_M as u128)
1632+
.min(u64::MAX as u128) as u64;
1633+
return nano_usd;
1634+
}
1635+
DEFAULT_MONTHLY_CREDITS_NANO_USD
1636+
};
16061637
let (plan_credits, period_start, period_end) = match self
16071638
.subscription_repo
16081639
.get_active_subscription(user_id)
@@ -1614,8 +1645,7 @@ impl SubscriptionService for SubscriptionServiceImpl {
16141645
resolve_plan_name_from_config("stripe", &sub.price_id, &subscription_plans);
16151646
let plan_credits = subscription_plans
16161647
.get(&plan_name)
1617-
.and_then(|c| c.monthly_credits.as_ref())
1618-
.map(|l| l.max)
1648+
.map(plan_limit_max)
16191649
.unwrap_or(DEFAULT_MONTHLY_CREDITS_NANO_USD);
16201650
let period_end = sub.current_period_end;
16211651
let period_start = sub_one_month_same_day(period_end);
@@ -1624,8 +1654,7 @@ impl SubscriptionService for SubscriptionServiceImpl {
16241654
None => {
16251655
let plan_credits = subscription_plans
16261656
.get("free")
1627-
.and_then(|c| c.monthly_credits.as_ref())
1628-
.map(|l| l.max)
1657+
.map(plan_limit_max)
16291658
.unwrap_or(DEFAULT_MONTHLY_CREDITS_NANO_USD);
16301659
let (period_start, period_end) = current_calendar_month_period(Utc::now());
16311660
(plan_credits, period_start, period_end)

crates/services/src/system_configs/ports.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ pub struct SubscriptionPlanConfig {
102102
/// Agent instance limits (e.g. { "max": 1 })
103103
#[serde(skip_serializing_if = "Option::is_none")]
104104
pub agent_instances: Option<PlanLimitConfig>,
105-
/// Monthly token limits (e.g. { "max": 1000000 }). Kept for backward compatibility.
105+
/// Monthly token limits (e.g. { "max": 1000000 }). Backward compatibility only: when monthly_credits is unset,
106+
/// this is converted to a nano-USD limit at 1.5 USD per M tokens (M = 1e6). Not used when monthly_credits is set.
106107
#[serde(skip_serializing_if = "Option::is_none")]
107108
pub monthly_tokens: Option<PlanLimitConfig>,
108109
/// Monthly credit limits in nano-USD (e.g. { "max": 1000000000 } = $1). Used for quota enforcement.

0 commit comments

Comments
 (0)