feat(premium): initial subscription implementation through paddle

This commit is contained in:
alyssa 2026-01-04 14:00:42 -05:00
parent 26af2df720
commit bd5b5c03fe
15 changed files with 1121 additions and 144 deletions

413
Cargo.lock generated
View file

@ -90,7 +90,7 @@ dependencies = [
"metrics",
"pk_macros",
"pluralkit_models",
"reqwest 0.12.15",
"reqwest 0.12.28",
"reverse-proxy-service",
"sea-query",
"sea-query-sqlx",
@ -101,7 +101,7 @@ dependencies = [
"subtle",
"tokio",
"tower 0.4.13",
"tower-http",
"tower-http 0.5.2",
"tracing",
"twilight-http",
]
@ -240,7 +240,7 @@ dependencies = [
"gif",
"image",
"libpk",
"reqwest 0.12.15",
"reqwest 0.12.28",
"rust-s3",
"serde",
"sha2",
@ -894,8 +894,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core 0.21.3",
"darling_macro 0.21.3",
]
[[package]]
@ -911,13 +921,38 @@ dependencies = [
"syn",
]
[[package]]
name = "darling_core"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"darling_core 0.20.11",
"quote",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core 0.21.3",
"quote",
"syn",
]
@ -1013,7 +1048,7 @@ dependencies = [
"axum 0.8.4",
"hickory-client",
"libpk",
"reqwest 0.12.15",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
@ -1059,6 +1094,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
@ -1370,7 +1411,7 @@ dependencies = [
"lazy_static",
"libpk",
"metrics",
"reqwest 0.12.15",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_variant",
@ -1469,7 +1510,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap",
"indexmap 2.8.0",
"slab",
"tokio",
"tokio-util",
@ -1488,7 +1529,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.3.1",
"indexmap",
"indexmap 2.8.0",
"slab",
"tokio",
"tokio-util",
@ -1802,22 +1843,28 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.11"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2"
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"hyper 1.6.0",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.9",
"socket2 0.6.0",
"system-configuration 0.6.1",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -2017,6 +2064,17 @@ dependencies = [
"quick-error",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.8.0"
@ -2025,6 +2083,7 @@ checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058"
dependencies = [
"equivalent",
"hashbrown 0.15.2",
"serde",
]
[[package]]
@ -2055,6 +2114,16 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "itertools"
version = "0.12.1"
@ -2343,7 +2412,7 @@ dependencies = [
"http-body-util",
"hyper 1.6.0",
"hyper-util",
"indexmap",
"indexmap 2.8.0",
"ipnet",
"metrics",
"metrics-util",
@ -2584,6 +2653,36 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "paddle-rust-sdk"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a6177ee650f105756a75e7c10ed29c31b625346433718cc20218180e96507ee"
dependencies = [
"chrono",
"hmac",
"paddle-rust-sdk-types",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_qs",
"serde_with",
"sha2",
"url",
]
[[package]]
name = "paddle-rust-sdk-types"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eade7cb56ac8217b595a2beca5cf2db20cfd0b0a261edb45662dbe9a1d996de5"
dependencies = [
"chrono",
"serde",
"serde_json",
"serde_with",
]
[[package]]
name = "parking"
version = "2.2.1"
@ -2789,7 +2888,7 @@ dependencies = [
"async-trait",
"bytes",
"http 1.3.1",
"reqwest 0.12.15",
"reqwest 0.12.28",
"serde",
"serde_json",
"thiserror 2.0.12",
@ -2828,11 +2927,12 @@ dependencies = [
"lazy_static",
"libpk",
"metrics",
"paddle-rust-sdk",
"pk_macros",
"pluralkit_models",
"postmark",
"rand 0.8.5",
"reqwest 0.12.15",
"reqwest 0.12.28",
"sea-query",
"serde",
"serde_json",
@ -2841,7 +2941,7 @@ dependencies = [
"thiserror 1.0.69",
"time",
"tokio",
"tower-http",
"tower-http 0.5.2",
"tracing",
"twilight-http",
]
@ -3078,6 +3178,26 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "regex"
version = "1.11.1"
@ -3131,12 +3251,12 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"rustls 0.21.12",
"rustls-pemfile 1.0.4",
"rustls-pemfile",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper 0.1.2",
"system-configuration",
"system-configuration 0.5.1",
"tokio",
"tokio-rustls 0.24.1",
"tokio-util",
@ -3152,31 +3272,31 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.12.15"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.8",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.6.0",
"hyper-rustls 0.27.5",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"once_cell",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.25",
"rustls-pemfile 2.2.0",
"rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
@ -3185,13 +3305,13 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.2",
"tower 0.5.2",
"tower-http 0.6.8",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 0.26.8",
"windows-registry",
"webpki-roots 1.0.4",
]
[[package]]
@ -3435,15 +3555,6 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.11.0"
@ -3544,7 +3655,7 @@ dependencies = [
"libpk",
"metrics",
"num-format",
"reqwest 0.12.15",
"reqwest 0.12.28",
"serde",
"serde_json",
"sqlx",
@ -3553,6 +3664,30 @@ dependencies = [
"twilight-http",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -3586,7 +3721,7 @@ version = "1.0.0-rc.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217e9422de35f26c16c5f671fce3c075a65e10322068dbc66078428634af6195"
dependencies = [
"darling",
"darling 0.20.11",
"heck 0.4.1",
"proc-macro2",
"quote",
@ -3641,7 +3776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a7332159e544e34db06b251b1eda5e546bd90285c3f58d9c8ff8450b484e0da"
dependencies = [
"httpdate",
"reqwest 0.12.15",
"reqwest 0.12.28",
"rustls 0.23.25",
"sentry-backtrace",
"sentry-contexts",
@ -3785,15 +3920,16 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.140"
version = "1.0.148"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
dependencies = [
"indexmap",
"indexmap 2.8.0",
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@ -3806,6 +3942,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_qs"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352"
dependencies = [
"percent-encoding",
"serde",
"thiserror 2.0.12",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@ -3847,6 +3994,37 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
dependencies = [
"base64 0.22.1",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.8.0",
"schemars 0.9.0",
"schemars 1.2.0",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
dependencies = [
"darling 0.21.3",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "sha-1"
version = "0.10.1"
@ -4032,7 +4210,7 @@ dependencies = [
"futures-util",
"hashbrown 0.15.2",
"hashlink 0.10.0",
"indexmap",
"indexmap 2.8.0",
"log",
"memchr",
"once_cell",
@ -4217,6 +4395,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@ -4268,7 +4452,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"system-configuration-sys",
"system-configuration-sys 0.5.0",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.9.4",
"system-configuration-sys 0.6.0",
]
[[package]]
@ -4281,6 +4476,16 @@ dependencies = [
"libc",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tempfile"
version = "3.19.1"
@ -4545,7 +4750,7 @@ version = "0.22.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
dependencies = [
"indexmap",
"indexmap 2.8.0",
"serde",
"serde_spanned",
"toml_datetime",
@ -4609,6 +4814,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.9.0",
"bytes",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"iri-string",
"pin-project-lite",
"tower 0.5.2",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@ -5175,6 +5398,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.8"
@ -5263,7 +5495,7 @@ dependencies = [
"windows-interface",
"windows-link",
"windows-result",
"windows-strings 0.4.0",
"windows-strings",
]
[[package]]
@ -5296,13 +5528,13 @@ checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-registry"
version = "0.4.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
checksum = "ad1da3e436dc7653dfdf3da67332e22bff09bb0e28b0239e1624499c7830842e"
dependencies = [
"windows-link",
"windows-result",
"windows-strings 0.3.1",
"windows-targets 0.53.0",
"windows-strings",
]
[[package]]
@ -5314,15 +5546,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.4.0"
@ -5407,29 +5630,13 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b"
dependencies = [
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
"windows_i686_gnullvm 0.53.0",
"windows_i686_msvc 0.53.0",
"windows_x86_64_gnu 0.53.0",
"windows_x86_64_gnullvm 0.53.0",
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@ -5448,12 +5655,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@ -5472,12 +5673,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@ -5496,24 +5691,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@ -5532,12 +5715,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@ -5556,12 +5733,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@ -5580,12 +5751,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@ -5604,12 +5769,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.4"
@ -5774,6 +5933,12 @@ dependencies = [
"syn",
]
[[package]]
name = "zmij"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f4a4e8e9dc5c62d159f04fcdbe07f4c3fb710415aab4754bf11505501e3251d"
[[package]]
name = "zstd-safe"
version = "7.2.4"

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>