feat(billing): add billing_mode to providers (postpaid support) & UI/migration
This commit is contained in:
13
migrations/001-add-billing-mode.sql
Normal file
13
migrations/001-add-billing-mode.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration: add billing_mode to provider_configs
|
||||||
|
-- Adds a billing_mode TEXT column with default 'prepaid'
|
||||||
|
-- After applying, set Gemini to postpaid with:
|
||||||
|
-- UPDATE provider_configs SET billing_mode = 'postpaid' WHERE id = 'gemini';
|
||||||
|
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
ALTER TABLE provider_configs ADD COLUMN billing_mode TEXT DEFAULT 'prepaid';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- NOTE: If you use a production SQLite file, run the following to set Gemini to postpaid:
|
||||||
|
-- sqlite3 /path/to/db.sqlite "UPDATE provider_configs SET billing_mode='postpaid' WHERE id='gemini';"
|
||||||
@@ -17,6 +17,7 @@ pub(super) struct UpdateProviderRequest {
|
|||||||
pub(super) api_key: Option<String>,
|
pub(super) api_key: Option<String>,
|
||||||
pub(super) credit_balance: Option<f64>,
|
pub(super) credit_balance: Option<f64>,
|
||||||
pub(super) low_credit_threshold: Option<f64>,
|
pub(super) low_credit_threshold: Option<f64>,
|
||||||
|
pub(super) billing_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_get_providers(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
pub(super) async fn handle_get_providers(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
@@ -24,11 +25,12 @@ pub(super) async fn handle_get_providers(State(state): State<DashboardState>) ->
|
|||||||
let config = &state.app_state.config;
|
let config = &state.app_state.config;
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
// Load all overrides from database
|
// Load all overrides from database (including billing_mode)
|
||||||
let db_configs_result =
|
let db_configs_result = sqlx::query(
|
||||||
sqlx::query("SELECT id, enabled, base_url, credit_balance, low_credit_threshold FROM provider_configs")
|
"SELECT id, enabled, base_url, credit_balance, low_credit_threshold, billing_mode FROM provider_configs",
|
||||||
.fetch_all(pool)
|
)
|
||||||
.await;
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
let mut db_configs = HashMap::new();
|
let mut db_configs = HashMap::new();
|
||||||
if let Ok(rows) = db_configs_result {
|
if let Ok(rows) = db_configs_result {
|
||||||
@@ -38,7 +40,8 @@ pub(super) async fn handle_get_providers(State(state): State<DashboardState>) ->
|
|||||||
let base_url: Option<String> = row.get("base_url");
|
let base_url: Option<String> = row.get("base_url");
|
||||||
let balance: f64 = row.get("credit_balance");
|
let balance: f64 = row.get("credit_balance");
|
||||||
let threshold: f64 = row.get("low_credit_threshold");
|
let threshold: f64 = row.get("low_credit_threshold");
|
||||||
db_configs.insert(id, (enabled, base_url, balance, threshold));
|
let billing_mode: Option<String> = row.get("billing_mode");
|
||||||
|
db_configs.insert(id, (enabled, base_url, balance, threshold, billing_mode));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,15 +83,17 @@ pub(super) async fn handle_get_providers(State(state): State<DashboardState>) ->
|
|||||||
|
|
||||||
let mut balance = 0.0;
|
let mut balance = 0.0;
|
||||||
let mut threshold = 5.0;
|
let mut threshold = 5.0;
|
||||||
|
let mut billing_mode: Option<String> = None;
|
||||||
|
|
||||||
// Apply database overrides
|
// Apply database overrides
|
||||||
if let Some((db_enabled, db_url, db_balance, db_threshold)) = db_configs.get(id) {
|
if let Some((db_enabled, db_url, db_balance, db_threshold, db_billing)) = db_configs.get(id) {
|
||||||
enabled = *db_enabled;
|
enabled = *db_enabled;
|
||||||
if let Some(url) = db_url {
|
if let Some(url) = db_url {
|
||||||
base_url = url.clone();
|
base_url = url.clone();
|
||||||
}
|
}
|
||||||
balance = *db_balance;
|
balance = *db_balance;
|
||||||
threshold = *db_threshold;
|
threshold = *db_threshold;
|
||||||
|
billing_mode = db_billing.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find models for this provider in registry
|
// Find models for this provider in registry
|
||||||
@@ -138,6 +143,7 @@ pub(super) async fn handle_get_providers(State(state): State<DashboardState>) ->
|
|||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"credit_balance": balance,
|
"credit_balance": balance,
|
||||||
"low_credit_threshold": threshold,
|
"low_credit_threshold": threshold,
|
||||||
|
"billing_mode": billing_mode,
|
||||||
"last_used": None::<String>,
|
"last_used": None::<String>,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -185,10 +191,11 @@ pub(super) async fn handle_get_provider(
|
|||||||
|
|
||||||
let mut balance = 0.0;
|
let mut balance = 0.0;
|
||||||
let mut threshold = 5.0;
|
let mut threshold = 5.0;
|
||||||
|
let mut billing_mode: Option<String> = None;
|
||||||
|
|
||||||
// Apply database overrides
|
// Apply database overrides
|
||||||
let db_config = sqlx::query(
|
let db_config = sqlx::query(
|
||||||
"SELECT enabled, base_url, credit_balance, low_credit_threshold FROM provider_configs WHERE id = ?",
|
"SELECT enabled, base_url, credit_balance, low_credit_threshold, billing_mode FROM provider_configs WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -201,6 +208,7 @@ pub(super) async fn handle_get_provider(
|
|||||||
}
|
}
|
||||||
balance = row.get::<f64, _>("credit_balance");
|
balance = row.get::<f64, _>("credit_balance");
|
||||||
threshold = row.get::<f64, _>("low_credit_threshold");
|
threshold = row.get::<f64, _>("low_credit_threshold");
|
||||||
|
billing_mode = row.get::<Option<String>, _>("billing_mode");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find models for this provider
|
// Find models for this provider
|
||||||
@@ -246,6 +254,7 @@ pub(super) async fn handle_get_provider(
|
|||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"credit_balance": balance,
|
"credit_balance": balance,
|
||||||
"low_credit_threshold": threshold,
|
"low_credit_threshold": threshold,
|
||||||
|
"billing_mode": billing_mode,
|
||||||
"last_used": None::<String>,
|
"last_used": None::<String>,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -262,19 +271,20 @@ pub(super) async fn handle_update_provider(
|
|||||||
|
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
// Update or insert into database
|
// Update or insert into database (include billing_mode)
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key, credit_balance, low_credit_threshold)
|
INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key, credit_balance, low_credit_threshold, billing_mode)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
base_url = excluded.base_url,
|
base_url = excluded.base_url,
|
||||||
api_key = COALESCE(excluded.api_key, provider_configs.api_key),
|
api_key = COALESCE(excluded.api_key, provider_configs.api_key),
|
||||||
credit_balance = COALESCE(excluded.credit_balance, provider_configs.credit_balance),
|
credit_balance = COALESCE(excluded.credit_balance, provider_configs.credit_balance),
|
||||||
low_credit_threshold = COALESCE(excluded.low_credit_threshold, provider_configs.low_credit_threshold),
|
low_credit_threshold = COALESCE(excluded.low_credit_threshold, provider_configs.low_credit_threshold),
|
||||||
|
billing_mode = COALESCE(excluded.billing_mode, provider_configs.billing_mode),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(name.to_uppercase())
|
.bind(name.to_uppercase())
|
||||||
@@ -283,6 +293,7 @@ pub(super) async fn handle_update_provider(
|
|||||||
.bind(&payload.api_key)
|
.bind(&payload.api_key)
|
||||||
.bind(payload.credit_balance)
|
.bind(payload.credit_balance)
|
||||||
.bind(payload.low_credit_threshold)
|
.bind(payload.low_credit_threshold)
|
||||||
|
.bind(payload.billing_mode)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -100,13 +100,18 @@ impl RequestLogger {
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Deduct from provider balance if successful (skip postpaid like Gemini)
|
// Deduct from provider balance if successful.
|
||||||
if log.cost > 0.0 && log.provider != "gemini" {
|
// Providers configured with billing_mode = 'postpaid' will not have their
|
||||||
sqlx::query("UPDATE provider_configs SET credit_balance = credit_balance - ? WHERE id = ?")
|
// credit_balance decremented. Use a conditional UPDATE so we don't need
|
||||||
.bind(log.cost)
|
// a prior SELECT and avoid extra round-trips.
|
||||||
.bind(&log.provider)
|
if log.cost > 0.0 {
|
||||||
.execute(&mut *tx)
|
sqlx::query(
|
||||||
.await?;
|
"UPDATE provider_configs SET credit_balance = credit_balance - ? WHERE id = ? AND (billing_mode IS NULL OR billing_mode != 'postpaid')",
|
||||||
|
)
|
||||||
|
.bind(log.cost)
|
||||||
|
.bind(&log.provider)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ class ProvidersPage {
|
|||||||
<span>Balance: ${provider.id === 'ollama' ? 'FREE' : window.api.formatCurrency(provider.credit_balance)}</span>
|
<span>Balance: ${provider.id === 'ollama' ? 'FREE' : window.api.formatCurrency(provider.credit_balance)}</span>
|
||||||
${isLowBalance ? '<i class="fas fa-exclamation-triangle" title="Low Balance"></i>' : ''}
|
${isLowBalance ? '<i class="fas fa-exclamation-triangle" title="Low Balance"></i>' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
<span>Billing: ${provider.billing_mode ? provider.billing_mode.toUpperCase() : 'PREPAID'}</span>
|
||||||
|
</div>
|
||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<i class="fas fa-clock"></i>
|
<i class="fas fa-clock"></i>
|
||||||
<span>Last used: ${provider.last_used ? window.api.formatTimeAgo(provider.last_used) : 'Never'}</span>
|
<span>Last used: ${provider.last_used ? window.api.formatTimeAgo(provider.last_used) : 'Never'}</span>
|
||||||
@@ -163,16 +167,23 @@ class ProvidersPage {
|
|||||||
<label for="provider-api-key">API Key (Optional / Overwrite)</label>
|
<label for="provider-api-key">API Key (Optional / Overwrite)</label>
|
||||||
<input type="password" id="provider-api-key" placeholder="••••••••••••••••">
|
<input type="password" id="provider-api-key" placeholder="••••••••••••••••">
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label for="provider-balance">Current Credit Balance ($)</label>
|
<label for="provider-balance">Current Credit Balance ($)</label>
|
||||||
<input type="number" id="provider-balance" value="${provider.credit_balance}" step="0.01">
|
<input type="number" id="provider-balance" value="${provider.credit_balance}" step="0.01">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="provider-threshold">Low Balance Alert ($)</label>
|
||||||
|
<input type="number" id="provider-threshold" value="${provider.low_credit_threshold}" step="0.50">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label for="provider-threshold">Low Balance Alert ($)</label>
|
<label for="provider-billing-mode">Billing Mode</label>
|
||||||
<input type="number" id="provider-threshold" value="${provider.low_credit_threshold}" step="0.50">
|
<select id="provider-billing-mode">
|
||||||
|
<option value="prepaid" ${!provider.billing_mode || provider.billing_mode === 'prepaid' ? 'selected' : ''}>Prepaid</option>
|
||||||
|
<option value="postpaid" ${provider.billing_mode === 'postpaid' ? 'selected' : ''}>Postpaid</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
|
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||||
@@ -187,17 +198,19 @@ class ProvidersPage {
|
|||||||
const enabled = modal.querySelector('#provider-enabled').checked;
|
const enabled = modal.querySelector('#provider-enabled').checked;
|
||||||
const baseUrl = modal.querySelector('#provider-base-url').value;
|
const baseUrl = modal.querySelector('#provider-base-url').value;
|
||||||
const apiKey = modal.querySelector('#provider-api-key').value;
|
const apiKey = modal.querySelector('#provider-api-key').value;
|
||||||
const balance = parseFloat(modal.querySelector('#provider-balance').value);
|
const balance = parseFloat(modal.querySelector('#provider-balance').value);
|
||||||
const threshold = parseFloat(modal.querySelector('#provider-threshold').value);
|
const threshold = parseFloat(modal.querySelector('#provider-threshold').value);
|
||||||
|
const billingMode = modal.querySelector('#provider-billing-mode').value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.api.put(`/providers/${id}`, {
|
await window.api.put(`/providers/${id}`, {
|
||||||
enabled,
|
enabled,
|
||||||
base_url: baseUrl || null,
|
base_url: baseUrl || null,
|
||||||
api_key: apiKey || null,
|
api_key: apiKey || null,
|
||||||
credit_balance: isNaN(balance) ? null : balance,
|
credit_balance: isNaN(balance) ? null : balance,
|
||||||
low_credit_threshold: isNaN(threshold) ? null : threshold
|
low_credit_threshold: isNaN(threshold) ? null : threshold,
|
||||||
});
|
billing_mode: billingMode || null,
|
||||||
|
});
|
||||||
|
|
||||||
window.authManager.showToast(`${provider.name} configuration saved`, 'success');
|
window.authManager.showToast(`${provider.name} configuration saved`, 'success');
|
||||||
modal.remove();
|
modal.remove();
|
||||||
|
|||||||
Reference in New Issue
Block a user