Skip to content

Commit d9b016b

Browse files
fix: admin grant credits
1 parent a70d4b0 commit d9b016b

File tree

5 files changed

+268
-0
lines changed

5 files changed

+268
-0
lines changed

crates/api/src/routes/admin.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,87 @@ pub async fn list_users(
241241
}))
242242
}
243243

244+
/// Admin request to grant credits to a user (nano-USD).
245+
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
246+
pub struct AdminGrantCreditsRequest {
247+
/// Target user id
248+
pub user_id: Uuid,
249+
/// Grant amount in nano-USD (must be positive; 1_000_000_000 = $1).
250+
pub amount_nano_usd: i64,
251+
/// Optional reason for the grant (stored as reference_id in credit_transactions).
252+
#[serde(default)]
253+
pub reason: Option<String>,
254+
}
255+
256+
/// Admin response after granting credits.
257+
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
258+
pub struct AdminGrantCreditsResponse {
259+
/// New purchased credits balance for the user (nano-USD).
260+
pub new_balance_nano_usd: i64,
261+
}
262+
263+
/// Admin endpoint: Grant credits to a user (manual/admin adjustment).
264+
#[utoipa::path(
265+
post,
266+
path = "/v1/admin/credits",
267+
tag = "Admin",
268+
request_body = AdminGrantCreditsRequest,
269+
responses(
270+
(status = 200, description = "Credits granted successfully", body = AdminGrantCreditsResponse),
271+
(status = 400, description = "Invalid request", body = crate::error::ApiErrorResponse),
272+
(status = 401, description = "Unauthorized", body = crate::error::ApiErrorResponse),
273+
(status = 403, description = "Forbidden - Admin access required", body = crate::error::ApiErrorResponse),
274+
(status = 500, description = "Internal server error", body = crate::error::ApiErrorResponse)
275+
),
276+
security(
277+
("session_token" = [])
278+
)
279+
)]
280+
pub async fn admin_grant_credits(
281+
State(app_state): State<AppState>,
282+
Json(request): Json<AdminGrantCreditsRequest>,
283+
) -> Result<Json<AdminGrantCreditsResponse>, ApiError> {
284+
if request.amount_nano_usd <= 0 {
285+
return Err(ApiError::bad_request(
286+
"amount_nano_usd must be positive".to_string(),
287+
));
288+
}
289+
290+
let user_id = UserId(request.user_id);
291+
tracing::info!(
292+
"Admin: Granting credits to user_id={}, amount_nano_usd={}, reason={:?}",
293+
user_id,
294+
request.amount_nano_usd,
295+
request.reason
296+
);
297+
298+
let new_balance = app_state
299+
.subscription_service
300+
.admin_grant_credits(user_id, request.amount_nano_usd, request.reason.clone())
301+
.await
302+
.map_err(|e| match e {
303+
services::subscription::ports::SubscriptionError::InvalidCredits(msg) => {
304+
ApiError::bad_request(msg)
305+
}
306+
services::subscription::ports::SubscriptionError::DatabaseError(msg) => {
307+
tracing::error!(error = ?msg, "Database error granting credits");
308+
ApiError::internal_server_error("Failed to grant credits")
309+
}
310+
services::subscription::ports::SubscriptionError::InternalError(msg) => {
311+
tracing::error!(error = ?msg, "Internal error granting credits");
312+
ApiError::internal_server_error("Failed to grant credits")
313+
}
314+
other => {
315+
tracing::error!(error = ?other, "Unexpected error granting credits");
316+
ApiError::internal_server_error("Failed to grant credits")
317+
}
318+
})?;
319+
320+
Ok(Json(AdminGrantCreditsResponse {
321+
new_balance_nano_usd: new_balance,
322+
}))
323+
}
324+
244325
/// Implementation used by bi_list_users only (BI endpoint).
245326
async fn list_users_bi_impl(
246327
app_state: &AppState,
@@ -2449,6 +2530,7 @@ pub fn create_admin_router() -> Router<AppState> {
24492530
.route("/models", get(list_models).patch(batch_upsert_models))
24502531
.route("/models/{model_id}", delete(delete_model))
24512532
.route("/vpc/revoke", post(revoke_vpc_credentials))
2533+
.route("/credits", post(admin_grant_credits))
24522534
.route(
24532535
"/configs",
24542536
get(get_system_configs_admin).patch(upsert_system_configs),

crates/api/tests/admin_tests.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod common;
22

33
use common::{create_test_server, create_test_server_and_db, mock_login};
4+
use serde_json::json;
45
use services::user::ports::UserRepository;
56
use uuid::Uuid;
67

@@ -131,6 +132,112 @@ async fn test_revoke_vpc_credentials_with_non_admin_account() {
131132
assert_eq!(error, Some("Admin access required"));
132133
}
133134

135+
#[tokio::test]
136+
async fn test_admin_grant_credits_with_admin_account() {
137+
let (server, db) = create_test_server_and_db(common::TestServerConfig::default()).await;
138+
139+
// Create a regular user
140+
let user_email = "credits_user@example.com";
141+
let user_token = mock_login(&server, user_email).await;
142+
let users_repo = db.user_repository();
143+
let user = users_repo
144+
.get_user_by_email(user_email)
145+
.await
146+
.expect("get_user_by_email")
147+
.expect("user should exist");
148+
149+
// Admin token
150+
let admin_email = "credits_admin@admin.org";
151+
let admin_token = mock_login(&server, admin_email).await;
152+
153+
// Grant 2 USD worth of credits (2_000_000_000 nano-USD)
154+
let grant_body = json!({
155+
"user_id": user.id,
156+
"amount_nano_usd": 2_000_000_000_i64,
157+
"reason": "test admin grant"
158+
});
159+
160+
let response = server
161+
.post("/v1/admin/credits")
162+
.add_header(
163+
http::HeaderName::from_static("authorization"),
164+
http::HeaderValue::from_str(&format!("Bearer {admin_token}")).unwrap(),
165+
)
166+
.add_header(
167+
http::HeaderName::from_static("content-type"),
168+
http::HeaderValue::from_static("application/json"),
169+
)
170+
.json(&grant_body)
171+
.await;
172+
173+
assert_eq!(
174+
response.status_code(),
175+
200,
176+
"Admin should be able to grant credits"
177+
);
178+
179+
let body: serde_json::Value = response.json();
180+
let new_balance = body
181+
.get("new_balance_nano_usd")
182+
.and_then(|v| v.as_i64())
183+
.expect("new_balance_nano_usd should be present");
184+
assert_eq!(new_balance, 2_000_000_000_i64);
185+
186+
// Verify that the endpoint is protected (regular user cannot call it successfully)
187+
let response_non_admin = server
188+
.post("/v1/admin/credits")
189+
.add_header(
190+
http::HeaderName::from_static("authorization"),
191+
http::HeaderValue::from_str(&format!("Bearer {user_token}")).unwrap(),
192+
)
193+
.add_header(
194+
http::HeaderName::from_static("content-type"),
195+
http::HeaderValue::from_static("application/json"),
196+
)
197+
.json(&grant_body)
198+
.await;
199+
200+
assert_eq!(
201+
response_non_admin.status_code(),
202+
403,
203+
"Non-admin should receive 403 Forbidden when trying to grant credits"
204+
);
205+
let body_non_admin: serde_json::Value = response_non_admin.json();
206+
let error = body_non_admin.get("message").and_then(|v| v.as_str());
207+
assert_eq!(error, Some("Admin access required"));
208+
}
209+
210+
#[tokio::test]
211+
async fn test_admin_grant_credits_validation() {
212+
let server = create_test_server().await;
213+
214+
let admin_email = "credits_admin_validation@admin.org";
215+
let admin_token = mock_login(&server, admin_email).await;
216+
217+
let body = json!({
218+
"user_id": "00000000-0000-4000-8000-000000000000",
219+
"amount_nano_usd": 0
220+
});
221+
222+
let response = server
223+
.post("/v1/admin/credits")
224+
.add_header(
225+
http::HeaderName::from_static("authorization"),
226+
http::HeaderValue::from_str(&format!("Bearer {admin_token}")).unwrap(),
227+
)
228+
.add_header(
229+
http::HeaderName::from_static("content-type"),
230+
http::HeaderValue::from_static("application/json"),
231+
)
232+
.json(&body)
233+
.await;
234+
235+
assert_eq!(response.status_code(), 400);
236+
let body_json: serde_json::Value = response.json();
237+
let message = body_json.get("message").and_then(|v| v.as_str());
238+
assert_eq!(message, Some("amount_nano_usd must be positive"));
239+
}
240+
134241
/// Admin list instances sanitizes dashboard_url by stripping query params, fragment, and userinfo.
135242
#[tokio::test]
136243
async fn test_admin_agents_instances_sanitizes_dashboard_url() {

crates/database/src/repositories/credits_repository.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,22 @@ impl CreditsRepository for PostgresCreditsRepository {
7878
}
7979
}
8080
}
81+
82+
async fn record_grant(
83+
&self,
84+
txn: &tokio_postgres::Transaction<'_>,
85+
user_id: UserId,
86+
amount: i64,
87+
reason: Option<String>,
88+
) -> anyhow::Result<()> {
89+
txn.execute(
90+
r#"
91+
INSERT INTO credit_transactions (user_id, amount, type, reference_id)
92+
VALUES ($1, $2, 'grant', $3)
93+
"#,
94+
&[&user_id, &amount, &reason],
95+
)
96+
.await?;
97+
Ok(())
98+
}
8199
}

crates/services/src/subscription/ports.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,15 @@ pub trait CreditsRepository: Send + Sync {
233233
amount: i64,
234234
reference_id: &str,
235235
) -> anyhow::Result<bool>;
236+
237+
/// Record an admin grant transaction (for manual/admin adjustments).
238+
async fn record_grant(
239+
&self,
240+
txn: &tokio_postgres::Transaction<'_>,
241+
user_id: UserId,
242+
amount: i64,
243+
reason: Option<String>,
244+
) -> anyhow::Result<()>;
236245
}
237246

238247
/// Repository trait for payment webhook events
@@ -365,6 +374,15 @@ pub trait SubscriptionService: Send + Sync {
365374

366375
/// Get user's credits: balance, used in period, effective max.
367376
async fn get_credits(&self, user_id: UserId) -> Result<CreditsSummary, SubscriptionError>;
377+
378+
/// Admin-only: grant credits (nano-USD) directly to a user.
379+
/// Records a 'grant' transaction and updates user_credits balance.
380+
async fn admin_grant_credits(
381+
&self,
382+
user_id: UserId,
383+
amount_nano_usd: i64,
384+
reason: Option<String>,
385+
) -> Result<i64, SubscriptionError>;
368386
}
369387

370388
/// Summary of user's credits (balance, used, effective limit).

crates/services/src/subscription/service.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1683,4 +1683,47 @@ impl SubscriptionService for SubscriptionServiceImpl {
16831683
effective_max_credits,
16841684
})
16851685
}
1686+
1687+
async fn admin_grant_credits(
1688+
&self,
1689+
user_id: UserId,
1690+
amount_nano_usd: i64,
1691+
reason: Option<String>,
1692+
) -> Result<i64, SubscriptionError> {
1693+
if amount_nano_usd <= 0 {
1694+
return Err(SubscriptionError::InvalidCredits(
1695+
"grant amount must be positive".to_string(),
1696+
));
1697+
}
1698+
1699+
let mut client = self
1700+
.db_pool
1701+
.get()
1702+
.await
1703+
.map_err(|e| SubscriptionError::DatabaseError(e.to_string()))?;
1704+
let txn = client
1705+
.transaction()
1706+
.await
1707+
.map_err(|e| SubscriptionError::DatabaseError(e.to_string()))?;
1708+
1709+
self.credits_repo
1710+
.record_grant(&txn, user_id, amount_nano_usd, reason)
1711+
.await
1712+
.map_err(|e| SubscriptionError::DatabaseError(e.to_string()))?;
1713+
1714+
let new_balance = self
1715+
.credits_repo
1716+
.add_credits(&txn, user_id, amount_nano_usd)
1717+
.await
1718+
.map_err(|e| SubscriptionError::DatabaseError(e.to_string()))?;
1719+
1720+
txn.commit()
1721+
.await
1722+
.map_err(|e| SubscriptionError::DatabaseError(e.to_string()))?;
1723+
1724+
// Invalidate cache so future checks see updated purchased balance
1725+
self.invalidate_credit_limit_cache(user_id).await;
1726+
1727+
Ok(new_balance)
1728+
}
16861729
}

0 commit comments

Comments
 (0)