mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-09 23:37:54 +00:00
feat(premium): initial subscription implementation through paddle
This commit is contained in:
parent
81cde5e688
commit
226947e6aa
15 changed files with 1121 additions and 144 deletions
|
|
@ -1,4 +1,4 @@
|
|||
use crate::{ApiContext, auth::AuthState, error::fail};
|
||||
use crate::{ApiContext, auth::AuthState, fail};
|
||||
use axum::{
|
||||
Extension,
|
||||
extract::{Path, State},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use sqlx::Postgres;
|
|||
|
||||
use pluralkit_models::{PKDashView, PKSystem, PKSystemConfig, PrivacyLevel};
|
||||
|
||||
use crate::{ApiContext, auth::AuthState, error::fail};
|
||||
use crate::{ApiContext, auth::AuthState, fail};
|
||||
|
||||
#[api_endpoint]
|
||||
pub async fn get_system_settings(
|
||||
|
|
|
|||
|
|
@ -84,15 +84,14 @@ impl IntoResponse for PKError {
|
|||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! fail {
|
||||
($($stuff:tt)+) => {{
|
||||
tracing::error!($($stuff)+);
|
||||
return Err(crate::error::GENERIC_SERVER_ERROR);
|
||||
return Err($crate::error::GENERIC_SERVER_ERROR);
|
||||
}};
|
||||
}
|
||||
|
||||
pub(crate) use fail;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! fail_html {
|
||||
($($stuff:tt)+) => {{
|
||||
|
|
|
|||
|
|
@ -100,6 +100,13 @@ pub struct ScheduledTasksConfig {
|
|||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct PremiumConfig {
|
||||
pub paddle_webhook_secret: String,
|
||||
pub paddle_api_key: String,
|
||||
pub paddle_client_token: String,
|
||||
pub paddle_price_id: String,
|
||||
#[serde(default)]
|
||||
pub is_paddle_production: bool,
|
||||
|
||||
pub postmark_token: String,
|
||||
pub from_email: String,
|
||||
pub base_url: String,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ api = { path = "../api" }
|
|||
|
||||
anyhow = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
axum-extra = { workspace = true }
|
||||
fred = { workspace = true }
|
||||
lazy_static = { workspace = true }
|
||||
|
|
@ -30,6 +31,6 @@ postmark = { version = "0.11", features = ["reqwest"] }
|
|||
rand = "0.8"
|
||||
thiserror = "1.0"
|
||||
hex = "0.4"
|
||||
chrono = { workspace = true }
|
||||
paddle-rust-sdk = { version = "0.16.0", default-features = false, features = ["rustls-native-roots"] }
|
||||
serde_urlencoded = "0.7"
|
||||
time = "0.3"
|
||||
time = "0.3"
|
||||
|
|
|
|||
10
crates/premium/init.sql
Normal file
10
crates/premium/init.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
create table premium_subscriptions (
|
||||
id serial primary key,
|
||||
provider text not null,
|
||||
provider_id text not null,
|
||||
email text not null,
|
||||
system_id int references systems(id) on delete set null,
|
||||
status text,
|
||||
next_renewal_at text,
|
||||
unique (provider, provider_id)
|
||||
)
|
||||
|
|
@ -14,7 +14,7 @@ use fred::{
|
|||
use rand::{Rng, distributions::Alphanumeric};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::web::{render, message};
|
||||
use crate::web::{message, render};
|
||||
|
||||
const LOGIN_TOKEN_TTL_SECS: i64 = 60 * 10;
|
||||
|
||||
|
|
@ -162,9 +162,12 @@ pub async fn middleware(
|
|||
refresh_session_cookie(session, response)
|
||||
} else {
|
||||
return render!(crate::web::Index {
|
||||
base_url: libpk::config.premium().base_url.clone(),
|
||||
session: None,
|
||||
show_login_form: true,
|
||||
message: None,
|
||||
subscriptions: vec![],
|
||||
paddle: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -185,17 +188,23 @@ pub async fn middleware(
|
|||
};
|
||||
let Some(email) = form.get("email") else {
|
||||
return render!(crate::web::Index {
|
||||
base_url: libpk::config.premium().base_url.clone(),
|
||||
session: None,
|
||||
show_login_form: true,
|
||||
message: Some("email field is required".to_string()),
|
||||
subscriptions: vec![],
|
||||
paddle: None,
|
||||
});
|
||||
};
|
||||
let email = email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return render!(crate::web::Index {
|
||||
base_url: libpk::config.premium().base_url.clone(),
|
||||
session: None,
|
||||
show_login_form: true,
|
||||
message: Some("email field is required".to_string()),
|
||||
subscriptions: vec![],
|
||||
paddle: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -237,9 +246,12 @@ pub async fn middleware(
|
|||
let token = path.strip_prefix("/login/").unwrap_or("");
|
||||
if token.is_empty() {
|
||||
return render!(crate::web::Index {
|
||||
base_url: libpk::config.premium().base_url.clone(),
|
||||
session: None,
|
||||
show_login_form: true,
|
||||
message: Some("invalid login link".to_string()),
|
||||
subscriptions: vec![],
|
||||
paddle: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -251,11 +263,14 @@ pub async fn middleware(
|
|||
|
||||
let Some(email) = email else {
|
||||
return render!(crate::web::Index {
|
||||
base_url: libpk::config.premium().base_url.clone(),
|
||||
session: None,
|
||||
show_login_form: true,
|
||||
message: Some(
|
||||
"invalid or expired login link. please request a new one.".to_string()
|
||||
),
|
||||
subscriptions: vec![],
|
||||
paddle: None,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -313,6 +328,14 @@ pub async fn middleware(
|
|||
)
|
||||
.into_response()
|
||||
}
|
||||
"/cancel" | "/validate-token" => {
|
||||
if let Some(ref session) = session {
|
||||
let response = next.run(request).await;
|
||||
refresh_session_cookie(session, response)
|
||||
} else {
|
||||
Redirect::to("/").into_response()
|
||||
}
|
||||
}
|
||||
_ => (axum::http::StatusCode::NOT_FOUND, "404 not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
crates/premium/src/error.rs
Normal file
1
crates/premium/src/error.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub use api::error::*;
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
use askama::Template;
|
||||
use axum::{
|
||||
Extension, Router,
|
||||
response::Html,
|
||||
extract::State,
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use tower_http::{catch_panic::CatchPanicLayer, services::ServeDir};
|
||||
|
|
@ -10,21 +11,56 @@ use tracing::info;
|
|||
use api::{ApiContext, middleware};
|
||||
|
||||
mod auth;
|
||||
mod error;
|
||||
mod mailer;
|
||||
mod paddle;
|
||||
mod system;
|
||||
mod web;
|
||||
|
||||
pub use api::fail;
|
||||
|
||||
async fn home_handler(
|
||||
State(ctx): State<ApiContext>,
|
||||
Extension(session): Extension<auth::AuthState>,
|
||||
) -> Response {
|
||||
let subscriptions = match paddle::fetch_subscriptions_for_email(&ctx, &session.email).await {
|
||||
Ok(subs) => subs,
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "failed to fetch subscriptions for {}", session.email);
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
Html(
|
||||
web::Index {
|
||||
base_url: libpk::config.premium().base_url.clone(),
|
||||
session: Some(session),
|
||||
show_login_form: false,
|
||||
message: None,
|
||||
subscriptions,
|
||||
paddle: Some(web::PaddleData {
|
||||
client_token: libpk::config.premium().paddle_client_token.clone(),
|
||||
price_id: libpk::config.premium().paddle_price_id.clone(),
|
||||
environment: if libpk::config.premium().is_paddle_production {
|
||||
"production"
|
||||
} else {
|
||||
"sandbox"
|
||||
}
|
||||
.to_string(),
|
||||
}),
|
||||
}
|
||||
.render()
|
||||
.unwrap(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// this function is manually formatted for easier legibility of route_services
|
||||
#[rustfmt::skip]
|
||||
fn router(ctx: ApiContext) -> Router {
|
||||
// processed upside down (???) so we have to put middleware at the end
|
||||
Router::new()
|
||||
.route("/", get(|Extension(session): Extension<auth::AuthState>| async move {
|
||||
Html(web::Index {
|
||||
session: Some(session),
|
||||
show_login_form: false,
|
||||
message: None,
|
||||
}.render().unwrap())
|
||||
}))
|
||||
.route("/", get(home_handler))
|
||||
|
||||
.route("/login/{token}", get(|| async {
|
||||
"handled in auth middleware"
|
||||
|
|
@ -35,8 +71,13 @@ fn router(ctx: ApiContext) -> Router {
|
|||
.route("/logout", post(|| async {
|
||||
"handled in auth middleware"
|
||||
}))
|
||||
.route("/cancel", get(paddle::cancel_page).post(paddle::cancel))
|
||||
.route("/validate-token", post(system::validate_token))
|
||||
|
||||
.layer(axum::middleware::from_fn_with_state(ctx.clone(), auth::middleware))
|
||||
|
||||
.route("/paddle", post(paddle::webhook))
|
||||
|
||||
.layer(axum::middleware::from_fn(middleware::logger::logger))
|
||||
.nest_service("/static", ServeDir::new("static"))
|
||||
.layer(CatchPanicLayer::custom(api::util::handle_panic))
|
||||
|
|
|
|||
493
crates/premium/src/paddle.rs
Normal file
493
crates/premium/src/paddle.rs
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
use std::{collections::HashSet, vec};
|
||||
|
||||
use api::ApiContext;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use paddle_rust_sdk::{
|
||||
Paddle,
|
||||
entities::{Customer, Subscription},
|
||||
enums::{EventData, SubscriptionStatus},
|
||||
webhooks::MaximumVariance,
|
||||
};
|
||||
use pk_macros::api_endpoint;
|
||||
use serde::Serialize;
|
||||
use sqlx::postgres::Postgres;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::fail;
|
||||
|
||||
// ew
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref PADDLE_CLIENT: Paddle = {
|
||||
let config = libpk::config.premium();
|
||||
let base_url = if config.is_paddle_production {
|
||||
Paddle::PRODUCTION
|
||||
} else {
|
||||
Paddle::SANDBOX
|
||||
};
|
||||
Paddle::new(&config.paddle_api_key, base_url).expect("failed to create paddle client")
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn fetch_customer(customer_id: &str) -> anyhow::Result<Customer> {
|
||||
let customer = PADDLE_CLIENT.customer_get(customer_id).send().await?;
|
||||
Ok(customer.data)
|
||||
}
|
||||
|
||||
const SUBSCRIPTION_QUERY: &str = r#"
|
||||
select
|
||||
p.id, p.provider, p.provider_id, p.email, p.system_id,
|
||||
s.hid as system_hid, s.name as system_name,
|
||||
p.status, p.next_renewal_at
|
||||
from premium_subscriptions p
|
||||
left join systems s on p.system_id = s.id
|
||||
"#;
|
||||
|
||||
async fn get_subscriptions_by_email(
|
||||
ctx: &ApiContext,
|
||||
email: &str,
|
||||
) -> anyhow::Result<Vec<DbSubscription>> {
|
||||
let query = format!("{} where p.email = $1", SUBSCRIPTION_QUERY);
|
||||
let subs = sqlx::query_as(&query)
|
||||
.bind(email)
|
||||
.fetch_all(&ctx.db)
|
||||
.await?;
|
||||
Ok(subs)
|
||||
}
|
||||
|
||||
async fn get_subscription(
|
||||
ctx: &ApiContext,
|
||||
provider_id: &str,
|
||||
email: &str,
|
||||
) -> anyhow::Result<Option<DbSubscription>> {
|
||||
let query = format!(
|
||||
"{} where p.provider_id = $1 and p.email = $2",
|
||||
SUBSCRIPTION_QUERY
|
||||
);
|
||||
let sub = sqlx::query_as(&query)
|
||||
.bind(provider_id)
|
||||
.bind(email)
|
||||
.fetch_optional(&ctx.db)
|
||||
.await?;
|
||||
Ok(sub)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||
pub struct DbSubscription {
|
||||
pub id: i32,
|
||||
pub provider: String,
|
||||
pub provider_id: String,
|
||||
pub email: String,
|
||||
pub system_id: Option<i32>,
|
||||
pub system_hid: Option<String>,
|
||||
pub system_name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub next_renewal_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SubscriptionInfo {
|
||||
pub db: Option<DbSubscription>,
|
||||
pub paddle: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl SubscriptionInfo {
|
||||
pub fn subscription_id(&self) -> &str {
|
||||
if let Some(paddle) = &self.paddle {
|
||||
paddle.id.as_ref()
|
||||
} else if let Some(db) = &self.db {
|
||||
&db.provider_id
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> String {
|
||||
if let Some(paddle) = &self.paddle {
|
||||
if let Some(ref scheduled) = paddle.scheduled_change {
|
||||
if matches!(
|
||||
scheduled.action,
|
||||
paddle_rust_sdk::enums::ScheduledChangeAction::Cancel
|
||||
) {
|
||||
return format!("expires {}", scheduled.effective_at.format("%Y-%m-%d"));
|
||||
}
|
||||
}
|
||||
format!("{:?}", paddle.status).to_lowercase()
|
||||
} else if let Some(db) = &self.db {
|
||||
db.status.clone().unwrap_or_else(|| "unknown".to_string())
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_renewal(&self) -> String {
|
||||
if let Some(paddle) = &self.paddle {
|
||||
// if subscription is canceled, show next_billed_at as "ends at" date instead of "next renewal"
|
||||
if paddle.scheduled_change.as_ref().is_some_and(|s| {
|
||||
matches!(
|
||||
s.action,
|
||||
paddle_rust_sdk::enums::ScheduledChangeAction::Cancel
|
||||
)
|
||||
}) {
|
||||
return "-".to_string();
|
||||
}
|
||||
if let Some(next) = paddle.next_billed_at {
|
||||
return next.format("%Y-%m-%d").to_string();
|
||||
}
|
||||
}
|
||||
if let Some(db) = &self.db {
|
||||
if let Some(next) = &db.next_renewal_at {
|
||||
return next.split('T').next().unwrap_or(next).to_string();
|
||||
}
|
||||
}
|
||||
"-".to_string()
|
||||
}
|
||||
|
||||
pub fn system_id_display(&self) -> String {
|
||||
if let Some(db) = &self.db {
|
||||
if let Some(hid) = &db.system_hid {
|
||||
if let Some(name) = &db.system_name {
|
||||
// ew, this needs to be fixed
|
||||
let escaped_name = html_escape(name);
|
||||
return format!("{} (<code>{}</code>)", escaped_name, hid);
|
||||
}
|
||||
return format!("<code>{}</code>", hid);
|
||||
}
|
||||
if db.system_id.is_some() {
|
||||
return "unknown system (contact us at billing@pluralkit.me to fix this)"
|
||||
.to_string();
|
||||
}
|
||||
return "not linked".to_string();
|
||||
}
|
||||
"not linked".to_string()
|
||||
}
|
||||
|
||||
pub fn is_cancellable(&self) -> bool {
|
||||
if let Some(paddle) = &self.paddle {
|
||||
if paddle.scheduled_change.as_ref().is_some_and(|s| {
|
||||
matches!(
|
||||
s.action,
|
||||
paddle_rust_sdk::enums::ScheduledChangeAction::Cancel
|
||||
)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
paddle.status,
|
||||
SubscriptionStatus::Active | SubscriptionStatus::PastDue
|
||||
)
|
||||
} else if let Some(db) = &self.db {
|
||||
matches!(db.status.as_deref(), Some("active") | Some("past_due"))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this is slightly terrible, but works
|
||||
// the paddle sdk is a mess which does not help
|
||||
pub async fn fetch_subscriptions_for_email(
|
||||
ctx: &ApiContext,
|
||||
email: &str,
|
||||
) -> anyhow::Result<Vec<SubscriptionInfo>> {
|
||||
let db_subs = get_subscriptions_by_email(ctx, email).await?;
|
||||
|
||||
let mut paddle_subs: Vec<Subscription> = Vec::new();
|
||||
|
||||
// there's no method to look up customer by email, so we have to do this nonsense
|
||||
let Some(customer) = PADDLE_CLIENT
|
||||
.customers_list()
|
||||
.emails([email])
|
||||
.send()
|
||||
.next()
|
||||
.await?
|
||||
.and_then(|v| v.data.into_iter().next())
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
// why
|
||||
let mut temp_paddle_for_sub_list = PADDLE_CLIENT.subscriptions_list();
|
||||
let mut subs_pages = temp_paddle_for_sub_list.customer_id([customer.id]).send();
|
||||
while let Some(subs_page) = subs_pages.next().await? {
|
||||
paddle_subs.extend(subs_page.data);
|
||||
}
|
||||
|
||||
let mut results: Vec<SubscriptionInfo> = Vec::new();
|
||||
let mut found_ids: HashSet<String> = HashSet::new();
|
||||
|
||||
for db_sub in &db_subs {
|
||||
let paddle_match = paddle_subs
|
||||
.iter()
|
||||
.find(|p| p.id.as_ref() == db_sub.provider_id);
|
||||
|
||||
if let Some(paddle) = paddle_match {
|
||||
found_ids.insert(paddle.id.as_ref().to_string());
|
||||
results.push(SubscriptionInfo {
|
||||
db: Some(db_sub.clone()),
|
||||
paddle: Some(paddle.clone()),
|
||||
});
|
||||
} else {
|
||||
results.push(SubscriptionInfo {
|
||||
db: Some(db_sub.clone()),
|
||||
paddle: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for paddle_sub in paddle_subs {
|
||||
if !found_ids.contains(paddle_sub.id.as_ref()) {
|
||||
results.push(SubscriptionInfo {
|
||||
db: None,
|
||||
paddle: Some(paddle_sub),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// todo: show some error if a sub is only in db/provider but not both
|
||||
|
||||
// todo: we may want to show canceled subscriptions in the future
|
||||
results.retain(|sub| sub.status() != "canceled");
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn save_subscription(
|
||||
ctx: &ApiContext,
|
||||
sub: &Subscription,
|
||||
email: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let status = format!("{:?}", sub.status).to_lowercase();
|
||||
let next_renewal_at = sub.next_billed_at.map(|dt| dt.to_rfc3339());
|
||||
let system_id: Option<i32> = sub
|
||||
.custom_data
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("system_id"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|v| v as i32);
|
||||
|
||||
sqlx::query::<Postgres>(
|
||||
r#"
|
||||
insert into premium_subscriptions (provider, provider_id, email, system_id, status, next_renewal_at)
|
||||
values ('paddle', $1, $2, $3, $4, $5)
|
||||
on conflict (provider, provider_id) do update set
|
||||
status = excluded.status,
|
||||
next_renewal_at = excluded.next_renewal_at
|
||||
"#,
|
||||
)
|
||||
.bind(sub.id.as_ref())
|
||||
.bind(email)
|
||||
.bind(system_id)
|
||||
.bind(&status)
|
||||
.bind(&next_renewal_at)
|
||||
.execute(&ctx.db)
|
||||
.await?;
|
||||
|
||||
// if has a linked system, also update system_config
|
||||
// just in case we get out of order webhooks, never reduce the premium_until
|
||||
// todo: this will obviously break if we refund someone's subscription
|
||||
if let Some(system_id) = system_id {
|
||||
if matches!(sub.status, SubscriptionStatus::Active) {
|
||||
if let Some(next_billed_at) = sub.next_billed_at {
|
||||
let premium_until = next_billed_at.naive_utc();
|
||||
sqlx::query::<Postgres>(
|
||||
r#"
|
||||
update system_config set
|
||||
premium_until = greatest(system_config.premium_until, $2)
|
||||
where system = $1
|
||||
"#,
|
||||
)
|
||||
.bind(system_id)
|
||||
.bind(premium_until)
|
||||
.execute(&ctx.db)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"updated premium_until for system {} to {}",
|
||||
system_id, premium_until
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[api_endpoint]
|
||||
pub async fn webhook(State(ctx): State<ApiContext>, headers: HeaderMap, body: String) -> Response {
|
||||
let Some(signature) = headers
|
||||
.get("paddle-signature")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
else {
|
||||
return Ok(StatusCode::BAD_REQUEST.into_response());
|
||||
};
|
||||
|
||||
match match Paddle::unmarshal(
|
||||
body,
|
||||
&libpk::config.premium().paddle_webhook_secret,
|
||||
signature,
|
||||
MaximumVariance::default(),
|
||||
) {
|
||||
Ok(event) => event,
|
||||
Err(err) => {
|
||||
error!(?err, "failed to unmarshal paddle data");
|
||||
return Ok(StatusCode::BAD_REQUEST.into_response());
|
||||
}
|
||||
}
|
||||
.data
|
||||
{
|
||||
EventData::SubscriptionCreated(sub)
|
||||
| EventData::SubscriptionActivated(sub)
|
||||
| EventData::SubscriptionUpdated(sub) => {
|
||||
match sub.status {
|
||||
SubscriptionStatus::Trialing => {
|
||||
error!(
|
||||
"got status trialing for subscription {}, this should never happen",
|
||||
sub.id
|
||||
);
|
||||
return Ok("".into_response());
|
||||
}
|
||||
SubscriptionStatus::Active
|
||||
| SubscriptionStatus::Canceled
|
||||
| SubscriptionStatus::PastDue
|
||||
| SubscriptionStatus::Paused => {}
|
||||
unk => {
|
||||
error!("got unknown status {unk:?} for subscription {}", sub.id);
|
||||
return Ok("".into_response());
|
||||
}
|
||||
}
|
||||
|
||||
let email = match fetch_customer(sub.customer_id.as_ref()).await {
|
||||
Ok(cus) => cus.email,
|
||||
Err(err) => {
|
||||
fail!(
|
||||
?err,
|
||||
"failed to fetch customer email for subscription {}",
|
||||
sub.id
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = save_subscription(&ctx, &sub, &email).await {
|
||||
fail!(?err, "failed to save subscription {}", sub.id);
|
||||
}
|
||||
|
||||
info!("saved subscription {} with status {:?}", sub.id, sub.status);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok("".into_response())
|
||||
}
|
||||
|
||||
pub async fn cancel_subscription(subscription_id: &str) -> anyhow::Result<Subscription> {
|
||||
let result = PADDLE_CLIENT
|
||||
.subscription_cancel(subscription_id)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(result.data)
|
||||
}
|
||||
|
||||
#[api_endpoint]
|
||||
pub async fn cancel(
|
||||
State(ctx): State<ApiContext>,
|
||||
axum::Extension(session): axum::Extension<crate::auth::AuthState>,
|
||||
axum::Form(form): axum::Form<CancelForm>,
|
||||
) -> Response {
|
||||
if form.csrf_token != session.csrf_token {
|
||||
return Ok((StatusCode::FORBIDDEN, "invalid csrf token").into_response());
|
||||
}
|
||||
|
||||
let db_sub = get_subscription(&ctx, &form.subscription_id, &session.email)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(?e, "failed to fetch subscription from db");
|
||||
crate::error::GENERIC_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
if db_sub.is_none() {
|
||||
return Ok((
|
||||
StatusCode::FORBIDDEN,
|
||||
"subscription not found or not owned by you",
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
match cancel_subscription(&form.subscription_id).await {
|
||||
Ok(sub) => {
|
||||
info!("cancelled subscription {} for {}", sub.id, session.email);
|
||||
Ok(axum::response::Redirect::to("/").into_response())
|
||||
}
|
||||
Err(err) => {
|
||||
fail!(
|
||||
?err,
|
||||
"failed to cancel subscription {}",
|
||||
form.subscription_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CancelForm {
|
||||
pub csrf_token: String,
|
||||
pub subscription_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CancelQuery {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub async fn cancel_page(
|
||||
State(ctx): State<ApiContext>,
|
||||
axum::Extension(session): axum::Extension<crate::auth::AuthState>,
|
||||
axum::extract::Query(query): axum::extract::Query<CancelQuery>,
|
||||
) -> Response {
|
||||
let subscriptions = match fetch_subscriptions_for_email(&ctx, &session.email).await {
|
||||
Ok(subs) => subs,
|
||||
Err(e) => {
|
||||
error!(?e, "failed to fetch subscriptions");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"failed to fetch subscriptions",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let subscription = subscriptions
|
||||
.into_iter()
|
||||
.find(|s| s.subscription_id() == query.id);
|
||||
|
||||
let Some(subscription) = subscription else {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
"subscription not found or not owned by you",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
axum::response::Html(
|
||||
crate::web::Cancel {
|
||||
csrf_token: session.csrf_token,
|
||||
subscription,
|
||||
}
|
||||
.render()
|
||||
.unwrap(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
67
crates/premium/src/system.rs
Normal file
67
crates/premium/src/system.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
use axum::{
|
||||
Extension, Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::AuthState;
|
||||
use api::ApiContext;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ValidateTokenRequest {
|
||||
csrf_token: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ValidateTokenResponse {
|
||||
system_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ValidateTokenError {
|
||||
error: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn validate_token(
|
||||
State(ctx): State<ApiContext>,
|
||||
Extension(session): Extension<AuthState>,
|
||||
Json(body): Json<ValidateTokenRequest>,
|
||||
) -> Response {
|
||||
if body.csrf_token != session.csrf_token {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ValidateTokenError {
|
||||
error: "Invalid CSRF token.".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let system_id = match libpk::db::repository::legacy_token_auth(&ctx.db, &body.token).await {
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ValidateTokenError {
|
||||
error: "Invalid system token.".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "failed to validate system token");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ValidateTokenError {
|
||||
error: "Failed to validate token.".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
Json(ValidateTokenResponse { system_id }).into_response()
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
use askama::Template;
|
||||
|
||||
use crate::auth::AuthState;
|
||||
use crate::paddle::SubscriptionInfo;
|
||||
|
||||
macro_rules! render {
|
||||
($stuff:expr) => {{
|
||||
|
|
@ -18,16 +19,35 @@ pub(crate) use render;
|
|||
|
||||
pub fn message(message: String, session: Option<AuthState>) -> Index {
|
||||
Index {
|
||||
session: session,
|
||||
base_url: libpk::config.premium().base_url.clone(),
|
||||
session,
|
||||
show_login_form: false,
|
||||
message: Some(message)
|
||||
message: Some(message),
|
||||
subscriptions: vec![],
|
||||
paddle: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
pub struct Index {
|
||||
pub base_url: String,
|
||||
pub session: Option<AuthState>,
|
||||
pub show_login_form: bool,
|
||||
pub message: Option<String>,
|
||||
pub subscriptions: Vec<SubscriptionInfo>,
|
||||
pub paddle: Option<PaddleData>,
|
||||
}
|
||||
|
||||
pub struct PaddleData {
|
||||
pub client_token: String,
|
||||
pub price_id: String,
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "cancel.html")]
|
||||
pub struct Cancel {
|
||||
pub csrf_token: String,
|
||||
pub subscription: crate::paddle::SubscriptionInfo,
|
||||
}
|
||||
|
|
|
|||
29
crates/premium/templates/cancel.html
Normal file
29
crates/premium/templates/cancel.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>Cancel Subscription - PluralKit Premium</title>
|
||||
<link rel="stylesheet" href="/static/stylesheet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h2>PluralKit Premium</h2>
|
||||
|
||||
{% if subscription.is_cancellable() %}
|
||||
<h3>Cancel Subscription</h3>
|
||||
|
||||
<p>Are you sure you want to cancel subscription <strong>{{ subscription.subscription_id() }}</strong>?</p>
|
||||
<p>Your subscription will remain active until the end of the current billing period.</p>
|
||||
|
||||
<form action="/cancel" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="subscription_id" value="{{ subscription.subscription_id() }}" />
|
||||
<button type="submit">Yes, cancel subscription</button>
|
||||
<a href="/"><button type="button">No, go back</button></a>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>This subscription (<strong>{{ subscription.subscription_id() }}</strong>) has already been canceled and will end on <strong>{{ subscription.next_renewal() }}</strong>.</p>
|
||||
|
||||
<a href="/"><button type="button">Go back</button></a>
|
||||
{% endif %}
|
||||
|
||||
<br/><br/>
|
||||
<p>for assistance please email us at <a href="mailto:billing@pluralkit.me">billing@pluralkit.me</a></p>
|
||||
</body>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
<head>
|
||||
<title>PluralKit Premium</title>
|
||||
<link rel="stylesheet" href="/static/stylesheet.css" />
|
||||
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h2>PluralKit Premium</h2>
|
||||
|
|
@ -9,9 +10,126 @@
|
|||
{% if let Some(session) = session %}
|
||||
<form action="/logout" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}" />
|
||||
<p>logged in as <strong>{{ session.email }}.</strong></p>
|
||||
<button type="submit">log out</button>
|
||||
<p>
|
||||
logged in as <strong>{{ session.email }}.</strong>
|
||||
<button type="submit">log out</button>
|
||||
</p>
|
||||
</form>
|
||||
<br/>
|
||||
|
||||
{% if subscriptions.is_empty() %}
|
||||
<p>You are not currently subscribed to PluralKit Premium.</p>
|
||||
<p>Enter your system token to subscribe. yes this will be fixed before release</p>
|
||||
<div>
|
||||
<input type="text" id="system-token" placeholder="token" required />
|
||||
<button id="buy-button">Subscribe to PluralKit Premium</button>
|
||||
</div>
|
||||
<p id="token-error" style="color: red; display: none;"></p>
|
||||
<p id="system-info" style="color: green; display: none;"></p>
|
||||
{% else %}
|
||||
You are currently subscribed to PluralKit Premium. Thanks for the support!
|
||||
<br/>
|
||||
{% for sub in &subscriptions %}
|
||||
<p>
|
||||
<strong>Subscription ID:</strong> {{ sub.subscription_id() }}<br/>
|
||||
<strong>Status:</strong> {{ sub.status() }}<br/>
|
||||
<strong>Next Renewal:</strong> {{ sub.next_renewal() }}<br/>
|
||||
<strong>Linked System:</strong> {{ sub.system_id_display()|safe }}<br/>
|
||||
{% if sub.is_cancellable() %}
|
||||
<a href="/cancel?id={{ sub.subscription_id() }}">Cancel</a><br/>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(paddle) = paddle %}
|
||||
<script>
|
||||
Paddle.Environment.set("{{ paddle.environment }}");
|
||||
Paddle.Initialize({
|
||||
token: "{{ paddle.client_token }}",
|
||||
eventCallback: function(event) {
|
||||
if (event.name === "checkout.completed") {
|
||||
// webhook request sometimes takes a while, artificially delay here
|
||||
document.body.innerHTML = "<h2>PluralKit Premium</h2><p>Processing your subscription, please wait...</p>";
|
||||
setTimeout(function() {
|
||||
window.location.href = "{{ base_url }}";
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const buyButton = document.getElementById("buy-button");
|
||||
if (buyButton) {
|
||||
buyButton.addEventListener("click", async function() {
|
||||
const tokenInput = document.getElementById("system-token");
|
||||
const errorEl = document.getElementById("token-error");
|
||||
const infoEl = document.getElementById("system-info");
|
||||
|
||||
if (!tokenInput || !tokenInput.value.trim()) {
|
||||
errorEl.textContent = "Please enter your system token.";
|
||||
errorEl.style.display = "block";
|
||||
infoEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
buyButton.disabled = true;
|
||||
buyButton.textContent = "Validating...";
|
||||
errorEl.style.display = "none";
|
||||
infoEl.style.display = "none";
|
||||
|
||||
try {
|
||||
const response = await fetch("/validate-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
csrf_token: "{{ session.csrf_token }}",
|
||||
token: tokenInput.value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
errorEl.textContent = data.error || "Invalid token.";
|
||||
errorEl.style.display = "block";
|
||||
buyButton.disabled = false;
|
||||
buyButton.textContent = "Subscribe to PluralKit Premium";
|
||||
return;
|
||||
}
|
||||
|
||||
// Token is valid, open Paddle checkout
|
||||
Paddle.Checkout.open({
|
||||
settings: {
|
||||
allowLogout: false,
|
||||
},
|
||||
items: [
|
||||
{ priceId: "{{ paddle.price_id }}", quantity: 1 }
|
||||
],
|
||||
customer: {
|
||||
email: "{{ session.email }}"
|
||||
},
|
||||
customData: {
|
||||
email: "{{ session.email }}",
|
||||
system_id: data.system_id
|
||||
}
|
||||
});
|
||||
|
||||
buyButton.disabled = false;
|
||||
buyButton.textContent = "Subscribe to PluralKit Premium";
|
||||
} catch (err) {
|
||||
errorEl.textContent = "Failed to validate token. Please try again.";
|
||||
errorEl.style.display = "block";
|
||||
buyButton.disabled = false;
|
||||
buyButton.textContent = "Subscribe to PluralKit Premium";
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
error initializing paddle client
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if show_login_form %}
|
||||
|
|
@ -26,4 +144,7 @@
|
|||
{% if let Some(msg) = message %}
|
||||
<div>{{ msg }}</div>
|
||||
{% endif %}
|
||||
|
||||
<br/><br/>
|
||||
<p>for assistance please email us at <a href="mailto:billing@pluralkit.me">billing@pluralkit.me</a></p>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue