From 57523fa3397139354fc05968f3db16709abd94a9 Mon Sep 17 00:00:00 2001 From: alyssa Date: Sun, 4 Jan 2026 14:00:42 -0500 Subject: [PATCH] feat(premium): initial subscription implementation through paddle --- Cargo.lock | 413 +++++++++++++++------- crates/api/src/endpoints/private.rs | 2 +- crates/api/src/endpoints/system.rs | 2 +- crates/api/src/error.rs | 5 +- crates/libpk/src/_config.rs | 7 + crates/premium/Cargo.toml | 5 +- crates/premium/init.sql | 10 + crates/premium/src/auth.rs | 25 +- crates/premium/src/error.rs | 1 + crates/premium/src/main.rs | 57 +++- crates/premium/src/paddle.rs | 493 +++++++++++++++++++++++++++ crates/premium/src/system.rs | 67 ++++ crates/premium/src/web.rs | 24 +- crates/premium/templates/cancel.html | 29 ++ crates/premium/templates/index.html | 125 ++++++- 15 files changed, 1121 insertions(+), 144 deletions(-) create mode 100644 crates/premium/init.sql create mode 100644 crates/premium/src/error.rs create mode 100644 crates/premium/src/paddle.rs create mode 100644 crates/premium/src/system.rs create mode 100644 crates/premium/templates/cancel.html diff --git a/Cargo.lock b/Cargo.lock index 851a8c8c..653554a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/api/src/endpoints/private.rs b/crates/api/src/endpoints/private.rs index 8e3e3b45..56616d30 100644 --- a/crates/api/src/endpoints/private.rs +++ b/crates/api/src/endpoints/private.rs @@ -1,4 +1,4 @@ -use crate::{ApiContext, auth::AuthState, error::fail}; +use crate::{ApiContext, auth::AuthState, fail}; use axum::{ Extension, extract::{Path, State}, diff --git a/crates/api/src/endpoints/system.rs b/crates/api/src/endpoints/system.rs index 58be13a9..bd458bf9 100644 --- a/crates/api/src/endpoints/system.rs +++ b/crates/api/src/endpoints/system.rs @@ -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( diff --git a/crates/api/src/error.rs b/crates/api/src/error.rs index f13dbff2..acbfffbc 100644 --- a/crates/api/src/error.rs +++ b/crates/api/src/error.rs @@ -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)+) => {{ diff --git a/crates/libpk/src/_config.rs b/crates/libpk/src/_config.rs index 00d88ff8..d2fc7ef3 100644 --- a/crates/libpk/src/_config.rs +++ b/crates/libpk/src/_config.rs @@ -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, diff --git a/crates/premium/Cargo.toml b/crates/premium/Cargo.toml index bfc62ade..e0ec83cb 100644 --- a/crates/premium/Cargo.toml +++ b/crates/premium/Cargo.toml @@ -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" \ No newline at end of file +time = "0.3" diff --git a/crates/premium/init.sql b/crates/premium/init.sql new file mode 100644 index 00000000..a79c552a --- /dev/null +++ b/crates/premium/init.sql @@ -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) +) \ No newline at end of file diff --git a/crates/premium/src/auth.rs b/crates/premium/src/auth.rs index 2d3092d7..8f84f32c 100644 --- a/crates/premium/src/auth.rs +++ b/crates/premium/src/auth.rs @@ -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(), } } diff --git a/crates/premium/src/error.rs b/crates/premium/src/error.rs new file mode 100644 index 00000000..95c8ba27 --- /dev/null +++ b/crates/premium/src/error.rs @@ -0,0 +1 @@ +pub use api::error::*; diff --git a/crates/premium/src/main.rs b/crates/premium/src/main.rs index c573a976..98c96c49 100644 --- a/crates/premium/src/main.rs +++ b/crates/premium/src/main.rs @@ -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, + Extension(session): Extension, +) -> 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| 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)) diff --git a/crates/premium/src/paddle.rs b/crates/premium/src/paddle.rs new file mode 100644 index 00000000..ace4b8f5 --- /dev/null +++ b/crates/premium/src/paddle.rs @@ -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 { + 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> { + 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> { + 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, + pub system_hid: Option, + pub system_name: Option, + pub status: Option, + pub next_renewal_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SubscriptionInfo { + pub db: Option, + pub paddle: Option, +} + +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!("{} ({})", escaped_name, hid); + } + return format!("{}", 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> { + let db_subs = get_subscriptions_by_email(ctx, email).await?; + + let mut paddle_subs: Vec = 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 = Vec::new(); + let mut found_ids: HashSet = 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 = 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::( + 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::( + 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, 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 { + let result = PADDLE_CLIENT + .subscription_cancel(subscription_id) + .send() + .await?; + Ok(result.data) +} + +#[api_endpoint] +pub async fn cancel( + State(ctx): State, + axum::Extension(session): axum::Extension, + axum::Form(form): axum::Form, +) -> 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, + axum::Extension(session): axum::Extension, + axum::extract::Query(query): axum::extract::Query, +) -> 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() +} diff --git a/crates/premium/src/system.rs b/crates/premium/src/system.rs new file mode 100644 index 00000000..0341f920 --- /dev/null +++ b/crates/premium/src/system.rs @@ -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, + Extension(session): Extension, + Json(body): Json, +) -> 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() +} diff --git a/crates/premium/src/web.rs b/crates/premium/src/web.rs index ba610037..363996f6 100644 --- a/crates/premium/src/web.rs +++ b/crates/premium/src/web.rs @@ -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) -> 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, pub show_login_form: bool, pub message: Option, + pub subscriptions: Vec, + pub paddle: Option, +} + +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, } diff --git a/crates/premium/templates/cancel.html b/crates/premium/templates/cancel.html new file mode 100644 index 00000000..222d8bdb --- /dev/null +++ b/crates/premium/templates/cancel.html @@ -0,0 +1,29 @@ + + + Cancel Subscription - PluralKit Premium + + + +

PluralKit Premium

+ +{% if subscription.is_cancellable() %} +

Cancel Subscription

+ +

Are you sure you want to cancel subscription {{ subscription.subscription_id() }}?

+

Your subscription will remain active until the end of the current billing period.

+ +
+ + + + +
+{% else %} +

This subscription ({{ subscription.subscription_id() }}) has already been canceled and will end on {{ subscription.next_renewal() }}.

+ + +{% endif %} + +

+

for assistance please email us at billing@pluralkit.me

+ diff --git a/crates/premium/templates/index.html b/crates/premium/templates/index.html index df99e357..687707ed 100644 --- a/crates/premium/templates/index.html +++ b/crates/premium/templates/index.html @@ -2,6 +2,7 @@ PluralKit Premium +

PluralKit Premium

@@ -9,9 +10,126 @@ {% if let Some(session) = session %}
-

logged in as {{ session.email }}.

- +

+ logged in as {{ session.email }}. + +

+
+ +{% if subscriptions.is_empty() %} +

You are not currently subscribed to PluralKit Premium.

+

Enter your system token to subscribe. yes this will be fixed before release

+
+ + +
+ + +{% else %} +You are currently subscribed to PluralKit Premium. Thanks for the support! +
+{% for sub in &subscriptions %} +

+ Subscription ID: {{ sub.subscription_id() }}
+ Status: {{ sub.status() }}
+ Next Renewal: {{ sub.next_renewal() }}
+ Linked System: {{ sub.system_id_display()|safe }}
+ {% if sub.is_cancellable() %} + Cancel
+ {% endif %} + +{% endfor %} +{% endif %} + +{% if let Some(paddle) = paddle %} + +{% else %} +error initializing paddle client +{% endif %} + {% endif %} {% if show_login_form %} @@ -26,4 +144,7 @@ {% if let Some(msg) = message %}

{{ msg }}
{% endif %} + +

+

for assistance please email us at billing@pluralkit.me