feat(premium): initial subscription implementation through paddle

This commit is contained in:
alyssa 2026-01-04 14:00:42 -05:00
parent 81cde5e688
commit 226947e6aa
15 changed files with 1121 additions and 144 deletions

View file

@ -1,4 +1,4 @@
use crate::{ApiContext, auth::AuthState, error::fail};
use crate::{ApiContext, auth::AuthState, fail};
use axum::{
Extension,
extract::{Path, State},

View file

@ -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(

View file

@ -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)+) => {{

View file

@ -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,

View file

@ -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
View 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)
)

View file

@ -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(),
}
}

View file

@ -0,0 +1 @@
pub use api::error::*;

View file

@ -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))

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
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()
}

View 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()
}

View file

@ -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,
}

View 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>

View file

@ -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>