lmao
This commit is contained in:
parent
03445188ea
commit
524580eb25
102 changed files with 36625 additions and 1295 deletions
178
server-rs/src/routes/checkout.rs
Normal file
178
server-rs/src/routes/checkout.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::pricing::{count_licensed_users, price_for_count};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckoutRequest {
|
||||
referral_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CheckoutResponse {
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Create a Stripe Checkout session for the lifetime license (or grant for free if in free tier).
|
||||
/// Requires authentication. Optionally accepts a referral code to apply a coupon.
|
||||
pub async fn post_checkout(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Json(req): Json<CheckoutRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
let count = match count_licensed_users(&state).await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
warn!("Failed to count licensed users at checkout: {err}");
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let price_pence = price_for_count(count);
|
||||
let public_url = &state.public_url;
|
||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
||||
|
||||
// Free tier — grant license directly without Stripe
|
||||
if price_pence == 0 {
|
||||
if let Err(err) = grant_license(&state, &user.id).await {
|
||||
warn!(user_id = %user.id, "Failed to grant free license: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
info!(user_id = %user.id, "Granted free early-bird license");
|
||||
return Json(CheckoutResponse { url: success_url }).into_response();
|
||||
}
|
||||
|
||||
// Paid tier — create Stripe checkout with dynamic price
|
||||
let secret_key = &state.stripe_secret_key;
|
||||
let cancel_url = format!("{public_url}/pricing");
|
||||
|
||||
let mut form_params = vec![
|
||||
("mode", "payment".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][unit_amount]",
|
||||
price_pence.to_string(),
|
||||
),
|
||||
("line_items[0][price_data][currency]", "gbp".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][product_data][name]",
|
||||
"Perfect Postcodes Lifetime License".to_string(),
|
||||
),
|
||||
("line_items[0][quantity]", "1".to_string()),
|
||||
("success_url", success_url),
|
||||
("cancel_url", cancel_url),
|
||||
("client_reference_id", user.id.clone()),
|
||||
("customer_email", user.email.clone()),
|
||||
];
|
||||
|
||||
// If a referral code is provided and valid, look it up and apply the coupon
|
||||
if let Some(ref code) = req.referral_code {
|
||||
if validate_referral_invite(&state, code).await {
|
||||
form_params.push(("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()));
|
||||
info!(code = %code, "Applying referral coupon to checkout");
|
||||
}
|
||||
}
|
||||
|
||||
let res = state
|
||||
.http_client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.basic_auth(secret_key, None::<&str>)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse Stripe response: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
let url = body["url"].as_str().unwrap_or_default().to_string();
|
||||
if url.is_empty() {
|
||||
warn!("Stripe session missing URL");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
info!(user_id = %user.id, price_pence, "Created Stripe checkout session");
|
||||
Json(CheckoutResponse { url }).into_response()
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("Stripe checkout failed ({status}): {text}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Stripe request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grant a license by updating the user's subscription to "licensed" in PocketBase.
|
||||
async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
|
||||
|
||||
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
|
||||
let resp = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("PocketBase update failed ({status}): {text}");
|
||||
}
|
||||
|
||||
state.token_cache.invalidate_by_user_id(user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a referral invite code exists and is unused.
|
||||
async fn validate_referral_invite(state: &AppState, code: &str) -> bool {
|
||||
// Only allow alphanumeric codes to prevent PocketBase filter injection
|
||||
if code.is_empty()
|
||||
|| code.len() > 20
|
||||
|| !code.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!(
|
||||
"code=\"{}\" && invite_type=\"referral\" && used_by_id=\"\"",
|
||||
code
|
||||
);
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
match state.http_client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
body["totalItems"].as_u64().unwrap_or(0) > 0
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue