feat: rewrite website API in Rust — axum, sqlx, Stripe, lettre, argon2, MariaDB

This commit is contained in:
NixReaper
2026-04-23 17:28:37 -05:00
parent faf0818dde
commit d0424577a9
21 changed files with 1451 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Server
HOST=0.0.0.0
PORT=4000
RUST_LOG=nixpanel_website_api=info,tower_http=info
# Database
DATABASE_URL=mysql://nixpanel_web:[email protected]:3306/nixpanel_website
# JWT — generate: openssl rand -hex 64
JWT_SECRET=changeme_use_openssl_rand_hex_64
JWT_EXPIRES_IN_SECS=604800
# App
APP_URL=https://nixpanel.io
CORS_ORIGIN=https://nixpanel.io
# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_SOLO_MONTHLY=price_...
STRIPE_PRICE_SOLO_ANNUAL=price_...
STRIPE_PRICE_HOST_MONTHLY=price_...
STRIPE_PRICE_HOST_ANNUAL=price_...
STRIPE_PRICE_AGENCY_MONTHLY=price_...
STRIPE_PRICE_AGENCY_ANNUAL=price_...
# Internal licensing server
LICENSING_SERVER_URL=http://127.0.0.1:3000
LICENSING_SERVER_API_KEY=your_internal_api_key_here
# SMTP
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
MAIL_USER=[email protected]
MAIL_PASSWORD=your_smtp_password
MAIL_FROM=[email protected]
+3
View File
@@ -0,0 +1,3 @@
target/
.env
*.log
+68
View File
@@ -0,0 +1,68 @@
[package]
name = "nixpanel-website-api"
version = "0.1.0"
edition = "2021"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# Web framework
axum = { version = "0.7", features = ["macros", "multipart"] }
tower = { version = "0.4" }
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] }
# Database
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "mysql", "uuid", "chrono", "migrate"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Auth
jsonwebtoken = "9"
argon2 = "0.5"
# IDs / time
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# Config / env
dotenvy = "0.15"
# HTTP client (for calling licensing server + Stripe)
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
# Stripe
async-stripe = { version = "0.37", default-features = false, features = [
"runtime-tokio-hyper-rustls",
"checkout",
"billing",
"webhook",
] }
# Email
lettre = { version = "0.11", default-features = false, features = [
"smtp-transport",
"tokio1-rustls-tls",
"builder",
] }
# Error handling
thiserror = "1"
anyhow = "1"
# Crypto
sha2 = "0.10"
hex = "0.4"
rand = "0.8"
# Logging
tracing = "1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS customers (
id VARCHAR(36) NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
is_verified TINYINT(1) NOT NULL DEFAULT 0,
verify_token VARCHAR(128) NULL,
reset_token VARCHAR(128) NULL,
reset_token_expires DATETIME NULL,
stripe_customer_id VARCHAR(100) NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_stripe_customer (stripe_customer_id)
);
CREATE TABLE IF NOT EXISTS subscriptions (
id VARCHAR(36) NOT NULL PRIMARY KEY,
customer_id VARCHAR(36) NOT NULL,
license_id VARCHAR(36) NOT NULL,
stripe_subscription_id VARCHAR(100) NOT NULL UNIQUE,
stripe_price_id VARCHAR(100) NOT NULL,
plan_key VARCHAR(20) NOT NULL DEFAULT 'free',
billing_interval VARCHAR(10) NOT NULL DEFAULT 'monthly',
status VARCHAR(20) NOT NULL DEFAULT 'active',
server_ip VARCHAR(45) NOT NULL,
current_period_start DATETIME NOT NULL,
current_period_end DATETIME NOT NULL,
cancel_at_period_end TINYINT(1) NOT NULL DEFAULT 0,
canceled_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
INDEX idx_customer_id (customer_id),
INDEX idx_license_id (license_id),
INDEX idx_stripe_sub (stripe_subscription_id),
INDEX idx_server_ip (server_ip)
);
CREATE TABLE IF NOT EXISTS ip_change_requests (
id VARCHAR(36) NOT NULL PRIMARY KEY,
customer_id VARCHAR(36) NOT NULL,
subscription_id VARCHAR(36) NOT NULL,
old_ip VARCHAR(45) NOT NULL,
new_ip VARCHAR(45) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'pending',
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
INDEX idx_customer_id (customer_id)
);
+96
View File
@@ -0,0 +1,96 @@
use anyhow::{Context, Result};
use std::env;
#[derive(Clone, Debug)]
pub struct Config {
pub host: String,
pub port: u16,
pub database_url: String,
pub jwt_secret: String,
pub jwt_expires_in_secs: u64,
pub app_url: String,
pub cors_origin: String,
pub stripe: StripeConfig,
pub licensing: LicensingConfig,
pub mail: MailConfig,
}
#[derive(Clone, Debug)]
pub struct StripeConfig {
pub secret_key: String,
pub webhook_secret: String,
pub price_solo_monthly: String,
pub price_solo_annual: String,
pub price_host_monthly: String,
pub price_host_annual: String,
pub price_agency_monthly: String,
pub price_agency_annual: String,
}
#[derive(Clone, Debug)]
pub struct LicensingConfig {
pub url: String,
pub api_key: String,
}
#[derive(Clone, Debug)]
pub struct MailConfig {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub from: String,
}
impl Config {
pub fn from_env() -> Result<Self> {
dotenvy::dotenv().ok();
Ok(Self {
host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()),
port: env::var("PORT").unwrap_or_else(|_| "4000".into()).parse()
.context("PORT must be a number")?,
database_url: env::var("DATABASE_URL").context("DATABASE_URL required")?,
jwt_secret: env::var("JWT_SECRET").context("JWT_SECRET required")?,
jwt_expires_in_secs: env::var("JWT_EXPIRES_IN_SECS")
.unwrap_or_else(|_| "604800".into()).parse()
.context("JWT_EXPIRES_IN_SECS must be a number")?,
app_url: env::var("APP_URL").unwrap_or_else(|_| "https://nixpanel.io".into()),
cors_origin: env::var("CORS_ORIGIN").unwrap_or_else(|_| "https://nixpanel.io".into()),
stripe: StripeConfig {
secret_key: env::var("STRIPE_SECRET_KEY").context("STRIPE_SECRET_KEY required")?,
webhook_secret: env::var("STRIPE_WEBHOOK_SECRET").context("STRIPE_WEBHOOK_SECRET required")?,
price_solo_monthly: env::var("STRIPE_PRICE_SOLO_MONTHLY").unwrap_or_default(),
price_solo_annual: env::var("STRIPE_PRICE_SOLO_ANNUAL").unwrap_or_default(),
price_host_monthly: env::var("STRIPE_PRICE_HOST_MONTHLY").unwrap_or_default(),
price_host_annual: env::var("STRIPE_PRICE_HOST_ANNUAL").unwrap_or_default(),
price_agency_monthly: env::var("STRIPE_PRICE_AGENCY_MONTHLY").unwrap_or_default(),
price_agency_annual: env::var("STRIPE_PRICE_AGENCY_ANNUAL").unwrap_or_default(),
},
licensing: LicensingConfig {
url: env::var("LICENSING_SERVER_URL").context("LICENSING_SERVER_URL required")?,
api_key: env::var("LICENSING_SERVER_API_KEY").context("LICENSING_SERVER_API_KEY required")?,
},
mail: MailConfig {
host: env::var("MAIL_HOST").unwrap_or_else(|_| "smtp.mailgun.org".into()),
port: env::var("MAIL_PORT").unwrap_or_else(|_| "587".into()).parse().unwrap_or(587),
username: env::var("MAIL_USER").unwrap_or_default(),
password: env::var("MAIL_PASSWORD").unwrap_or_default(),
from: env::var("MAIL_FROM").unwrap_or_else(|_| "[email protected]".into()),
},
})
}
/// Returns the Stripe price ID for a given plan key + interval
pub fn stripe_price_id(&self, plan_key: &str, interval: &str) -> Option<&str> {
match (plan_key, interval) {
("solo", "monthly") => Some(&self.stripe.price_solo_monthly),
("solo", "annual") => Some(&self.stripe.price_solo_annual),
("host", "monthly") => Some(&self.stripe.price_host_monthly),
("host", "annual") => Some(&self.stripe.price_host_annual),
("agency", "monthly") => Some(&self.stripe.price_agency_monthly),
("agency", "annual") => Some(&self.stripe.price_agency_annual),
_ => None,
}
.filter(|s| !s.is_empty())
}
}
+16
View File
@@ -0,0 +1,16 @@
use anyhow::Result;
use sqlx::mysql::MySqlPoolOptions;
use sqlx::MySqlPool;
use crate::config::Config;
pub async fn create_pool(cfg: &Config) -> Result<MySqlPool> {
let pool = MySqlPoolOptions::new()
.max_connections(20)
.connect(&cfg.database_url)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
+44
View File
@@ -0,0 +1,44 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("Not found")]
NotFound,
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden")]
Forbidden,
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m.clone()),
AppError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden".into()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
AppError::Database(e) => {
tracing::error!("DB: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "Database error".into())
}
AppError::Internal(e) => {
tracing::error!("Internal: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
+40
View File
@@ -0,0 +1,40 @@
mod config;
mod db;
mod error;
mod middleware;
mod models;
mod routes;
mod services;
mod state;
use anyhow::Result;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "nixpanel_website_api=info,tower_http=info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let cfg = config::Config::from_env()?;
let pool = db::create_pool(&cfg).await?;
tracing::info!("Database connected and migrations applied");
let state = state::AppState::new(pool, cfg.clone());
let app = routes::create_router(state)
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());
let addr = format!("{}:{}", cfg.host, cfg.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("NixPanel Website API listening on {addr}");
axum::serve(listener, app).await?;
Ok(())
}
+34
View File
@@ -0,0 +1,34 @@
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::{models::CustomerClaims, services::auth::verify_jwt, state::AppState};
pub struct AuthCustomer(pub CustomerClaims);
impl FromRequestParts<AppState> for AuthCustomer {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let token = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
(StatusCode::UNAUTHORIZED, Json(json!({"error": "Authentication required"})))
.into_response()
})?;
let claims = verify_jwt(&state.config, token).map_err(|_| {
(StatusCode::UNAUTHORIZED, Json(json!({"error": "Invalid or expired session"})))
.into_response()
})?;
Ok(AuthCustomer(claims))
}
}
+1
View File
@@ -0,0 +1 @@
pub mod auth;
+46
View File
@@ -0,0 +1,46 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Customer {
pub id: String,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub full_name: String,
pub is_verified: bool,
#[serde(skip_serializing)]
pub verify_token: Option<String>,
#[serde(skip_serializing)]
pub reset_token: Option<String>,
#[serde(skip_serializing)]
pub reset_token_expires: Option<NaiveDateTime>,
pub stripe_customer_id: Option<String>,
pub created_at: NaiveDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Subscription {
pub id: String,
pub customer_id: String,
pub license_id: String,
pub stripe_subscription_id: String,
pub stripe_price_id: String,
pub plan_key: String,
pub billing_interval: String,
pub status: String,
pub server_ip: String,
pub current_period_start: NaiveDateTime,
pub current_period_end: NaiveDateTime,
pub cancel_at_period_end: bool,
pub canceled_at: Option<NaiveDateTime>,
pub created_at: NaiveDateTime,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CustomerClaims {
pub sub: String,
pub email: String,
pub exp: usize,
}
+111
View File
@@ -0,0 +1,111 @@
use axum::{extract::State, http::StatusCode, Json};
use serde::Deserialize;
use crate::{
error::AppResult,
middleware::auth::AuthCustomer,
services::{auth, mail},
state::AppState,
};
#[derive(Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
#[serde(rename = "fullName")]
pub full_name: String,
}
#[derive(Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
// POST /api/v1/auth/register
pub async fn register(
State(state): State<AppState>,
Json(body): Json<RegisterRequest>,
) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
if body.password.len() < 8 {
return Err(crate::error::AppError::BadRequest("Password must be at least 8 characters".into()));
}
let (customer, token) = auth::register(&state.pool, &body.email, &body.password, &body.full_name).await?;
// Non-fatal — log if email fails
if let Err(e) = mail::send_verification(&state.config.mail, &state.config.app_url, &customer.email, &token).await {
tracing::warn!("Failed to send verification email: {e}");
}
Ok((StatusCode::CREATED, Json(serde_json::json!({
"message": "Account created. Check your email to verify your account.",
"id": customer.id,
}))))
}
// POST /api/v1/auth/login
pub async fn login(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> AppResult<Json<serde_json::Value>> {
let token = auth::login(&state.pool, &state.config, &body.email, &body.password).await?;
Ok(Json(serde_json::json!({ "token": token })))
}
// GET /api/v1/auth/me
pub async fn me(
State(state): State<AppState>,
AuthCustomer(claims): AuthCustomer,
) -> AppResult<Json<serde_json::Value>> {
let customer = auth::get_by_id(&state.pool, &claims.sub)
.await?
.ok_or(crate::error::AppError::NotFound)?;
Ok(Json(serde_json::to_value(&customer).unwrap()))
}
// GET /api/v1/auth/verify-email?token=...
pub async fn verify_email(
State(state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> AppResult<Json<serde_json::Value>> {
let token = params.get("token").ok_or_else(|| crate::error::AppError::BadRequest("Missing token".into()))?;
let ok = auth::verify_email(&state.pool, token).await?;
if !ok {
return Err(crate::error::AppError::BadRequest("Invalid or expired verification link".into()));
}
Ok(Json(serde_json::json!({ "message": "Email verified. You can now log in." })))
}
// POST /api/v1/auth/forgot-password
pub async fn forgot_password(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<serde_json::Value>> {
if let Some(email) = body["email"].as_str() {
if let Ok(Some(token)) = auth::initiate_reset(&state.pool, email).await {
let _ = mail::send_password_reset(&state.config.mail, &state.config.app_url, email, &token).await;
}
}
// Always return success to prevent email enumeration
Ok(Json(serde_json::json!({ "message": "If that email exists, a reset link has been sent." })))
}
// POST /api/v1/auth/reset-password
pub async fn reset_password(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<serde_json::Value>> {
let token = body["token"].as_str().ok_or_else(|| crate::error::AppError::BadRequest("Missing token".into()))?;
let password = body["password"].as_str().ok_or_else(|| crate::error::AppError::BadRequest("Missing password".into()))?;
if password.len() < 8 {
return Err(crate::error::AppError::BadRequest("Password must be at least 8 characters".into()));
}
let ok = auth::reset_password(&state.pool, token, password).await?;
if !ok {
return Err(crate::error::AppError::BadRequest("Invalid or expired reset token".into()));
}
Ok(Json(serde_json::json!({ "message": "Password updated. You can now log in." })))
}
+298
View File
@@ -0,0 +1,298 @@
use axum::{body::Bytes, extract::State, http::{HeaderMap, StatusCode}, Json};
use serde::Deserialize;
use uuid::Uuid;
use crate::{
error::{AppError, AppResult},
middleware::auth::AuthCustomer,
services::{auth::get_by_id, licensing, mail, stripe as stripe_svc},
state::AppState,
};
const PLAN_ID_FREE: &str = "00000000-0000-0000-0000-000000000001";
const PLAN_ID_SOLO: &str = "00000000-0000-0000-0000-000000000002";
const PLAN_ID_HOST: &str = "00000000-0000-0000-0000-000000000003";
const PLAN_ID_AGENCY: &str = "00000000-0000-0000-0000-000000000004";
fn plan_id(key: &str) -> Option<&'static str> {
match key {
"free" => Some(PLAN_ID_FREE),
"solo" => Some(PLAN_ID_SOLO),
"host" => Some(PLAN_ID_HOST),
"agency" => Some(PLAN_ID_AGENCY),
_ => None,
}
}
#[derive(Deserialize)]
pub struct CheckoutRequest {
#[serde(rename = "planKey")] pub plan_key: String,
#[serde(rename = "interval")] pub interval: String,
#[serde(rename = "serverIp")] pub server_ip: String,
}
#[derive(Deserialize)]
pub struct ChangePlanRequest {
#[serde(rename = "planKey")] pub plan_key: String,
#[serde(rename = "interval")] pub interval: String,
}
// GET /api/v1/billing/plans — public
pub async fn plans() -> Json<serde_json::Value> {
Json(serde_json::json!({
"free": { "monthly": 0.00, "annual": 0.00, "label": "Free" },
"solo": { "monthly": 9.99, "annual": 95.90, "label": "Solo" },
"host": { "monthly": 29.99, "annual": 287.90, "label": "Host" },
"agency": { "monthly": 79.99, "annual": 767.90, "label": "Agency" },
}))
}
// POST /api/v1/billing/checkout
pub async fn checkout(
State(state): State<AppState>,
AuthCustomer(claims): AuthCustomer,
Json(body): Json<CheckoutRequest>,
) -> AppResult<Json<serde_json::Value>> {
let customer = get_by_id(&state.pool, &claims.sub).await?.ok_or(AppError::NotFound)?;
let price_id = state.config.stripe_price_id(&body.plan_key, &body.interval)
.ok_or_else(|| AppError::BadRequest("Invalid plan or interval".into()))?
.to_string();
// Ensure Stripe customer exists
let stripe_cid = ensure_stripe_customer(&state, &customer).await?;
let url = stripe_svc::create_checkout_session(
&state.config.stripe,
&stripe_cid,
&price_id,
&body.plan_key,
&body.interval,
&body.server_ip,
&claims.sub,
&format!("{}/dashboard?success=1", state.config.app_url),
&format!("{}/pricing", state.config.app_url),
).await.map_err(|e| AppError::Internal(e))?;
Ok(Json(serde_json::json!({ "url": url })))
}
// POST /api/v1/billing/portal
pub async fn portal(
State(state): State<AppState>,
AuthCustomer(claims): AuthCustomer,
) -> AppResult<Json<serde_json::Value>> {
let customer = get_by_id(&state.pool, &claims.sub).await?.ok_or(AppError::NotFound)?;
let stripe_cid = customer.stripe_customer_id
.ok_or_else(|| AppError::BadRequest("No billing account found".into()))?;
let url = stripe_svc::create_portal_session(
&state.config.stripe,
&stripe_cid,
&format!("{}/dashboard", state.config.app_url),
).await.map_err(|e| AppError::Internal(e))?;
Ok(Json(serde_json::json!({ "url": url })))
}
// POST /api/v1/billing/cancel
pub async fn cancel(
State(state): State<AppState>,
AuthCustomer(claims): AuthCustomer,
) -> AppResult<Json<serde_json::Value>> {
let sub = active_subscription(&state, &claims.sub).await?
.ok_or_else(|| AppError::NotFound)?;
stripe_svc::cancel_at_period_end(&state.config.stripe, &sub.stripe_subscription_id)
.await.map_err(|e| AppError::Internal(e))?;
sqlx::query!(
"UPDATE subscriptions SET cancel_at_period_end = 1 WHERE id = ?", sub.id
).execute(&state.pool).await?;
Ok(Json(serde_json::json!({ "message": "Subscription will cancel at end of billing period." })))
}
// POST /api/v1/billing/change-plan
pub async fn change_plan(
State(state): State<AppState>,
AuthCustomer(claims): AuthCustomer,
Json(body): Json<ChangePlanRequest>,
) -> AppResult<Json<serde_json::Value>> {
let sub = active_subscription(&state, &claims.sub).await?
.ok_or_else(|| AppError::NotFound)?;
let price_id = state.config.stripe_price_id(&body.plan_key, &body.interval)
.ok_or_else(|| AppError::BadRequest("Invalid plan or interval".into()))?
.to_string();
stripe_svc::change_plan(&state.config.stripe, &sub.stripe_subscription_id, &price_id)
.await.map_err(|e| AppError::Internal(e))?;
sqlx::query!(
"UPDATE subscriptions SET plan_key = ?, billing_interval = ? WHERE id = ?",
body.plan_key, body.interval, sub.id
).execute(&state.pool).await?;
Ok(Json(serde_json::json!({ "message": "Plan updated." })))
}
// GET /api/v1/billing/subscription
pub async fn subscription(
State(state): State<AppState>,
AuthCustomer(claims): AuthCustomer,
) -> AppResult<Json<serde_json::Value>> {
let sub = active_subscription(&state, &claims.sub).await?;
Ok(Json(serde_json::to_value(&sub).unwrap()))
}
// POST /api/v1/billing/webhook (raw body — no auth, verified by Stripe signature)
pub async fn webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Json<serde_json::Value>> {
let sig = headers.get("stripe-signature")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::BadRequest("Missing Stripe signature".into()))?;
let event = stripe_svc::construct_webhook_event(&body, sig, &state.config.stripe.webhook_secret)
.map_err(|_| AppError::BadRequest("Invalid webhook signature".into()))?;
match event.type_ {
stripe::EventType::CheckoutSessionCompleted => {
if let stripe::EventObject::CheckoutSession(session) = event.data.object {
handle_checkout_completed(&state, session).await?;
}
}
stripe::EventType::CustomerSubscriptionUpdated => {
if let stripe::EventObject::Subscription(sub) = event.data.object {
handle_subscription_updated(&state, sub).await?;
}
}
stripe::EventType::CustomerSubscriptionDeleted => {
if let stripe::EventObject::Subscription(sub) = event.data.object {
handle_subscription_deleted(&state, sub).await?;
}
}
_ => {}
}
Ok(Json(serde_json::json!({ "received": true })))
}
// ── Webhook handlers ──────────────────────────────────────────────────────────
async fn handle_checkout_completed(
state: &AppState,
session: stripe::CheckoutSession,
) -> AppResult<()> {
let meta = session.metadata.unwrap_or_default();
let customer_id = meta.get("nixpanel_customer_id").cloned().unwrap_or_default();
let plan_key = meta.get("plan_key").cloned().unwrap_or_default();
let interval = meta.get("billing_interval").cloned().unwrap_or_default();
let server_ip = meta.get("server_ip").cloned().unwrap_or_default();
let stripe_sub = session.subscription.map(|s| s.id().to_string()).unwrap_or_default();
let customer = get_by_id(&state.pool, &customer_id).await?.ok_or(AppError::NotFound)?;
let pid = plan_id(&plan_key).ok_or_else(|| AppError::BadRequest("Unknown plan".into()))?;
let license = licensing::create_license(
&state.config.licensing,
pid,
&customer.email,
&customer.full_name,
&server_ip,
).await.map_err(|e| AppError::Internal(e))?;
let license_id = license["id"].as_str().unwrap_or("").to_string();
let sub_id = Uuid::new_v4().to_string();
sqlx::query!(
r#"INSERT INTO subscriptions
(id, customer_id, license_id, stripe_subscription_id, stripe_price_id,
plan_key, billing_interval, status, server_ip,
current_period_start, current_period_end)
VALUES (?, ?, ?, ?, '', ?, ?, 'active', ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 MONTH))"#,
sub_id, customer_id, license_id, stripe_sub,
plan_key, interval, server_ip
).execute(&state.pool).await?;
let _ = mail::send_license_confirmation(
&state.config.mail,
&state.config.app_url,
&customer.email,
&plan_key,
&server_ip,
&interval,
).await;
Ok(())
}
async fn handle_subscription_updated(state: &AppState, sub: stripe::Subscription) -> AppResult<()> {
sqlx::query!(
r#"UPDATE subscriptions
SET status = ?,
current_period_start = ?,
current_period_end = ?,
cancel_at_period_end = ?
WHERE stripe_subscription_id = ?"#,
sub.status.to_string(),
chrono::DateTime::from_timestamp(sub.current_period_start, 0).unwrap().naive_utc(),
chrono::DateTime::from_timestamp(sub.current_period_end, 0).unwrap().naive_utc(),
sub.cancel_at_period_end,
sub.id.to_string(),
).execute(&state.pool).await?;
Ok(())
}
async fn handle_subscription_deleted(state: &AppState, sub: stripe::Subscription) -> AppResult<()> {
let row = sqlx::query!(
"SELECT license_id FROM subscriptions WHERE stripe_subscription_id = ?",
sub.id.to_string()
).fetch_optional(&state.pool).await?;
if let Some(r) = row {
sqlx::query!(
"UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE stripe_subscription_id = ?",
sub.id.to_string()
).execute(&state.pool).await?;
let _ = licensing::revoke_license(&state.config.licensing, &r.license_id).await;
}
Ok(())
}
// ── Helpers ───────────────────────────────────────────────────────────────────
async fn active_subscription(state: &AppState, customer_id: &str) -> AppResult<Option<crate::models::Subscription>> {
Ok(sqlx::query_as!(
crate::models::Subscription,
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
customer_id
).fetch_optional(&state.pool).await?)
}
async fn ensure_stripe_customer(
state: &AppState,
customer: &crate::models::Customer,
) -> AppResult<String> {
if let Some(ref id) = customer.stripe_customer_id {
return Ok(id.clone());
}
use stripe::{Client, CreateCustomer, Customer as StripeCustomer};
let stripe_client = stripe::Client::new(&state.config.stripe.secret_key);
let mut params = CreateCustomer::new();
params.email = Some(&customer.email);
params.name = Some(&customer.full_name);
let sc = StripeCustomer::create(&stripe_client, params)
.await.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?;
let sid = sc.id.to_string();
sqlx::query!("UPDATE customers SET stripe_customer_id = ? WHERE id = ?", sid, customer.id)
.execute(&state.pool).await?;
Ok(sid)
}
+126
View File
@@ -0,0 +1,126 @@
use axum::{extract::State, Json};
use serde::Deserialize;
use crate::{
error::{AppError, AppResult},
middleware::auth::AuthCustomer,
services::licensing,
state::AppState,
};
#[derive(Deserialize)]
pub struct ChangeIpRequest {
#[serde(rename = "newIp")]
pub new_ip: String,
}
// GET /api/v1/license
pub async fn get(
State(state): State<AppState>,
AuthCustomer(claims): AuthCustomer,
) -> AppResult<Json<serde_json::Value>> {
let sub = sqlx::query!(
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
claims.sub
).fetch_optional(&state.pool).await?;
let Some(sub) = sub else { return Ok(Json(serde_json::Value::Null)) };
let license = licensing::get_license(&state.config.licensing, &sub.license_id)
.await.map_err(|e| AppError::Internal(e))?;
Ok(Json(serde_json::json!({
"license": license,
"subscription": {
"id": sub.id,
"plan_key": sub.plan_key,
"billing_interval": sub.billing_interval,
"status": sub.status,
"server_ip": sub.server_ip,
"current_period_end": sub.current_period_end.to_string(),
"cancel_at_period_end": sub.cancel_at_period_end,
}
})))
}
// POST /api/v1/license/change-ip
pub async fn change_ip(
State(state): State<AppState>,
AuthCustomer(claims): AuthCustomer,
Json(body): Json<ChangeIpRequest>,
) -> AppResult<Json<serde_json::Value>> {
// Validate IPv4 format
if !is_valid_ipv4(&body.new_ip) {
return Err(AppError::BadRequest("Invalid IPv4 address".into()));
}
let sub = sqlx::query!(
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
claims.sub
).fetch_optional(&state.pool).await?
.ok_or(AppError::NotFound)?;
if sub.server_ip == body.new_ip {
return Err(AppError::BadRequest("New IP is the same as current IP".into()));
}
// Check if new IP is already bound to another active subscription
let conflict = sqlx::query!(
"SELECT id FROM subscriptions WHERE server_ip = ? AND status = 'active' AND id != ?",
body.new_ip, sub.id
).fetch_optional(&state.pool).await?;
if conflict.is_some() {
return Err(AppError::Conflict("That IP address is already bound to another license".into()));
}
// Log IP change
let req_id = uuid::Uuid::new_v4().to_string();
sqlx::query!(
r#"INSERT INTO ip_change_requests
(id, customer_id, subscription_id, old_ip, new_ip, status, processed_at)
VALUES (?, ?, ?, ?, ?, 'approved', NOW())"#,
req_id, claims.sub, sub.id, sub.server_ip, body.new_ip
).execute(&state.pool).await?;
// Push to licensing server
licensing::update_server_ip(&state.config.licensing, &sub.license_id, &body.new_ip)
.await.map_err(|e| AppError::Internal(e))?;
// Update local record
sqlx::query!(
"UPDATE subscriptions SET server_ip = ? WHERE id = ?",
body.new_ip, sub.id
).execute(&state.pool).await?;
Ok(Json(serde_json::json!({
"message": "IP address updated successfully.",
"newIp": body.new_ip,
})))
}
// GET /api/v1/license/ip-history
pub async fn ip_history(
State(state): State<AppState>,
AuthCustomer(claims): AuthCustomer,
) -> AppResult<Json<serde_json::Value>> {
let rows = sqlx::query!(
"SELECT * FROM ip_change_requests WHERE customer_id = ? ORDER BY requested_at DESC",
claims.sub
).fetch_all(&state.pool).await?;
let result: Vec<serde_json::Value> = rows.into_iter().map(|r| serde_json::json!({
"id": r.id,
"old_ip": r.old_ip,
"new_ip": r.new_ip,
"status": r.status,
"requested_at": r.requested_at.to_string(),
"processed_at": r.processed_at.map(|d| d.to_string()),
})).collect();
Ok(Json(serde_json::json!(result)))
}
fn is_valid_ipv4(ip: &str) -> bool {
ip.parse::<std::net::Ipv4Addr>().is_ok()
}
+52
View File
@@ -0,0 +1,52 @@
use axum::{routing::{get, post}, Router};
use crate::state::AppState;
mod auth;
mod billing;
mod license;
pub fn create_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.nest("/api/v1", api_router())
.with_state(state)
}
fn api_router() -> Router<AppState> {
Router::new()
.nest("/auth", auth_router())
.nest("/billing", billing_router())
.nest("/license", license_router())
}
fn auth_router() -> Router<AppState> {
Router::new()
.route("/register", post(auth::register))
.route("/login", post(auth::login))
.route("/me", get(auth::me))
.route("/verify-email", get(auth::verify_email))
.route("/forgot-password", post(auth::forgot_password))
.route("/reset-password", post(auth::reset_password))
}
fn billing_router() -> Router<AppState> {
Router::new()
.route("/plans", get(billing::plans))
.route("/checkout", post(billing::checkout))
.route("/portal", post(billing::portal))
.route("/cancel", post(billing::cancel))
.route("/change-plan", post(billing::change_plan))
.route("/subscription",get(billing::subscription))
.route("/webhook", post(billing::webhook))
}
fn license_router() -> Router<AppState> {
Router::new()
.route("/", get(license::get))
.route("/change-ip", post(license::change_ip))
.route("/ip-history",get(license::ip_history))
}
async fn health() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ "status": "ok", "ts": chrono::Utc::now().to_rfc3339() }))
}
+154
View File
@@ -0,0 +1,154 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use sqlx::MySqlPool;
use uuid::Uuid;
use crate::{
config::Config,
error::{AppError, AppResult},
models::{Customer, CustomerClaims},
};
pub fn new_id() -> String { Uuid::new_v4().to_string() }
pub fn random_token() -> String {
use rand::Rng;
let bytes: [u8; 32] = rand::thread_rng().gen();
hex::encode(bytes)
}
fn hash_password(password: &str) -> AppResult<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(anyhow::anyhow!("Hash error: {e}")))
}
fn verify_password(hash: &str, password: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else { return false };
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}
pub fn issue_jwt(cfg: &Config, id: &str, email: &str) -> AppResult<String> {
let exp = (Utc::now().timestamp() as u64 + cfg.jwt_expires_in_secs) as usize;
let claims = CustomerClaims { sub: id.to_string(), email: email.to_string(), exp };
encode(&Header::default(), &claims, &EncodingKey::from_secret(cfg.jwt_secret.as_bytes()))
.map_err(|e| AppError::Internal(anyhow::anyhow!("JWT sign error: {e}")))
}
pub fn verify_jwt(cfg: &Config, token: &str) -> AppResult<CustomerClaims> {
decode::<CustomerClaims>(
token,
&DecodingKey::from_secret(cfg.jwt_secret.as_bytes()),
&Validation::default(),
)
.map(|d| d.claims)
.map_err(|_| AppError::Unauthorized("Invalid or expired session".into()))
}
// ── DB helpers ────────────────────────────────────────────────────────────────
pub async fn get_by_email(pool: &MySqlPool, email: &str) -> AppResult<Option<Customer>> {
Ok(sqlx::query_as!(Customer, "SELECT * FROM customers WHERE email = ?", email)
.fetch_optional(pool).await?)
}
pub async fn get_by_id(pool: &MySqlPool, id: &str) -> AppResult<Option<Customer>> {
Ok(sqlx::query_as!(Customer, "SELECT * FROM customers WHERE id = ?", id)
.fetch_optional(pool).await?)
}
// ── Business logic ────────────────────────────────────────────────────────────
pub async fn register(
pool: &MySqlPool,
email: &str,
password: &str,
full_name: &str,
) -> AppResult<(Customer, String)> {
let email = email.to_lowercase().trim().to_string();
if get_by_email(pool, &email).await?.is_some() {
return Err(AppError::Conflict("Email already registered".into()));
}
let id = new_id();
let hash = hash_password(password)?;
let verify_token = random_token();
sqlx::query!(
"INSERT INTO customers (id, email, password_hash, full_name, verify_token) VALUES (?, ?, ?, ?, ?)",
id, email, hash, full_name.trim(), verify_token
)
.execute(pool).await?;
let customer = get_by_id(pool, &id).await?.unwrap();
Ok((customer, verify_token))
}
pub async fn verify_email(pool: &MySqlPool, token: &str) -> AppResult<bool> {
let row = sqlx::query!("SELECT id FROM customers WHERE verify_token = ?", token)
.fetch_optional(pool).await?;
let Some(r) = row else { return Ok(false) };
sqlx::query!("UPDATE customers SET is_verified = 1, verify_token = NULL WHERE id = ?", r.id)
.execute(pool).await?;
Ok(true)
}
pub async fn login(
pool: &MySqlPool,
cfg: &Config,
email: &str,
password: &str,
) -> AppResult<String> {
let email = email.to_lowercase().trim().to_string();
let customer = get_by_email(pool, &email)
.await?
.ok_or_else(|| AppError::Unauthorized("Invalid credentials".into()))?;
if !customer.is_verified {
return Err(AppError::Unauthorized("Please verify your email before logging in".into()));
}
if !verify_password(&customer.password_hash, password) {
return Err(AppError::Unauthorized("Invalid credentials".into()));
}
issue_jwt(cfg, &customer.id, &customer.email)
}
pub async fn initiate_reset(pool: &MySqlPool, email: &str) -> AppResult<Option<String>> {
let email = email.to_lowercase().trim().to_string();
let Some(c) = get_by_email(pool, &email).await? else { return Ok(None) };
let token = random_token();
let expires = Utc::now().naive_utc() + chrono::Duration::hours(1);
sqlx::query!(
"UPDATE customers SET reset_token = ?, reset_token_expires = ? WHERE id = ?",
token, expires, c.id
).execute(pool).await?;
Ok(Some(token))
}
pub async fn reset_password(pool: &MySqlPool, token: &str, new_password: &str) -> AppResult<bool> {
let row = sqlx::query!(
"SELECT id FROM customers WHERE reset_token = ? AND reset_token_expires > NOW()",
token
).fetch_optional(pool).await?;
let Some(r) = row else { return Ok(false) };
let hash = hash_password(new_password)?;
sqlx::query!(
"UPDATE customers SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?",
hash, r.id
).execute(pool).await?;
Ok(true)
}
+65
View File
@@ -0,0 +1,65 @@
/// HTTP bridge to the nixpanel-licensing Rust server.
/// The website API calls this internally — clients never talk to it directly.
use anyhow::{Context, Result};
use reqwest::Client;
use serde_json::{json, Value};
use std::sync::OnceLock;
use crate::config::LicensingConfig;
static CLIENT: OnceLock<Client> = OnceLock::new();
fn client() -> &'static Client {
CLIENT.get_or_init(|| Client::builder().timeout(std::time::Duration::from_secs(10)).build().unwrap())
}
async fn request(cfg: &LicensingConfig, method: reqwest::Method, path: &str, body: Option<Value>) -> Result<Value> {
let mut req = client()
.request(method, format!("{}{path}", cfg.url))
.header("Authorization", format!("Bearer {}", cfg.api_key))
.header("Content-Type", "application/json");
if let Some(b) = body {
req = req.json(&b);
}
let res = req.send().await.context("Licensing server request failed")?;
if !res.status().is_success() {
let err: Value = res.json().await.unwrap_or(json!({"error": "unknown"}));
anyhow::bail!("{}", err["error"].as_str().unwrap_or("Licensing server error"));
}
Ok(res.json().await.context("Failed to parse licensing server response")?)
}
pub async fn create_license(
cfg: &LicensingConfig,
plan_id: &str,
owner_email: &str,
owner_name: &str,
server_ip: &str,
) -> Result<Value> {
request(cfg, reqwest::Method::POST, "/api/v1/licenses", Some(json!({
"planId": plan_id,
"ownerEmail": owner_email,
"ownerName": owner_name,
"serverIp": server_ip,
}))).await
}
pub async fn get_license(cfg: &LicensingConfig, license_id: &str) -> Result<Value> {
request(cfg, reqwest::Method::GET, &format!("/api/v1/licenses/{license_id}"), None).await
}
pub async fn revoke_license(cfg: &LicensingConfig, license_id: &str) -> Result<()> {
request(cfg, reqwest::Method::POST, &format!("/api/v1/licenses/{license_id}/revoke"), None).await?;
Ok(())
}
pub async fn update_server_ip(cfg: &LicensingConfig, license_id: &str, server_ip: &str) -> Result<()> {
request(cfg, reqwest::Method::POST, &format!("/api/v1/licenses/{license_id}/rebind"), Some(json!({
"serverIp": server_ip
}))).await?;
Ok(())
}
+75
View File
@@ -0,0 +1,75 @@
use anyhow::Result;
use lettre::{
message::header::ContentType,
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
use std::sync::OnceLock;
use crate::config::MailConfig;
static MAILER: OnceLock<AsyncSmtpTransport<Tokio1Executor>> = OnceLock::new();
fn mailer(cfg: &MailConfig) -> &'static AsyncSmtpTransport<Tokio1Executor> {
MAILER.get_or_init(|| {
let creds = Credentials::new(cfg.username.clone(), cfg.password.clone());
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host)
.unwrap()
.port(cfg.port)
.credentials(creds)
.build()
})
}
async fn send(cfg: &MailConfig, to: &str, subject: &str, html: String) -> Result<()> {
let email = Message::builder()
.from(cfg.from.parse()?)
.to(to.parse()?)
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html)?;
mailer(cfg).send(email).await?;
Ok(())
}
pub async fn send_verification(cfg: &MailConfig, app_url: &str, to: &str, token: &str) -> Result<()> {
let url = format!("{app_url}/verify-email?token={token}");
send(cfg, to, "Verify your NixPanel account", format!(r#"
<h2>Welcome to NixPanel</h2>
<p>Click below to verify your email address:</p>
<a href="{url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Verify Email
</a>
<p>This link expires in 24 hours.</p>
"#)).await
}
pub async fn send_password_reset(cfg: &MailConfig, app_url: &str, to: &str, token: &str) -> Result<()> {
let url = format!("{app_url}/reset-password?token={token}");
send(cfg, to, "Reset your NixPanel password", format!(r#"
<h2>Password Reset</h2>
<p>Click below to reset your password. This link expires in 1 hour.</p>
<a href="{url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p>If you didn't request this, ignore this email.</p>
"#)).await
}
pub async fn send_license_confirmation(
cfg: &MailConfig,
app_url: &str,
to: &str,
plan: &str,
server_ip: &str,
interval: &str,
) -> Result<()> {
send(cfg, to, &format!("Your NixPanel {plan} license is active"), format!(r#"
<h2>License Activated</h2>
<p>Your <strong>{plan}</strong> ({interval}) plan is now active for <strong>{server_ip}</strong>.</p>
<a href="{app_url}/dashboard" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Go to Dashboard
</a>
"#)).await
}
+4
View File
@@ -0,0 +1,4 @@
pub mod auth;
pub mod licensing;
pub mod mail;
pub mod stripe;
+115
View File
@@ -0,0 +1,115 @@
use anyhow::{Context, Result};
use stripe::{
BillingPortalSession, CheckoutSession, CheckoutSessionMode, Client,
CreateBillingPortalSession, CreateCheckoutSession, CreateCheckoutSessionLineItems,
CreateCheckoutSessionSubscriptionData, Currency, EventObject, EventType,
Subscription, SubscriptionId, Webhook,
};
use std::sync::OnceLock;
use crate::config::StripeConfig;
static STRIPE: OnceLock<Client> = OnceLock::new();
pub fn client(cfg: &StripeConfig) -> &'static Client {
STRIPE.get_or_init(|| Client::new(&cfg.secret_key))
}
pub async fn create_checkout_session(
cfg: &StripeConfig,
stripe_customer: &str,
price_id: &str,
plan_key: &str,
interval: &str,
server_ip: &str,
customer_id: &str,
success_url: &str,
cancel_url: &str,
) -> Result<String> {
use std::collections::HashMap;
let mut metadata = HashMap::new();
metadata.insert("nixpanel_customer_id".into(), customer_id.into());
metadata.insert("plan_key".into(), plan_key.into());
metadata.insert("billing_interval".into(), interval.into());
metadata.insert("server_ip".into(), server_ip.into());
let mut params = CreateCheckoutSession::new();
params.customer = Some(stripe_customer.parse().context("Invalid customer ID")?);
params.mode = Some(CheckoutSessionMode::Subscription);
params.success_url = Some(success_url);
params.cancel_url = Some(cancel_url);
params.metadata = Some(metadata.clone());
params.line_items = Some(vec![CreateCheckoutSessionLineItems {
price: Some(price_id.to_string()),
quantity: Some(1),
..Default::default()
}]);
params.subscription_data = Some(CreateCheckoutSessionSubscriptionData {
metadata: Some(metadata),
..Default::default()
});
let session = CheckoutSession::create(client(cfg), params)
.await
.context("Failed to create Stripe checkout session")?;
session.url.context("Stripe session missing URL")
}
pub async fn create_portal_session(
cfg: &StripeConfig,
stripe_customer: &str,
return_url: &str,
) -> Result<String> {
let mut params = CreateBillingPortalSession::new(
stripe_customer.parse().context("Invalid customer ID")?
);
params.return_url = Some(return_url);
let session = BillingPortalSession::create(client(cfg), params)
.await
.context("Failed to create billing portal session")?;
Ok(session.url)
}
pub async fn cancel_at_period_end(cfg: &StripeConfig, subscription_id: &str) -> Result<()> {
use stripe::UpdateSubscription;
let id = subscription_id.parse::<SubscriptionId>().context("Invalid subscription ID")?;
let mut params = UpdateSubscription::new();
params.cancel_at_period_end = Some(true);
Subscription::update(client(cfg), &id, params)
.await
.context("Failed to cancel subscription")?;
Ok(())
}
pub async fn change_plan(cfg: &StripeConfig, subscription_id: &str, new_price_id: &str) -> Result<()> {
use stripe::{UpdateSubscription, UpdateSubscriptionItems};
let id = subscription_id.parse::<SubscriptionId>().context("Invalid subscription ID")?;
let sub = Subscription::retrieve(client(cfg), &id, &[]).await?;
let item_id = sub.items.data.first().context("No subscription items")?.id.clone();
let mut params = UpdateSubscription::new();
params.items = Some(vec![UpdateSubscriptionItems {
id: Some(item_id.to_string()),
price: Some(new_price_id.to_string()),
..Default::default()
}]);
params.proration_behavior = Some(stripe::SubscriptionProrationBehavior::CreateProrations);
Subscription::update(client(cfg), &id, params)
.await
.context("Failed to change plan")?;
Ok(())
}
pub fn construct_webhook_event(payload: &[u8], signature: &str, secret: &str) -> Result<stripe::Event> {
Webhook::construct_event(
std::str::from_utf8(payload).context("Invalid payload")?,
signature,
secret,
)
.context("Invalid webhook signature")
}
+15
View File
@@ -0,0 +1,15 @@
use sqlx::MySqlPool;
use std::sync::Arc;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub pool: MySqlPool,
pub config: Arc<Config>,
}
impl AppState {
pub fn new(pool: MySqlPool, config: Config) -> Self {
Self { pool, config: Arc::new(config) }
}
}