diff --git a/libs/api/admin/billing.rs b/libs/api/admin/billing.rs index 084a76c..830cb74 100644 --- a/libs/api/admin/billing.rs +++ b/libs/api/admin/billing.rs @@ -60,7 +60,7 @@ pub async fn admin_workspace_add_credit( } let ws = service.utils_find_workspace_by_slug(slug.clone()).await?; - let billing = service.ensure_workspace_billing(ws.id).await?; + let billing = service.ensure_workspace_billing(ws.id, None).await?; let now_utc = Utc::now(); let new_balance = rust_decimal::Decimal::from_f64_retain( diff --git a/libs/migrate/lib.rs b/libs/migrate/lib.rs index ecc54dd..642e9ca 100644 --- a/libs/migrate/lib.rs +++ b/libs/migrate/lib.rs @@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*; mod m20260420_000003_add_model_id_to_room_message; pub mod m20260421_000001_add_agent_type_to_room_ai; +pub mod m20260426_000001_add_thinking_content_to_room_message; pub async fn execute_sql(manager: &SchemaManager<'_>, sql: &str) -> Result<(), DbErr> { for stmt in split_sql_statements(sql) { @@ -89,7 +90,7 @@ impl MigratorTrait for Migrator { Box::new(m20260420_000002_add_push_subscription::Migration), Box::new(m20260420_000003_add_model_id_to_room_message::Migration), Box::new(m20260421_000001_add_agent_type_to_room_ai::Migration), - Box::new(m20260420_000003_add_model_id_to_room_message::Migration), + Box::new(m20260426_000001_add_thinking_content_to_room_message::Migration), // Repo tables Box::new(m20250628_000028_create_repo::Migration), Box::new(m20250628_000029_create_repo_branch::Migration), diff --git a/libs/migrate/m20260426_000001_add_thinking_content_to_room_message.rs b/libs/migrate/m20260426_000001_add_thinking_content_to_room_message.rs new file mode 100644 index 0000000..c7e5b71 --- /dev/null +++ b/libs/migrate/m20260426_000001_add_thinking_content_to_room_message.rs @@ -0,0 +1,30 @@ +//! SeaORM migration: add thinking_content column to room_message + +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260426_000001_add_thinking_content_to_room_message" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260426_000001_add_thinking_content_to_room_message.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "ALTER TABLE room_message DROP COLUMN IF EXISTS thinking_content;", + )) + .await?; + Ok(()) + } +} diff --git a/libs/migrate/sql/m20260426_000001_add_thinking_content_to_room_message.sql b/libs/migrate/sql/m20260426_000001_add_thinking_content_to_room_message.sql new file mode 100644 index 0000000..12524e9 --- /dev/null +++ b/libs/migrate/sql/m20260426_000001_add_thinking_content_to_room_message.sql @@ -0,0 +1 @@ +ALTER TABLE room_message ADD COLUMN IF NOT EXISTS thinking_content TEXT; diff --git a/libs/models/rooms/room_message.rs b/libs/models/rooms/room_message.rs index ef1a90a..3d232ae 100644 --- a/libs/models/rooms/room_message.rs +++ b/libs/models/rooms/room_message.rs @@ -19,6 +19,8 @@ pub struct Model { pub in_reply_to: Option, pub content: String, pub content_type: MessageContentType, + /// Accumulated AI reasoning/thinking text. + pub thinking_content: Option, pub edited_at: Option, pub send_at: DateTimeUtc, pub revoked: Option, diff --git a/libs/service/project/billing.rs b/libs/service/project/billing.rs index 880d218..68a9956 100644 --- a/libs/service/project/billing.rs +++ b/libs/service/project/billing.rs @@ -149,9 +149,24 @@ impl AppService { } let now_utc = Utc::now(); + // Only first project per user gets initial budget ($10) + let initial_balance = if let Some(uid) = user_uid { + let existing_projects = models::projects::project::Entity::find() + .filter(models::projects::project::Column::CreatedBy.eq(uid)) + .all(&self.db) + .await?; + if existing_projects.is_empty() { + Decimal::from_f64_retain(DEFAULT_PROJECT_MONTHLY_CREDIT).unwrap_or(Decimal::ZERO) + } else { + Decimal::ZERO + } + } else { + Decimal::ZERO + }; + let created = project_billing::ActiveModel { project: Set(project_uid), - balance: Set(Decimal::from(DEFAULT_PROJECT_MONTHLY_CREDIT as i64)), + balance: Set(initial_balance), currency: Set("USD".to_string()), user: Set(user_uid), updated_at: Set(now_utc), diff --git a/libs/service/project/init.rs b/libs/service/project/init.rs index 2b64e8a..8c7dd77 100644 --- a/libs/service/project/init.rs +++ b/libs/service/project/init.rs @@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize}; use session::Session; use uuid::Uuid; +const DEFAULT_PROJECT_INITIAL_BALANCE: f64 = 10.0; + #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct ProjectInitParams { pub name: String, @@ -94,9 +96,20 @@ impl AppService { }; project_member.insert(&txn).await?; + // Only first project per user gets initial budget + let existing_projects = project::Entity::find() + .filter(project::Column::CreatedBy.eq(user.uid)) + .all(&self.db) + .await?; + let initial_balance = if existing_projects.is_empty() { + Decimal::from_f64_retain(DEFAULT_PROJECT_INITIAL_BALANCE).unwrap_or(Decimal::ZERO) + } else { + Decimal::ZERO + }; + let billing = project_billing::ActiveModel { project: Set(_project.id), - balance: Set(Decimal::from(200i64)), + balance: Set(initial_balance), currency: Set("USD".to_string()), user: Set(Some(user.uid)), updated_at: Set(Utc::now()), diff --git a/libs/service/workspace/billing.rs b/libs/service/workspace/billing.rs index 8bd5178..99ecce8 100644 --- a/libs/service/workspace/billing.rs +++ b/libs/service/workspace/billing.rs @@ -78,7 +78,7 @@ impl AppService { .await? .ok_or(AppError::NotWorkspaceMember)?; - let billing = self.ensure_workspace_billing(ws.id).await?; + let billing = self.ensure_workspace_billing(ws.id, Some(user_uid)).await?; let now_utc = Utc::now(); let (month_start, next_month_start) = utc_month_bounds(now_utc)?; @@ -132,7 +132,7 @@ impl AppService { let page = std::cmp::max(query.page.unwrap_or(1), 1); let per_page = query.per_page.unwrap_or(20).clamp(1, 200); - self.ensure_workspace_billing(ws.id).await?; + self.ensure_workspace_billing(ws.id, Some(user_uid)).await?; let paginator = workspace_billing_history::Entity::find() .filter(workspace_billing_history::Column::WorkspaceId.eq(ws.id)) @@ -186,7 +186,7 @@ impl AppService { return Err(AppError::BadRequest("Amount must be positive".to_string())); } - let billing = self.ensure_workspace_billing(ws.id).await?; + let billing = self.ensure_workspace_billing(ws.id, Some(user_uid)).await?; let now_utc = Utc::now(); let new_balance = Decimal::from_f64_retain(billing.balance.to_f64().unwrap_or_default() + params.amount) @@ -221,6 +221,7 @@ impl AppService { pub async fn ensure_workspace_billing( &self, workspace_id: Uuid, + user_uid: Option, ) -> Result { if let Some(billing) = workspace_billing::Entity::find_by_id(workspace_id) .one(&self.db) @@ -230,9 +231,25 @@ impl AppService { } let now_utc = Utc::now(); + // Only first workspace per user gets initial budget ($30) + let initial_balance = if let Some(uid) = user_uid { + let existing_workspaces = workspace_membership::Entity::find() + .filter(workspace_membership::Column::UserId.eq(uid)) + .filter(workspace_membership::Column::Status.eq("active")) + .all(&self.db) + .await?; + if existing_workspaces.len() <= 1 { + Decimal::from_f64_retain(30.0).unwrap_or(Decimal::ZERO) + } else { + Decimal::ZERO + } + } else { + Decimal::ZERO + }; + let created = workspace_billing::ActiveModel { workspace_id: Set(workspace_id), - balance: Set(Decimal::ZERO), + balance: Set(initial_balance), currency: Set("USD".to_string()), monthly_quota: Set( Decimal::from_f64_retain(DEFAULT_MONTHLY_QUOTA).unwrap_or(Decimal::ZERO) diff --git a/libs/service/workspace/init.rs b/libs/service/workspace/init.rs index 64993ce..ce91587 100644 --- a/libs/service/workspace/init.rs +++ b/libs/service/workspace/init.rs @@ -1,8 +1,10 @@ use crate::AppService; use crate::error::AppError; use chrono::Utc; +use models::Decimal; use models::WorkspaceRole; use models::workspaces::workspace; +use models::workspaces::workspace_billing; use models::workspaces::workspace_membership; use sea_orm::*; use serde::{Deserialize, Serialize}; @@ -89,6 +91,28 @@ impl AppService { }; membership.insert(&txn).await?; + // Create billing record — only first workspace gets $30 initial balance + let existing_workspaces = workspace_membership::Entity::find() + .filter(workspace_membership::Column::UserId.eq(user.uid)) + .filter(workspace_membership::Column::Status.eq("active")) + .all(&self.db) + .await?; + let initial_balance = if existing_workspaces.len() <= 1 { + Decimal::from_f64_retain(30.0).unwrap_or(Decimal::ZERO) + } else { + Decimal::ZERO + }; + let billing = workspace_billing::ActiveModel { + workspace_id: Set(ws.id), + balance: Set(initial_balance), + currency: Set("USD".to_string()), + monthly_quota: Set(Decimal::from_f64_retain(100.0).unwrap_or(Decimal::ZERO)), + total_spent: Set(Decimal::ZERO), + updated_at: Set(Utc::now()), + created_at: Set(Utc::now()), + }; + billing.insert(&txn).await?; + txn.commit().await?; Ok(ws) } diff --git a/src/lib/ws-protocol.ts b/src/lib/ws-protocol.ts index 16d1d7e..37dd712 100644 --- a/src/lib/ws-protocol.ts +++ b/src/lib/ws-protocol.ts @@ -193,6 +193,8 @@ export interface RoomMessagePayload { thread_id?: string; content: string; content_type: string; + /** Accumulated AI reasoning/thinking text. */ + thinking_content?: string; send_at: string; seq: number; display_name?: string;