perfect-postcode/server-rs/src/routes/pricing.rs
2026-03-18 22:46:08 +00:00

107 lines
2.7 KiB
Rust

use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use tracing::warn;
use crate::pocketbase::get_superuser_token;
use crate::state::{AppState, SharedState};
/// Pricing tiers: (cumulative user cap, price in pence).
const TIERS: &[(u64, u64)] = &[
(1, 0), // First 10 users: free
(20, 1000), // Next 10: £10
(45, 2500), // Next 25: £25
(95, 5000), // Next 50: £50
];
const FINAL_PRICE_PENCE: u64 = 10000; // £100 after 95
#[derive(Serialize)]
pub struct Tier {
up_to: Option<u64>,
price_pence: u64,
slots: u64,
}
#[derive(Serialize)]
pub struct PricingResponse {
licensed_count: u64,
current_price_pence: u64,
tiers: Vec<Tier>,
}
/// Determine the price (in pence) for the next user given `count` existing licensed users.
pub fn price_for_count(count: u64) -> u64 {
for &(cap, price) in TIERS {
if count < cap {
return price;
}
}
FINAL_PRICE_PENCE
}
/// Count users with subscription="licensed" in PocketBase.
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = "subscription=\"licensed\"";
let url = format!(
"{pb_url}/api/collections/users/records?filter={}&perPage=1",
urlencoding::encode(filter)
);
let resp = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("PocketBase returned {}", resp.status());
}
let body: serde_json::Value = resp.json().await?;
let total = body["totalItems"].as_u64().unwrap_or(0);
Ok(total)
}
pub async fn get_pricing(State(shared): State<Arc<SharedState>>) -> Response {
let state = shared.load_state();
let count = match count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
warn!("Failed to count licensed users: {err}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
let current_price = price_for_count(count);
let mut tiers = Vec::new();
let mut prev_cap = 0u64;
for &(cap, price) in TIERS {
tiers.push(Tier {
up_to: Some(cap),
price_pence: price,
slots: cap - prev_cap,
});
prev_cap = cap;
}
tiers.push(Tier {
up_to: None,
price_pence: FINAL_PRICE_PENCE,
slots: 0,
});
Json(PricingResponse {
licensed_count: count,
current_price_pence: current_price,
tiers,
})
.into_response()
}