From 161ae5fa5b30b3bd6b104c27de4ede4e1b615fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:03:23 -0500 Subject: [PATCH 1/3] chore: bump to async-stripe 1.x --- Cargo.lock | 393 +++++----- Cargo.toml | 11 +- apps/labrinth/Cargo.toml | 19 +- apps/labrinth/src/queue/billing.rs | 54 +- apps/labrinth/src/routes/internal/billing.rs | 740 ++++++++---------- .../src/routes/internal/billing/payments.rs | 389 +++++---- apps/labrinth/src/routes/internal/flows.rs | 19 +- apps/labrinth/src/util/anrok.rs | 6 +- 8 files changed, 812 insertions(+), 819 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 365cc0a37f8..3081a1b8d09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -818,23 +818,12 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener 5.4.2", + "event-listener", "event-listener-strategy", "futures-core", "pin-project-lite", ] -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -867,8 +856,8 @@ checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", - "fastrand 2.5.0", - "futures-lite 2.6.1", + "fastrand", + "futures-lite", "pin-project-lite", "slab", ] @@ -881,7 +870,7 @@ checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", "blocking", - "futures-lite 2.6.1", + "futures-lite", ] [[package]] @@ -894,7 +883,7 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite 2.6.1", + "futures-lite", "parking", "polling", "rustix 1.1.5", @@ -908,7 +897,7 @@ version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 5.4.2", + "event-listener", "event-listener-strategy", "pin-project-lite", ] @@ -933,15 +922,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "async-channel 2.5.0", + "async-channel", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener 5.4.2", - "futures-lite 2.6.1", + "event-listener", + "futures-lite", "rustix 1.1.5", ] @@ -998,26 +987,111 @@ dependencies = [ [[package]] name = "async-stripe" -version = "0.41.0" +version = "1.0.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecbddf002ad7a13d2041eadf1b234cb3f57653ffdd901a01bc3f1c65aa77440" +checksum = "f39cb2e472b909f60e10d399503515bdb502e4af64d65e67769d5dc1e2a04a68" dependencies = [ - "chrono", + "async-stripe-client-core", + "async-stripe-shared", + "bytes", + "http-body-util", + "hyper 1.12.0", + "hyper-rustls 0.27.10", + "hyper-util", + "miniserde", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "async-stripe-client-core" +version = "1.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b972760651b9f655d42fd98fd67b391161c1efb1511efadb1e8ebb1827ae2f" +dependencies = [ + "async-stripe-shared", + "async-stripe-types", + "bytes", "futures-util", - "hex", - "hmac 0.12.1", - "http-types", - "hyper 0.14.32", - "hyper-rustls 0.24.2", + "miniserde", "serde", "serde_json", - "serde_path_to_error", - "serde_qs 0.10.1", - "sha2 0.10.9", - "smart-default", + "serde_qs", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "async-stripe-core" +version = "1.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "265cd3600cb877213f94fae544252121f5561c8651e84b7a509728f4feed1102" +dependencies = [ + "async-stripe-client-core", + "async-stripe-shared", + "async-stripe-types", + "miniserde", + "serde", + "smol_str", + "tracing", +] + +[[package]] +name = "async-stripe-payment" +version = "1.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211a648449cdaa12ecc039afc5ae29c432178d14b8e6afd1004e12e0d36805f8" +dependencies = [ + "async-stripe-client-core", + "async-stripe-shared", + "async-stripe-types", + "miniserde", + "serde", + "smol_str", + "tracing", +] + +[[package]] +name = "async-stripe-shared" +version = "1.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aab3bb24923ab2651e1f92019779fea0d88634b0cddd78404cbffbfa58238ef" +dependencies = [ + "async-stripe-types", + "miniserde", + "serde", + "smol_str", + "tracing", +] + +[[package]] +name = "async-stripe-types" +version = "1.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97f16db6218479d22bdc9eb78834b37cc25f579815d7e953bb1f28f5f673db66" +dependencies = [ + "miniserde", + "serde", "smol_str", - "thiserror 1.0.69", - "tokio", +] + +[[package]] +name = "async-stripe-webhook" +version = "1.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c294b1efeab8527801f1e547030915311a763a904acc3dbe14c86b59c096e88e" +dependencies = [ + "async-stripe-shared", + "async-stripe-types", + "chrono", + "hex", + "hmac 0.13.0", + "miniserde", + "serde", + "sha2 0.11.1", + "thiserror 2.0.21", + "tracing", ] [[package]] @@ -1064,7 +1138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed40ca66d45a6775511920324e4ae05c81673f9133c187c4970a3089c3ae27c" dependencies = [ "async-fs", - "futures-lite 2.6.1", + "futures-lite", "thiserror 2.0.21", ] @@ -1211,7 +1285,7 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.5.0", + "fastrand", "hex", "http 1.5.0", "sha1 0.10.7", @@ -1300,7 +1374,7 @@ dependencies = [ "aws-types", "bytes", "bytes-utils", - "fastrand 2.5.0", + "fastrand", "http 0.2.12", "http 1.5.0", "http-body 0.4.6", @@ -1330,7 +1404,7 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.5.0", + "fastrand", "http 1.5.0", "regex-lite", "tracing", @@ -1358,7 +1432,7 @@ dependencies = [ "aws-smithy-xml 0.60.15", "aws-types", "bytes", - "fastrand 2.5.0", + "fastrand", "hex", "hmac 0.12.1", "http 0.2.12", @@ -1391,7 +1465,7 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.5.0", + "fastrand", "http 1.5.0", "regex-lite", "tracing", @@ -1416,7 +1490,7 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.5.0", + "fastrand", "http 1.5.0", "regex-lite", "tracing", @@ -1442,7 +1516,7 @@ dependencies = [ "aws-smithy-types", "aws-smithy-xml 0.62.1", "aws-types", - "fastrand 2.5.0", + "fastrand", "http 1.5.0", "regex-lite", "tracing", @@ -1589,7 +1663,7 @@ dependencies = [ "pin-project-lite", "rustls 0.21.12", "rustls 0.23.46", - "rustls-native-certs 0.8.5", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls 0.26.6", @@ -1664,7 +1738,7 @@ dependencies = [ "aws-smithy-schema 0.2.1", "aws-smithy-types", "bytes", - "fastrand 2.5.0", + "fastrand", "http 1.5.0", "http-body 1.1.0", "http-body-util", @@ -1857,12 +1931,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -2063,10 +2131,10 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ - "async-channel 2.5.0", + "async-channel", "async-task", "futures-io", - "futures-lite 2.6.1", + "futures-lite", "piper", ] @@ -2117,8 +2185,8 @@ dependencies = [ "pin-project-lite", "rand 0.9.5", "rustls 0.23.46", - "rustls-native-certs 0.8.5", - "rustls-pemfile 2.2.0", + "rustls-native-certs", + "rustls-pemfile", "rustls-pki-types", "serde", "serde_derive", @@ -4385,12 +4453,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - [[package]] name = "event-listener" version = "5.4.2" @@ -4407,7 +4469,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.2", + "event-listener", "pin-project-lite", ] @@ -4473,15 +4535,6 @@ dependencies = [ "siphasher 1.0.4", ] -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - [[package]] name = "fastrand" version = "2.5.0" @@ -4794,28 +4847,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" -[[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand 1.9.0", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - [[package]] name = "futures-lite" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand 2.5.0", + "fastrand", "futures-core", "futures-io", "parking", @@ -5681,27 +5719,6 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2149c4b4b83390c71fab669730347d578ec6bdf759f2160c581ad8b8819733b4" -[[package]] -name = "http-types" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" -dependencies = [ - "anyhow", - "async-channel 1.9.0", - "base64 0.13.1", - "futures-lite 1.13.0", - "http 0.2.12", - "infer 0.2.3", - "pin-project-lite", - "rand 0.7.3", - "serde", - "serde_json", - "serde_qs 0.8.5", - "serde_urlencoded", - "url", -] - [[package]] name = "httparse" version = "1.10.1" @@ -5805,7 +5822,6 @@ dependencies = [ "hyper 0.14.32", "log", "rustls 0.21.12", - "rustls-native-certs 0.6.3", "tokio", "tokio-rustls 0.24.1", ] @@ -5821,7 +5837,7 @@ dependencies = [ "hyper-util", "log", "rustls 0.23.46", - "rustls-native-certs 0.8.5", + "rustls-native-certs", "tokio", "tokio-rustls 0.26.6", "tower-service", @@ -6211,12 +6227,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "infer" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" - [[package]] name = "infer" version = "0.19.0" @@ -6301,15 +6311,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "interpolate_name" version = "0.2.4" @@ -6732,6 +6733,11 @@ dependencies = [ "ariadne", "async-minecraft-ping", "async-stripe", + "async-stripe-core", + "async-stripe-payment", + "async-stripe-shared", + "async-stripe-types", + "async-stripe-webhook", "async-trait", "aws-sdk-s3", "base64 0.23.1", @@ -6773,6 +6779,7 @@ dependencies = [ "lingua", "linkify", "minecraft-meta-model", + "miniserde", "modrinth-content-management", "modrinth-util", "muralpay", @@ -6872,7 +6879,7 @@ dependencies = [ "base64 0.23.1", "email-encoding", "email_address", - "fastrand 2.5.0", + "fastrand", "futures-io", "futures-util", "hostname", @@ -6883,7 +6890,7 @@ dependencies = [ "percent-encoding", "quoted_printable", "rustls 0.23.46", - "rustls-native-certs 0.8.5", + "rustls-native-certs", "socket2 0.6.5", "tokio", "tokio-rustls 0.26.6", @@ -7021,7 +7028,7 @@ checksum = "f40d9129bb9fe42c95d1bd420d6891607eaff17df16ee15674aed2d05b0ec8f4" dependencies = [ "counter", "dashmap", - "fastrand 2.5.0", + "fastrand", "fst", "include_dir", "itertools 0.14.0", @@ -8205,6 +8212,17 @@ dependencies = [ "uuid 1.28.0", ] +[[package]] +name = "mini-internal" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34dea53b00d87f21b1535cbef93765c75f159dbbfeef16152f0b6b3bf28f3bb6" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "minidom" version = "0.16.0" @@ -8220,6 +8238,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniserde" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f82621301d3b99a1542736de96e5df69865b235987d6470b28c7aa670cfeb1" +dependencies = [ + "itoa", + "mini-internal", + "zmij", +] + [[package]] name = "minisign-verify" version = "0.2.5" @@ -8443,10 +8472,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.2.1", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 3.7.0", + "security-framework", "security-framework-sys", "tempfile", ] @@ -9065,12 +9094,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -9592,7 +9615,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "fastrand 2.5.0", + "fastrand", "phf_shared 0.13.1", ] @@ -9602,7 +9625,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ - "fastrand 2.5.0", + "fastrand", "phf_shared 0.14.0", ] @@ -9730,7 +9753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", - "fastrand 2.5.0", + "fastrand", "futures-io", ] @@ -11388,37 +11411,16 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" -dependencies = [ - "openssl-probe 0.1.6", - "rustls-pemfile 1.0.4", - "schannel", - "security-framework 2.11.1", -] - [[package]] name = "rustls-native-certs" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.7.0", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", + "security-framework", ] [[package]] @@ -11452,10 +11454,10 @@ dependencies = [ "log", "once_cell", "rustls 0.23.46", - "rustls-native-certs 0.8.5", + "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki 0.103.16", - "security-framework 3.7.0", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -11694,19 +11696,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.2", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -12056,24 +12045,14 @@ dependencies = [ [[package]] name = "serde_qs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" -dependencies = [ - "percent-encoding", - "serde", - "thiserror 1.0.69", -] - -[[package]] -name = "serde_qs" -version = "0.10.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cac3f1e2ca2fe333923a1ae72caca910b98ed0630bb35ef6f8c8517d6e81afa" +checksum = "475b9310e272827d34113bf8679b6dfb7fa0023ee049764ba003f8f984e8b5d1" dependencies = [ + "itoa", "percent-encoding", + "ryu", "serde", - "thiserror 1.0.69", ] [[package]] @@ -12432,24 +12411,14 @@ dependencies = [ "serde", ] -[[package]] -name = "smart-default" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133659a15339456eeeb07572eb02a91c91e9815e9cbc89566944d2c8d3efdbf6" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "smol_str" -version = "0.1.24" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" +checksum = "eeb7109d23f319619d4cc0de5464613c8b7b1a09db2774a58101459cf6be2f10" dependencies = [ - "serde", + "borsh", + "serde_core", ] [[package]] @@ -12608,7 +12577,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener 5.4.2", + "event-listener", "futures-core", "futures-intrusive", "futures-io", @@ -13689,7 +13658,7 @@ version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand 2.5.0", + "fastrand", "getrandom 0.4.3", "once_cell", "rustix 1.1.5", @@ -14223,7 +14192,7 @@ dependencies = [ "postgres-types", "rust_decimal", "rustls 0.23.46", - "rustls-pemfile 2.2.0", + "rustls-pemfile", "rustls-platform-verifier", "rustls-webpki 0.103.16", "toasty-core", @@ -15619,12 +15588,6 @@ dependencies = [ "libc", ] -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - [[package]] name = "walkdir" version = "2.5.0" @@ -16930,9 +16893,9 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener 5.4.2", + "event-listener", "futures-core", - "futures-lite 2.6.1", + "futures-lite", "hex", "libc", "ordered-stream", diff --git a/Cargo.toml b/Cargo.toml index a01116a0d68..d62fcba9879 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,9 +43,15 @@ ariadne = { path = "packages/ariadne" } async-compression = "0.4.32" async-minecraft-ping = { path = "packages/async-minecraft-ping" } async-recursion = "1.2.0" -async-stripe = { version = "0.41.0", default-features = false, features = [ - "runtime-tokio-hyper-rustls", +async-stripe = { version = "=1.0.0-rc.10", default-features = false, features = [ + "rustls-aws-lc-rs", + "rustls-tls-webpki-roots", ] } +async-stripe-core = { version = "=1.0.0-rc.10", default-features = false } +async-stripe-payment = { version = "=1.0.0-rc.10", default-features = false } +async-stripe-shared = { version = "=1.0.0-rc.10", default-features = false } +async-stripe-types = { version = "=1.0.0-rc.10", default-features = false } +async-stripe-webhook = { version = "=1.0.0-rc.10", default-features = false } async-trait = "0.1.92" async-tungstenite = { version = "0.35.0", default-features = false, features = ["futures-03-sink"] } async-walkdir = "2.2.0" @@ -143,6 +149,7 @@ lz4_flex = { version = "0.14.0", default-features = false, features = [ maxminddb = "0.32.0" minecraft-meta-manifest = { path = "packages/minecraft-meta-manifest" } minecraft-meta-model = { path = "packages/minecraft-meta-model" } +miniserde = "0.1.40" modrinth-content-management = { path = "packages/modrinth-content-management" } modrinth-log = { path = "packages/modrinth-log" } modrinth-util = { path = "packages/modrinth-util" } diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 3597a506fb3..53c57c477ee 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -23,7 +23,23 @@ arc-swap = { workspace = true } argon2 = { workspace = true } ariadne = { workspace = true } async-minecraft-ping = { workspace = true, features = ["srv"] } -async-stripe = { workspace = true, features = ["billing", "checkout", "connect", "webhook-events"] } +async-stripe = { workspace = true } +async-stripe-core = { workspace = true, features = [ + "charge", + "customer", + "payment_intent", + "refund", + "serialize", + "setup_intent", +] } +async-stripe-payment = { workspace = true, features = [ + "confirmation_token", + "payment_method", + "serialize", +] } +async-stripe-shared = { workspace = true, features = ["serialize"] } +async-stripe-types = { workspace = true, features = ["serialize"] } +async-stripe-webhook = { workspace = true } async-trait = { workspace = true } aws-sdk-s3 = { workspace = true } base64 = { workspace = true } @@ -77,6 +93,7 @@ lettre = { workspace = true } lingua = { workspace = true } linkify = { workspace = true } minecraft-meta-model = { workspace = true } +miniserde = { workspace = true } modrinth-content-management = { workspace = true } modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] } muralpay = { workspace = true, features = ["client", "mock", "utoipa"] } diff --git a/apps/labrinth/src/queue/billing.rs b/apps/labrinth/src/queue/billing.rs index eeb78a6caf3..59a65e52edd 100644 --- a/apps/labrinth/src/queue/billing.rs +++ b/apps/labrinth/src/queue/billing.rs @@ -30,9 +30,10 @@ use chrono::Utc; use futures::FutureExt; use futures::stream::{FuturesUnordered, StreamExt}; use std::collections::HashSet; -use std::str::FromStr; use std::time::Instant; -use stripe::{self, Currency}; +use stripe_core::payment_intent::RetrievePaymentIntent; +use stripe_core::refund::RetrieveRefund; +use stripe_shared::{CustomerId, PaymentIntentId, RefundId}; use tracing::{debug, error, info, warn}; use xredis::RedisPool; @@ -205,7 +206,7 @@ async fn update_anrok_transactions( let (pi, tax_platform_id) = if c.type_ == ChargeType::Refund { // the payment_platform_id should be an re or a pyr - let refund_id: stripe::RefundId = c + let refund_id: RefundId = c .payment_platform_id .as_ref() .and_then(|x| x.parse().ok()) @@ -214,15 +215,13 @@ async fn update_anrok_transactions( .to_owned() })?; - let refund = stripe::Refund::retrieve( - stripe_client, - &refund_id, - &["payment_intent.payment_method"], - ) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + let refund = RetrieveRefund::new(refund_id.clone()) + .expand(["payment_intent.payment_method".to_owned()]) + .send(stripe_client) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let pi = refund .payment_intent @@ -233,7 +232,7 @@ async fn update_anrok_transactions( (pi, anrok::transaction_id_stripe_pyr(&refund_id)) } else { - let stripe_id: stripe::PaymentIntentId = c + let stripe_id: PaymentIntentId = c .payment_platform_id .as_ref() .and_then(|x| x.parse().ok()) @@ -243,15 +242,13 @@ async fn update_anrok_transactions( // Attempt retrieving the address via the payment intent's payment method - let pi = stripe::PaymentIntent::retrieve( - stripe_client, - &stripe_id, - &["payment_method"], - ) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + let pi = RetrievePaymentIntent::new(stripe_id.clone()) + .expand(["payment_method".to_owned()]) + .send(stripe_client) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let anrok_id = anrok::transaction_id_stripe_pi(&stripe_id); @@ -271,7 +268,7 @@ async fn update_anrok_transactions( .stripe_customer_id .wrap_request_err("finding Stripe customer ID on user")?; - let customer_id = stripe_customer_id + let customer_id: CustomerId = stripe_customer_id .parse() .wrap_request_err("parsing request value")?; @@ -282,17 +279,13 @@ async fn update_anrok_transactions( None => { warn!( "A PaymentMethod for '{:?}' has no address; falling back to the customer's address", - pi.customer.map(|x| x.id()) + pi.customer.map(|x| x.into_id()) ); } }; let customer = - stripe::Customer::retrieve(stripe_client, &customer_id, &[]) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + retrieve_customer(stripe_client, &customer_id, &[]).await?; let Some(address) = customer.address else { // We won't really be able to do anything about this. @@ -685,8 +678,7 @@ pub async fn process_chargeable_charges( continue; }; - let Ok(currency) = - Currency::from_str(&product_price.currency_code.to_lowercase()) + let Some(currency) = parse_currency(&product_price.currency_code) else { warn!( charge.id = charge.id.0, diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 5162f0c8b55..92e03efff2e 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -25,6 +25,7 @@ use crate::util::MinecraftMeta; use crate::util::anrok; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; +use ::stripe_webhook::{EventObject, Webhook, WebhookError}; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::base62_impl::{parse_base62, to_base62}; use chrono::{Duration, Utc}; @@ -33,12 +34,17 @@ use rust_decimal::prelude::ToPrimitive; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::str::FromStr; -use stripe::{ - CreateRefund, CreateSetupIntent, CreateSetupIntentAutomaticPaymentMethods, +use stripe_core::charge::RetrieveCharge; +use stripe_core::payment_intent::RetrievePaymentIntent; +use stripe_core::refund::CreateRefund; +use stripe_core::setup_intent::{ + CreateSetupIntent, CreateSetupIntentAutomaticPaymentMethods, CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, - CustomerPaymentMethodRetrieval, EventObject, EventType, PaymentIntentId, - PaymentMethodId, SetupIntent, Webhook, WebhookError, }; +use stripe_payment::payment_method::{ + DetachPaymentMethod, RetrievePaymentMethod, +}; +use stripe_shared::{CustomerId, PaymentIntentId, PaymentMethod, RefundId}; use tracing::{error, warn}; use xredis::RedisPool; @@ -273,10 +279,9 @@ pub async fn refund_charge( } else { match charge.payment_platform { PaymentPlatform::Stripe => { - if let Some(payment_platform_id) = - charge.payment_platform_id.and_then(|x| { - stripe::PaymentIntentId::from_str(&x).ok() - }) + if let Some(payment_platform_id) = charge + .payment_platform_id + .and_then(|x| PaymentIntentId::from_str(&x).ok()) { let mut metadata = HashMap::new(); @@ -289,11 +294,11 @@ pub async fn refund_charge( to_base62(charge.id.0 as u64), ); - let pi = stripe::PaymentIntent::retrieve( - &stripe_client, - &payment_platform_id, - &["payment_method"], + let pi = RetrievePaymentIntent::new( + payment_platform_id.clone(), ) + .expand(["payment_method".to_owned()]) + .send(&**stripe_client) .await .wrap_failed_dependency_err( "communicating with payment provider", @@ -343,22 +348,16 @@ pub async fn refund_charge( ))); }; - let refund = stripe::Refund::create( - &stripe_client, - CreateRefund { - amount: Some(refund_amount), - metadata: Some(metadata), - payment_intent: Some(payment_platform_id), - - expand: &["balance_transaction"], - - ..Default::default() - }, - ) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + let refund = CreateRefund::new() + .amount(refund_amount) + .metadata(metadata) + .payment_intent(payment_platform_id.as_str()) + .expand(["balance_transaction".to_owned()]) + .send(&**stripe_client) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let anrok_txn_result = anrok_client.negate_or_create_partial_negation( original_tax_platform_id, @@ -553,21 +552,19 @@ pub async fn reprocess_charge_tax( .wrap_internal_err_with( || "parent charge is missing a payment platform ID", )? - .parse::() + .parse::() .map_err(|err| eyre::eyre!(err)) .wrap_internal_err( "parent charge has an invalid payment platform ID.", )?; - let pi = stripe::PaymentIntent::retrieve( - &stripe_client, - &payment_platform_id, - &["payment_method"], - ) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + let pi = RetrievePaymentIntent::new(payment_platform_id) + .expand(["payment_method".to_owned()]) + .send(&**stripe_client) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let Some(billing_address) = pi .payment_method @@ -611,7 +608,7 @@ pub async fn reprocess_charge_tax( || "refund charge is missing a payment platform ID!", )?; - let refund_id = stripe::RefundId::from_str(&refund_id) + let refund_id = RefundId::from_str(&refund_id) .map_err(|err| eyre::eyre!(err)) .wrap_internal_err("invalid refund ID!")?; @@ -688,7 +685,7 @@ struct ResubscribePreview { pub currency_code: String, pub interval: Option, pub due: chrono::DateTime, - pub payment_method: stripe::PaymentMethod, + pub payment_method: PaymentMethod, } /// Recalculates the tax amount of a charge being resubscribed to, as the tax amount @@ -997,11 +994,8 @@ pub async fn edit_subscription( pub required_payment_is_proration: bool, } - let currency = stripe::Currency::from_str( - ¤t_price.currency_code.to_lowercase(), - ) - .map_err(|err| eyre::eyre!(err)) - .wrap_request_err("invalid currency code".to_string())?; + let currency = parse_currency(¤t_price.currency_code) + .wrap_request_err("invalid currency code".to_string())?; // The next charge is an expiring charge, so we are promoting the subscription to a paid product. // Instead of doing a proration (since the product is likely free) we either: @@ -1271,12 +1265,7 @@ pub async fn user_customer( ) .await .wrap_api_err("fetching or creating Stripe customer")?; - let customer = - stripe::Customer::retrieve(&stripe_client, &customer_id, &[]) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + let customer = retrieve_customer(&stripe_client, &customer_id, &[]).await?; Ok(HttpResponse::Ok().json(customer)) } @@ -1389,20 +1378,17 @@ pub async fn add_payment_method_flow( .await .wrap_api_err("fetching or creating Stripe customer")?; - let intent = SetupIntent::create( - &stripe_client, - CreateSetupIntent { - customer: Some(customer), - automatic_payment_methods: Some(CreateSetupIntentAutomaticPaymentMethods { - allow_redirects: Some( - CreateSetupIntentAutomaticPaymentMethodsAllowRedirects::Never, - ), - enabled: true, - }), - ..Default::default() - }, - ) - .await.wrap_failed_dependency_err("communicating with payment provider")?; + let mut automatic_payment_methods = + CreateSetupIntentAutomaticPaymentMethods::new(true); + automatic_payment_methods.allow_redirects = + Some(CreateSetupIntentAutomaticPaymentMethodsAllowRedirects::Never); + + let intent = CreateSetupIntent::new() + .customer(customer.as_str()) + .automatic_payment_methods(automatic_payment_methods) + .send(&**stripe_client) + .await + .wrap_failed_dependency_err("communicating with payment provider")?; Ok(HttpResponse::Ok().json(serde_json::json!({ "client_secret": intent.client_secret @@ -1442,7 +1428,7 @@ pub async fn edit_payment_method( let (id,) = info.into_inner(); - let Ok(payment_method_id) = PaymentMethodId::from_str(&id) else { + let Some(payment_method_id) = parse_payment_method_id(&id) else { return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; @@ -1457,15 +1443,15 @@ pub async fn edit_payment_method( .await .wrap_api_err("fetching or creating Stripe customer")?; - let payment_method = stripe::PaymentMethod::retrieve( - &stripe_client, - &payment_method_id, - &[], - ) - .await - .wrap_failed_dependency_err("communicating with payment provider")?; + let payment_method = RetrievePaymentMethod::new(payment_method_id) + .send(&**stripe_client) + .await + .wrap_failed_dependency_err("communicating with payment provider")?; - if payment_method.customer.is_some_and(|x| x.id() == customer) + if payment_method + .customer + .as_ref() + .is_some_and(|x| *x.id() == customer) || user.role.is_admin() { set_default_payment_method( @@ -1509,7 +1495,7 @@ pub async fn remove_payment_method( let (id,) = info.into_inner(); - let Ok(payment_method_id) = PaymentMethodId::from_str(&id) else { + let Some(payment_method_id) = parse_payment_method_id(&id) else { return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; @@ -1524,13 +1510,10 @@ pub async fn remove_payment_method( .await .wrap_api_err("fetching or creating Stripe customer")?; - let payment_method = stripe::PaymentMethod::retrieve( - &stripe_client, - &payment_method_id, - &[], - ) - .await - .wrap_failed_dependency_err("communicating with payment provider")?; + let payment_method = RetrievePaymentMethod::new(payment_method_id.clone()) + .send(&**stripe_client) + .await + .wrap_failed_dependency_err("communicating with payment provider")?; let user_subscriptions = user_subscription_item::DBUserSubscription::get_all_user( @@ -1545,17 +1528,13 @@ pub async fn remove_payment_method( .any(|x| x.status != SubscriptionStatus::Unprovisioned) { let customer = - stripe::Customer::retrieve(&stripe_client, &customer, &[]) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + retrieve_customer(&stripe_client, &customer, &[]).await?; if customer .invoice_settings .and_then(|x| { x.default_payment_method - .map(|x| x.id() == payment_method_id) + .map(|x| *x.id() == payment_method_id) }) .unwrap_or(false) { @@ -1568,16 +1547,17 @@ pub async fn remove_payment_method( if payment_method .customer .as_ref() - .is_some_and(|x| x.id() == customer) + .is_some_and(|x| *x.id() == customer) || user.role.is_admin() { - stripe::PaymentMethod::detach(&stripe_client, &payment_method_id) + DetachPaymentMethod::new(payment_method_id.clone()) + .send(&**stripe_client) .await .wrap_failed_dependency_err( "communicating with payment provider", )?; - if let Some(owner_id) = payment_method.customer.map(|x| x.id()) + if let Some(owner_id) = payment_method.customer.map(|x| x.into_id()) && let Err(err) = promote_default_payment_method( &stripe_client, &owner_id, @@ -1630,20 +1610,12 @@ pub async fn payment_methods( if let Some(customer_id) = user .stripe_customer_id .as_ref() - .and_then(|x| stripe::CustomerId::from_str(x).ok()) + .and_then(|x| CustomerId::from_str(x).ok()) { - let methods = stripe::Customer::retrieve_payment_methods( - &stripe_client, - &customer_id, - CustomerPaymentMethodRetrieval { - limit: Some(100), - ..Default::default() - }, - ) - .await - .wrap_failed_dependency_err("communicating with payment provider")?; + let methods = + list_payment_methods(&stripe_client, &customer_id, 100).await?; - Ok(HttpResponse::Ok().json(methods.data)) + Ok(HttpResponse::Ok().json(methods)) } else { Ok(HttpResponse::NoContent().finish()) } @@ -1726,7 +1698,7 @@ pub enum PaymentRequestType { impl PaymentRequestType { pub fn from_stripe_id(id: String) -> Option { let prefix = id.split_at(id.split_once('_')?.0.len() + 1).0; - if stripe::PaymentMethodId::is_valid_prefix(prefix) { + if PAYMENT_METHOD_ID_PREFIXES.contains(&prefix) { Some(Self::PaymentMethod { id }) } else if prefix == "ctoken_" { Some(Self::ConfirmationToken { token: id }) @@ -1771,8 +1743,7 @@ pub struct PaymentRequest { #[serde(flatten)] pub type_: PaymentRequestType, pub charge: ChargeRequestType, - #[schema(value_type = String)] - pub existing_payment_intent: Option, + pub existing_payment_intent: Option, pub metadata: Option, } @@ -1805,6 +1776,14 @@ pub async fn initiate_payment( let payment_request = payment_request.into_inner(); + let existing_payment_intent = payment_request + .existing_payment_intent + .map(|id| { + parse_stripe_id::(&id, &["pi_"]) + .wrap_request_err("invalid existing payment intent id") + }) + .transpose()?; + let results = create_or_update_payment_intent( &pool, &redis, @@ -1812,7 +1791,7 @@ pub async fn initiate_payment( &anrok_client, PaymentBootstrapOptions { user: &user, - payment_intent: payment_request.existing_payment_intent, + payment_intent: existing_payment_intent, payment_session: PaymentSession::Interactive { payment_request_type: payment_request.type_, }, @@ -2189,7 +2168,10 @@ pub async fn stripe_webhook( // Labrinth always attaches `modrinth_*` metadata when creating a payment // intent, so one without any was created elsewhere on the Stripe account. - if let EventObject::PaymentIntent(payment_intent) = &event.data.object + if let EventObject::PaymentIntentSucceeded(payment_intent) + | EventObject::PaymentIntentProcessing(payment_intent) + | EventObject::PaymentIntentPaymentFailed(payment_intent) = + &event.data.object && !payment_intent .metadata .keys() @@ -2203,90 +2185,59 @@ pub async fn stripe_webhook( return Ok(HttpResponse::Ok().finish()); } - match event.type_ { - EventType::PaymentIntentSucceeded => { - if let EventObject::PaymentIntent(payment_intent) = - event.data.object - { - let mut transaction = pool - .begin() - .await - .wrap_internal_err("starting database transaction")?; - - let mut metadata = get_payment_intent_metadata( - payment_intent.id, - payment_intent.amount, - payment_intent.currency.to_string().to_uppercase(), - payment_intent.metadata, - &pool, - &redis, - ChargeStatus::Succeeded, - &mut transaction, - ) + match event.data.object { + EventObject::PaymentIntentSucceeded(payment_intent) => { + let mut transaction = pool + .begin() .await - .wrap_api_err("fetching payment intent metadata")?; - - if let Some(latest_charge) = payment_intent.latest_charge { - /// The subset of a Stripe charge that we read. - /// - /// `stripe::Charge` fails to deserialize when Stripe - /// sends an enum value `async-stripe` doesn't know, - /// such as a new 3D Secure version, so we don't parse - /// fields we don't use. - #[derive(Deserialize)] - struct ChargeBalanceTransaction { - balance_transaction: Option, - } - - #[derive(Deserialize)] - struct BalanceTransactionNet { - net: i64, - } + .wrap_internal_err("starting database transaction")?; + + let mut metadata = get_payment_intent_metadata( + payment_intent.id, + payment_intent.amount, + payment_intent.currency.to_string().to_uppercase(), + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Succeeded, + &mut transaction, + ) + .await + .wrap_api_err("fetching payment intent metadata")?; - #[derive(Serialize)] - struct ExpandParams { - expand: &'static [&'static str], - } + if let Some(latest_charge) = payment_intent.latest_charge { + let charge = RetrieveCharge::new(latest_charge.into_id()) + .expand(["balance_transaction".to_owned()]) + .send(&**stripe_client) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; - let charge: ChargeBalanceTransaction = stripe_client - .get_query( - &format!("/charges/{}", latest_charge.id()), - ExpandParams { - expand: &["balance_transaction"], - }, - ) + if let Some(balance_transaction) = + charge.balance_transaction.and_then(|x| x.into_object()) + { + metadata.charge_item.net = + Some(balance_transaction.net); + metadata + .charge_item + .upsert(&mut transaction) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; - - if let Some(balance_transaction) = - charge.balance_transaction - { - metadata.charge_item.net = - Some(balance_transaction.net); - metadata - .charge_item - .upsert(&mut transaction) - .await - .wrap_internal_err("upserting charge")?; - } + .wrap_internal_err("upserting charge")?; } + } - // Provision subscription - match metadata.product_item.metadata { - // A payment shouldn't be processed for Medal subscriptions. - ProductMetadata::Medal { .. } => { - warn!( - "A payment processed for a free subscription" - ); - } + // Provision subscription + match metadata.product_item.metadata { + // A payment shouldn't be processed for Medal subscriptions. + ProductMetadata::Medal { .. } => { + warn!("A payment processed for a free subscription"); + } - ProductMetadata::Midas => { - let badges = - metadata.user_item.badges | Badges::MIDAS; + ProductMetadata::Midas => { + let badges = metadata.user_item.badges | Badges::MIDAS; - sqlx::query!( + sqlx::query!( " UPDATE users SET badges = $1 @@ -2299,19 +2250,19 @@ pub async fn stripe_webhook( .execute(&mut transaction) .await .wrap_internal_err("querying database for `get_payment_intent_metadata`")?; - } - ProductMetadata::Pyro { - ram, - cpu, - swap, - storage, - } => { - if let Some(ref mut subscription) = - metadata.user_subscription_item - { - let client = reqwest::Client::new(); + } + ProductMetadata::Pyro { + ram, + cpu, + swap, + storage, + } => { + if let Some(ref mut subscription) = + metadata.user_subscription_item + { + let client = reqwest::Client::new(); - if let Some( + if let Some( subscription_metadata @ ( SubscriptionMetadata::Pyro { id, region: _ } | SubscriptionMetadata::Medal { id } @@ -2469,21 +2420,20 @@ pub async fn stripe_webhook( }); } } - } } } + } - if let Some(mut subscription) = - metadata.user_subscription_item - { - let open_charge = DBCharge::get_open_subscription( - subscription.id, - &mut transaction, - ) - .await - .wrap_internal_err("fetching charge from database")?; + if let Some(mut subscription) = metadata.user_subscription_item + { + let open_charge = DBCharge::get_open_subscription( + subscription.id, + &mut transaction, + ) + .await + .wrap_internal_err("fetching charge from database")?; - let new_price = match metadata.product_price_item.prices { + let new_price = match metadata.product_price_item.prices { Price::OneTime { price } => price, Price::Recurring { intervals } => { *intervals.get(&subscription.interval).wrap_internal_err_with(|| "could not find a valid price for the user's country" @@ -2491,224 +2441,192 @@ pub async fn stripe_webhook( } }; - // If the next open charge is actually an expiring charge, - // this means the subscription was promoted from a temporary - // free subscription to a paid subscription. - // - // In this case, we need to modify this expiring charge to be the - // next charge of the subscription, turn it into a normal open charge. - // - // Otherwise, if there *is* an open charge, the subscription was upgraded - // and the just-processed payment was the proration charge. In this case, - // the existing open charge must be updated to reflect the new product's price. - // The subscription interval was updated above. - // - // If there are no open charges, the just-processed payment was a recurring - // or initial subscription charge, and we need to create the next charge. - if let Some(mut charge) = open_charge { - if charge.status == ChargeStatus::Expiring { - charge.status = ChargeStatus::Open; - charge.due = Utc::now() - + subscription.interval.duration(); - charge.payment_platform = - PaymentPlatform::Stripe; - charge.last_attempt = None; - charge.subscription_interval = - Some(subscription.interval); - charge.amount = new_price as i64; - charge.price_id = - metadata.product_price_item.id; - charge.tax_last_updated = None; - } else { - // Note: do not update the due date - charge.subscription_interval = - Some(subscription.interval); - charge.price_id = - metadata.product_price_item.id; - charge.amount = new_price as i64; - } - charge - .upsert(&mut transaction) - .await - .wrap_internal_err("upserting charge")?; - } else if metadata.charge_item.status - != ChargeStatus::Cancelled - { - let charge_id = - generate_charge_id(&mut transaction) - .await - .wrap_internal_err( - "generating charge ID", - )?; - DBCharge { - id: charge_id, - user_id: metadata.user_item.id, - price_id: metadata.product_price_item.id, - amount: new_price as i64, - tax_amount: metadata.next_tax_amount, - currency_code: metadata - .product_price_item - .currency_code, - status: ChargeStatus::Open, - due: if subscription.status - == SubscriptionStatus::Unprovisioned - { - Utc::now() - + subscription.interval.duration() - } else { - metadata.charge_item.due - + subscription.interval.duration() - }, - last_attempt: None, - type_: ChargeType::Subscription, - subscription_id: Some(subscription.id), - subscription_interval: Some( - subscription.interval, - ), - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: None, - parent_charge_id: None, - net: None, - tax_platform_id: None, - tax_last_updated: Some(Utc::now()), - tax_drift_loss: Some(0), - tax_transaction_version: None, - tax_platform_accounting_time: None, - } + // If the next open charge is actually an expiring charge, + // this means the subscription was promoted from a temporary + // free subscription to a paid subscription. + // + // In this case, we need to modify this expiring charge to be the + // next charge of the subscription, turn it into a normal open charge. + // + // Otherwise, if there *is* an open charge, the subscription was upgraded + // and the just-processed payment was the proration charge. In this case, + // the existing open charge must be updated to reflect the new product's price. + // The subscription interval was updated above. + // + // If there are no open charges, the just-processed payment was a recurring + // or initial subscription charge, and we need to create the next charge. + if let Some(mut charge) = open_charge { + if charge.status == ChargeStatus::Expiring { + charge.status = ChargeStatus::Open; + charge.due = + Utc::now() + subscription.interval.duration(); + charge.payment_platform = PaymentPlatform::Stripe; + charge.last_attempt = None; + charge.subscription_interval = + Some(subscription.interval); + charge.amount = new_price as i64; + charge.price_id = metadata.product_price_item.id; + charge.tax_last_updated = None; + } else { + // Note: do not update the due date + charge.subscription_interval = + Some(subscription.interval); + charge.price_id = metadata.product_price_item.id; + charge.amount = new_price as i64; + } + charge .upsert(&mut transaction) .await - .wrap_internal_err( - "upserting subscription charge", - )?; - }; - - subscription.status = SubscriptionStatus::Provisioned; - subscription - .upsert(&mut transaction) + .wrap_internal_err("upserting charge")?; + } else if metadata.charge_item.status + != ChargeStatus::Cancelled + { + let charge_id = generate_charge_id(&mut transaction) .await - .wrap_internal_err( - "updating subscription in database", - )?; - } + .wrap_internal_err("generating charge ID")?; + DBCharge { + id: charge_id, + user_id: metadata.user_item.id, + price_id: metadata.product_price_item.id, + amount: new_price as i64, + tax_amount: metadata.next_tax_amount, + currency_code: metadata + .product_price_item + .currency_code, + status: ChargeStatus::Open, + due: if subscription.status + == SubscriptionStatus::Unprovisioned + { + Utc::now() + subscription.interval.duration() + } else { + metadata.charge_item.due + + subscription.interval.duration() + }, + last_attempt: None, + type_: ChargeType::Subscription, + subscription_id: Some(subscription.id), + subscription_interval: Some(subscription.interval), + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: None, + parent_charge_id: None, + net: None, + tax_platform_id: None, + tax_last_updated: Some(Utc::now()), + tax_drift_loss: Some(0), + tax_transaction_version: None, + tax_platform_accounting_time: None, + } + .upsert(&mut transaction) + .await + .wrap_internal_err("upserting subscription charge")?; + }; - transaction - .commit() + subscription.status = SubscriptionStatus::Provisioned; + subscription + .upsert(&mut transaction) .await - .wrap_internal_err("committing database transaction")?; - crate::database::models::user_item::DBUser::clear_caches( - &[(metadata.user_item.id, None)], - &redis, - ) - .await - .wrap_internal_err("clearing cached data from Redis")?; + .wrap_internal_err( + "updating subscription in database", + )?; } + + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + crate::database::models::user_item::DBUser::clear_caches( + &[(metadata.user_item.id, None)], + &redis, + ) + .await + .wrap_internal_err("clearing cached data from Redis")?; } - EventType::PaymentIntentProcessing => { - if let EventObject::PaymentIntent(payment_intent) = - event.data.object - { - let mut transaction = pool - .begin() - .await - .wrap_internal_err("starting database transaction")?; - get_payment_intent_metadata( - payment_intent.id, - payment_intent.amount, - payment_intent.currency.to_string().to_uppercase(), - payment_intent.metadata, - &pool, - &redis, - ChargeStatus::Processing, - &mut transaction, - ) + EventObject::PaymentIntentProcessing(payment_intent) => { + let mut transaction = pool + .begin() .await - .wrap_api_err("fetching payment intent metadata")?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - } + .wrap_internal_err("starting database transaction")?; + get_payment_intent_metadata( + payment_intent.id, + payment_intent.amount, + payment_intent.currency.to_string().to_uppercase(), + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Processing, + &mut transaction, + ) + .await + .wrap_api_err("fetching payment intent metadata")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; } - EventType::PaymentIntentPaymentFailed => { - if let EventObject::PaymentIntent(payment_intent) = - event.data.object - { - let mut transaction = pool - .begin() - .await - .wrap_internal_err("starting database transaction")?; - - let metadata = get_payment_intent_metadata( - payment_intent.id, - payment_intent.amount, - payment_intent.currency.to_string().to_uppercase(), - payment_intent.metadata, - &pool, - &redis, - ChargeStatus::Failed, - &mut transaction, - ) + EventObject::PaymentIntentPaymentFailed(payment_intent) => { + let mut transaction = pool + .begin() .await - .wrap_api_err("fetching payment intent metadata")?; - - if metadata.user_item.email.is_some() { - let money = rusty_money::Money::from_minor( - metadata.charge_item.amount as i64, - rusty_money::iso::find( - &metadata.charge_item.currency_code, - ) - .unwrap_or(rusty_money::iso::USD), - ); + .wrap_internal_err("starting database transaction")?; + + let metadata = get_payment_intent_metadata( + payment_intent.id, + payment_intent.amount, + payment_intent.currency.to_string().to_uppercase(), + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Failed, + &mut transaction, + ) + .await + .wrap_api_err("fetching payment intent metadata")?; - NotificationBuilder { - body: NotificationBody::PaymentFailed { - amount: money.to_string(), - service: if metadata - .product_item - .metadata - .is_midas() - { - "Modrinth+" - } else if metadata - .product_item - .metadata - .is_pyro() - { - "Modrinth Hosting" - } else { - "a Modrinth product" - } - .to_owned(), - }, - } - .insert(metadata.user_item.id, &mut transaction, &redis) - .await - .wrap_internal_err( - "inserting payment failure notification", - )?; + if metadata.user_item.email.is_some() { + let money = rusty_money::Money::from_minor( + metadata.charge_item.amount as i64, + rusty_money::iso::find( + &metadata.charge_item.currency_code, + ) + .unwrap_or(rusty_money::iso::USD), + ); + + NotificationBuilder { + body: NotificationBody::PaymentFailed { + amount: money.to_string(), + service: if metadata + .product_item + .metadata + .is_midas() + { + "Modrinth+" + } else if metadata.product_item.metadata.is_pyro() { + "Modrinth Hosting" + } else { + "a Modrinth product" + } + .to_owned(), + }, } - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + .insert(metadata.user_item.id, &mut transaction, &redis) + .await + .wrap_internal_err( + "inserting payment failure notification", + )?; } + + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; } - EventType::PaymentMethodAttached => { - if let EventObject::PaymentMethod(payment_method) = - event.data.object - && let Some(customer_id) = - payment_method.customer.map(|x| x.id()) + EventObject::PaymentMethodAttached(payment_method) => { + if let Some(customer_id) = + payment_method.customer.map(|x| x.into_id()) { - let customer = stripe::Customer::retrieve( - &stripe_client, - &customer_id, - &[], - ) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + let customer = + retrieve_customer(&stripe_client, &customer_id, &[]) + .await?; if customer .invoice_settings @@ -2727,7 +2645,7 @@ pub async fn stripe_webhook( } } else if let Err(WebhookError::BadParse(err)) = result { return Err(ApiError::Internal( - eyre::Report::new(err).wrap_err("parsing webhook event"), + eyre::eyre!(err).wrap_err("parsing webhook event"), )); } else { return Err(ApiError::Request(eyre::eyre!( diff --git a/apps/labrinth/src/routes/internal/billing/payments.rs b/apps/labrinth/src/routes/internal/billing/payments.rs index 579e8780823..baa268c25a7 100644 --- a/apps/labrinth/src/routes/internal/billing/payments.rs +++ b/apps/labrinth/src/routes/internal/billing/payments.rs @@ -14,15 +14,24 @@ use xredis::RedisPool; use crate::database::PgPool; use ariadne::ids::base62_impl::to_base62; use ariadne::ids::*; -use serde::Deserialize; use std::collections::HashMap; use std::str::FromStr; -use stripe::{ - self, CreateCustomer, CreatePaymentIntent, Currency, CustomerId, - CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, - PaymentIntentOffSession, PaymentIntentSetupFutureUsage, PaymentMethod, - PaymentMethodId, UpdateCustomer, +use stripe_core::customer::{ + CreateCustomer, ListPaymentMethodsCustomer, RetrieveCustomer, + RetrieveCustomerReturned, UpdateCustomer, UpdateCustomerInvoiceSettings, }; +use stripe_core::payment_intent::{ + CreatePaymentIntent, CreatePaymentIntentOffSession, RetrievePaymentIntent, + UpdatePaymentIntent, +}; +use stripe_payment::ConfirmationTokenId; +use stripe_payment::confirmation_token::RetrieveConfirmationToken; +use stripe_payment::payment_method::RetrievePaymentMethod; +use stripe_shared::{ + Customer, CustomerId, PaymentIntent, PaymentIntentId, + PaymentIntentSetupFutureUsage, PaymentMethod, PaymentMethodId, +}; +use stripe_types::Currency; use super::{ ChargeRequestType, ChargeType, PaymentRequestMetadata, PaymentRequestType, @@ -42,6 +51,39 @@ pub const MODRINTH_CHARGE_ID: &str = "modrinth_charge_id"; pub const MODRINTH_TAX_AMOUNT: &str = "modrinth_tax_amount"; pub const MODRINTH_PAYMENT_METADATA: &str = "modrinth_payment_metadata"; +/// Prefixes of the IDs Stripe accepts as payment method IDs. +pub const PAYMENT_METHOD_ID_PREFIXES: &[&str] = + &["pm_", "card_", "src_", "ba_"]; + +/// Parses a Stripe object ID that came from a client, checking that it has one of +/// `prefixes` and only contains characters Stripe uses in IDs. +/// +/// `async-stripe` accepts any string as an ID and puts IDs into request paths +/// without escaping them, so IDs from untrusted input must go through this. +pub fn parse_stripe_id From<&'a str>>( + id: &str, + prefixes: &[&str], +) -> Option { + let has_prefix = prefixes + .iter() + .any(|prefix| id.len() > prefix.len() && id.starts_with(prefix)); + let valid_chars = + id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'); + + (has_prefix && valid_chars).then(|| T::from(id)) +} + +/// Parses a payment method ID that came from a client. +pub fn parse_payment_method_id(id: &str) -> Option { + parse_stripe_id(id, PAYMENT_METHOD_ID_PREFIXES) +} + +/// Parses an ISO 4217 currency code, returning `None` if Stripe doesn't support it. +pub fn parse_currency(code: &str) -> Option { + let Ok(currency) = code.to_lowercase().parse::(); + (!matches!(currency, Currency::Unknown(_))).then_some(currency) +} + pub enum AttachedCharge { /// Create a proration charge. /// @@ -127,15 +169,16 @@ pub enum PaymentSession { impl PaymentSession { pub fn set_payment_intent_session_options( &self, - intent: &mut CreatePaymentIntent, - ) { + intent: CreatePaymentIntent, + ) -> CreatePaymentIntent { if matches!(self, PaymentSession::AutomatedRenewal) { - intent.off_session = Some(PaymentIntentOffSession::Exists(true)); // Mark as the customer isn't able to perform manual verification/isn't on-session - intent.confirm = Some(true); // Immediately confirm the PI + // The customer isn't on-session to perform manual verification, so + // confirm the payment intent immediately. + intent + .off_session(CreatePaymentIntentOffSession::Bool(true)) + .confirm(true) } else { - intent.off_session = None; - intent.setup_future_usage = - Some(PaymentIntentSetupFutureUsage::OffSession); + intent.setup_future_usage(PaymentIntentSetupFutureUsage::OffSession) } } } @@ -148,7 +191,7 @@ pub enum CurrencyMode { pub struct PaymentBootstrapOptions<'a> { pub user: &'a User, /// Update this payment intent instead of creating a new intent. - pub payment_intent: Option, + pub payment_intent: Option, /// The status of the current payment session. This is used to derive the payment /// method as well as set the appropriate parameters on the payment intent. /// @@ -174,7 +217,7 @@ pub struct PaymentBootstrapOptions<'a> { } pub struct PaymentBootstrapResults { - pub new_payment_intent: Option, + pub new_payment_intent: Option, pub payment_method: PaymentMethod, pub price_id: ProductPriceId, pub subtotal: i64, @@ -216,17 +259,26 @@ pub async fn create_or_update_payment_intent( .await .wrap_api_err("fetching or creating Stripe customer")?; + if let Some(payment_intent_id) = &existing_payment_intent { + ensure_payment_intent_owned_by( + stripe_client, + payment_intent_id, + &customer_id, + ) + .await?; + } + let mut intent_uses_confirmation_token = false; let payment_method = match &payment_session { PaymentSession::Interactive { payment_request_type: PaymentRequestType::PaymentMethod { id }, } => { - let payment_method_id = PaymentMethodId::from_str(id) - .map_err(|err| eyre::eyre!(err)) + let payment_method_id = parse_payment_method_id(id) .wrap_request_err("invalid payment method id".to_string())?; - PaymentMethod::retrieve(stripe_client, &payment_method_id, &[]) + RetrievePaymentMethod::new(payment_method_id) + .send(stripe_client) .await .wrap_failed_dependency_err( "communicating with payment provider", @@ -238,35 +290,25 @@ pub async fn create_or_update_payment_intent( } => { intent_uses_confirmation_token = true; - #[derive(Deserialize)] - struct ConfirmationToken { - payment_method_preview: Option, - } + let token: ConfirmationTokenId = + parse_stripe_id(token, &["ctoken_"]).wrap_request_err( + "invalid confirmation token".to_string(), + )?; - let mut confirmation: serde_json::Value = stripe_client - .get(&format!("confirmation_tokens/{token}")) + let confirmation = RetrieveConfirmationToken::new(token) + .send(stripe_client) .await .wrap_failed_dependency_err( "communicating with payment provider", )?; - // We patch the JSONs to support the PaymentMethod struct - let p: json_patch::Patch = serde_json::from_value(serde_json::json!([ - { "op": "add", "path": "/payment_method_preview/id", "value": "pm_1PirTdJygY5LJFfKmPIaM1N1" }, - { "op": "add", "path": "/payment_method_preview/created", "value": 1723183475 }, - { "op": "add", "path": "/payment_method_preview/livemode", "value": false } - ])).unwrap(); - json_patch::patch(&mut confirmation, &p).unwrap(); - - let confirmation: ConfirmationToken = - serde_json::from_value(confirmation) - .wrap_request_err("deserializing JSON data")?; - - confirmation + let preview = confirmation .payment_method_preview .wrap_request_err_with(|| { - "confirmation token is missing payment method!".to_string() - })? + "confirmation token is missing payment method".to_string() + })?; + + payment_method_from_preview(&preview)? } PaymentSession::AutomatedRenewal => { if attached_charge.as_charge().is_none() { @@ -292,11 +334,10 @@ pub async fn create_or_update_payment_intent( let inferred_stripe_currency = match currency_mode { CurrencyMode::Set(currency) => currency, - CurrencyMode::Infer => infer_currency_code(user_country) - .to_lowercase() - .parse::() - .map_err(|err| eyre::eyre!(err)) - .wrap_request_err("invalid currency code".to_string())?, + CurrencyMode::Infer => { + parse_currency(&infer_currency_code(user_country)) + .wrap_request_err("invalid currency code".to_string())? + } }; let charge_data = match attached_charge { @@ -541,17 +582,16 @@ pub async fn create_or_update_payment_intent( } if let Some(payment_intent_id) = existing_payment_intent { - let mut update_payment_intent = stripe::UpdatePaymentIntent { - amount: Some(charge_data.amount + tax_amount), - currency: Some( - charge_data - .stripe_currency_code() - .wrap_api_err("converting charge currency for Stripe")?, - ), - customer: Some(customer_id), - metadata: Some(metadata), - ..Default::default() - }; + let mut update_payment_intent = + UpdatePaymentIntent::new(payment_intent_id) + .amount(charge_data.amount + tax_amount) + .currency( + charge_data.stripe_currency_code().wrap_api_err( + "converting charge currency for Stripe", + )?, + ) + .customer(customer_id.as_str()) + .metadata(metadata); // If the payment request type was done through a confirmation token, // the payment method ID is an invalid placeholder so we don't want @@ -560,19 +600,16 @@ pub async fn create_or_update_payment_intent( // The PaymentIntent will be confirmed using the confirmation token // by the client. if !intent_uses_confirmation_token { - update_payment_intent.payment_method = - Some(payment_method.id.clone()); + update_payment_intent = update_payment_intent + .payment_method(payment_method.id.as_str()); } - stripe::PaymentIntent::update( - stripe_client, - &payment_intent_id, - update_payment_intent, - ) - .await - .wrap_failed_dependency_err( - "updating database records for `create_or_update_payment_intent`", - )?; + update_payment_intent + .send(stripe_client) + .await + .wrap_failed_dependency_err( + "updating database records for `create_or_update_payment_intent`", + )?; Ok(PaymentBootstrapResults { new_payment_intent: None, @@ -587,23 +624,24 @@ pub async fn create_or_update_payment_intent( charge_data .stripe_currency_code() .wrap_api_err("converting charge currency for Stripe")?, - ); + ) + .customer(customer_id.as_str()) + .metadata(metadata); - intent.customer = Some(customer_id); - intent.metadata = Some(metadata); - intent.receipt_email = user.email.as_deref(); + if let Some(email) = user.email.as_deref() { + intent = intent.receipt_email(email); + } if !intent_uses_confirmation_token { - intent.payment_method = Some(payment_method.id.clone()); + intent = intent.payment_method(payment_method.id.as_str()); } - payment_session.set_payment_intent_session_options(&mut intent); - - let payment_intent = - stripe::PaymentIntent::create(stripe_client, intent) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + let payment_intent = payment_session + .set_payment_intent_session_options(intent) + .send(stripe_client) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; Ok(PaymentBootstrapResults { new_payment_intent: Some(payment_intent), @@ -624,23 +662,24 @@ pub async fn get_or_create_customer( redis: &RedisPool, ) -> Result { if let Some(customer_id) = - stripe_customer_id.and_then(|x| stripe::CustomerId::from_str(x).ok()) + stripe_customer_id.and_then(|x| CustomerId::from_str(x).ok()) { Ok(customer_id) } else { let mut metadata = HashMap::new(); metadata.insert(MODRINTH_USER_ID.to_owned(), to_base62(user_id.0)); - let customer = stripe::Customer::create( - client, - CreateCustomer { - email: user_email, - metadata: Some(metadata), - ..Default::default() - }, - ) - .await - .wrap_failed_dependency_err("communicating with payment provider")?; + let mut create_customer = CreateCustomer::new().metadata(metadata); + if let Some(email) = user_email { + create_customer = create_customer.email(email); + } + + let customer = create_customer + .send(client) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; sqlx::query!( " @@ -666,25 +705,114 @@ pub async fn get_or_create_customer( } } +/// Builds a [`PaymentMethod`] from a confirmation token's payment method preview. +/// +/// The preview has the same shape as a payment method, minus the fields that only +/// exist once the payment method is created. Those are filled with placeholders, so +/// the returned payment method's ID must not be sent to Stripe. +fn payment_method_from_preview( + preview: &stripe_payment::ConfirmationTokensResourcePaymentMethodPreview, +) -> Result { + let mut value = serde_json::to_value(preview) + .wrap_internal_err("serializing payment method preview")?; + let object = value + .as_object_mut() + .wrap_internal_err("payment method preview is not an object")?; + object.insert("id".to_owned(), "pm_1PirTdJygY5LJFfKmPIaM1N1".into()); + object.insert("created".to_owned(), 1723183475.into()); + object.insert("livemode".to_owned(), false.into()); + + miniserde::json::from_str(&value.to_string()) + .map_err(|_| eyre::eyre!("invalid payment method")) + .wrap_internal_err("converting payment method preview") +} + +/// Checks that a payment intent exists and belongs to the Stripe customer. +/// +/// A missing payment intent and one belonging to another customer return the same +/// error, so callers can't use this to probe for payment intent IDs. +async fn ensure_payment_intent_owned_by( + client: &stripe::Client, + payment_intent_id: &PaymentIntentId, + customer_id: &CustomerId, +) -> Result<(), ApiError> { + let not_found = + || ApiError::NotFound(eyre::eyre!("payment intent not found")); + + let payment_intent = + match RetrievePaymentIntent::new(payment_intent_id.clone()) + .send(client) + .await + { + Ok(payment_intent) => payment_intent, + Err(stripe::StripeError::Stripe(_, 404)) => return Err(not_found()), + Err(err) => { + return Err(err).wrap_failed_dependency_err( + "communicating with payment provider", + ); + } + }; + + if payment_intent + .customer + .as_ref() + .is_some_and(|customer| customer.id() == customer_id) + { + Ok(()) + } else { + Err(not_found()) + } +} + +/// Fetches a Stripe customer, failing if the customer has been deleted. +pub async fn retrieve_customer( + client: &stripe::Client, + customer_id: &CustomerId, + expand: &[&str], +) -> Result { + let customer = RetrieveCustomer::new(customer_id.clone()) + .expand(expand.iter().map(|x| (*x).to_owned()).collect::>()) + .send(client) + .await + .wrap_failed_dependency_err("communicating with payment provider")?; + + match customer { + RetrieveCustomerReturned::Customer(customer) => Ok(customer), + RetrieveCustomerReturned::DeletedCustomer(_) => Err(ApiError::Request( + eyre::eyre!("Stripe customer has been deleted"), + )), + } +} + +/// Lists up to `limit` payment methods attached to the customer. +pub async fn list_payment_methods( + client: &stripe::Client, + customer_id: &CustomerId, + limit: i64, +) -> Result, ApiError> { + Ok(ListPaymentMethodsCustomer::new(customer_id.clone()) + .limit(limit) + .send(client) + .await + .wrap_failed_dependency_err("communicating with payment provider")? + .data) +} + /// Sets the customer's default payment method, which is charged for automated renewals. pub async fn set_default_payment_method( client: &stripe::Client, customer_id: &CustomerId, payment_method_id: &PaymentMethodId, ) -> Result<(), ApiError> { - stripe::Customer::update( - client, - customer_id, - UpdateCustomer { - invoice_settings: Some(CustomerInvoiceSettings { - default_payment_method: Some(payment_method_id.to_string()), - ..Default::default() - }), - ..Default::default() - }, - ) - .await - .wrap_failed_dependency_err("communicating with payment provider")?; + let mut invoice_settings = UpdateCustomerInvoiceSettings::new(); + invoice_settings.default_payment_method = + Some(payment_method_id.to_string()); + + UpdateCustomer::new(customer_id.clone()) + .invoice_settings(invoice_settings) + .send(client) + .await + .wrap_failed_dependency_err("communicating with payment provider")?; Ok(()) } @@ -697,13 +825,12 @@ pub async fn get_default_payment_method( client: &stripe::Client, customer_id: &CustomerId, ) -> Result, ApiError> { - let customer = stripe::Customer::retrieve( + let customer = retrieve_customer( client, customer_id, &["invoice_settings.default_payment_method"], ) - .await - .wrap_failed_dependency_err("communicating with payment provider")?; + .await?; if let Some(payment_method) = customer .invoice_settings @@ -713,19 +840,9 @@ pub async fn get_default_payment_method( return Ok(Some(payment_method)); } - let payment_methods = stripe::Customer::retrieve_payment_methods( - client, - customer_id, - CustomerPaymentMethodRetrieval { - limit: Some(2), - ..Default::default() - }, - ) - .await - .wrap_failed_dependency_err("communicating with payment provider")?; + let payment_methods = list_payment_methods(client, customer_id, 2).await?; - let Ok([payment_method]) = - <[PaymentMethod; 1]>::try_from(payment_methods.data) + let Ok([payment_method]) = <[PaymentMethod; 1]>::try_from(payment_methods) else { return Ok(None); }; @@ -787,11 +904,8 @@ pub async fn quote_charge_tax( None => { tracing::warn!("PaymentMethod had no address"); - stripe::Customer::retrieve(stripe_client, &stripe_customer_id, &[]) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )? + retrieve_customer(stripe_client, &stripe_customer_id, &[]) + .await? .address .wrap_request_err_with(|| { "couldn't get an address for the Stripe customer".to_owned() @@ -831,32 +945,19 @@ pub async fn promote_default_payment_method( customer_id: &CustomerId, removed: &PaymentMethodId, ) -> Result<(), ApiError> { - let customer = stripe::Customer::retrieve(client, customer_id, &[]) - .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + let customer = retrieve_customer(client, customer_id, &[]).await?; if customer .invoice_settings .and_then(|x| x.default_payment_method) - .is_some_and(|x| x.id() != *removed) + .is_some_and(|x| x.id() != removed) { return Ok(()); } - let payment_methods = stripe::Customer::retrieve_payment_methods( - client, - customer_id, - CustomerPaymentMethodRetrieval { - limit: Some(1), - ..Default::default() - }, - ) - .await - .wrap_failed_dependency_err("communicating with payment provider")?; + let payment_methods = list_payment_methods(client, customer_id, 1).await?; - if let Some(payment_method) = payment_methods.data.first() { + if let Some(payment_method) = payment_methods.first() { set_default_payment_method(client, customer_id, &payment_method.id) .await?; } @@ -935,11 +1036,9 @@ struct ChargeData { } impl ChargeData { - pub fn stripe_currency_code(&self) -> Result { - self.currency_code - .to_lowercase() - .parse::() - .map_err(|_| ApiError::Request(eyre::eyre!( + pub fn stripe_currency_code(&self) -> Result { + parse_currency(&self.currency_code) + .ok_or_else(|| ApiError::Request(eyre::eyre!( format!("Invalid currency code '{}': could not convert to Stripe currency", &self.currency_code) ))) } diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 50baf37c6fc..9766650ddcb 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -3322,18 +3322,15 @@ pub async fn set_email( if let Some(customer_id) = user .stripe_customer_id .as_ref() - .and_then(|x| stripe::CustomerId::from_str(x).ok()) + .and_then(|x| stripe_shared::CustomerId::from_str(x).ok()) { - stripe::Customer::update( - &stripe_client, - &customer_id, - stripe::UpdateCustomer { - email: Some(&email_address.email), - ..Default::default() - }, - ) - .await - .wrap_failed_dependency_err("communicating with payment provider")?; + stripe_core::customer::UpdateCustomer::new(customer_id) + .email(email_address.email.as_str()) + .send(&**stripe_client) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; } let flow = DBFlow::ConfirmEmail { diff --git a/apps/labrinth/src/util/anrok.rs b/apps/labrinth/src/util/anrok.rs index ff7e1dfd359..e6b71affd2c 100644 --- a/apps/labrinth/src/util/anrok.rs +++ b/apps/labrinth/src/util/anrok.rs @@ -10,11 +10,11 @@ use tracing::trace; use crate::env::ENV; use crate::routes::ApiError; -pub fn transaction_id_stripe_pi(pi: &stripe::PaymentIntentId) -> String { +pub fn transaction_id_stripe_pi(pi: &stripe_shared::PaymentIntentId) -> String { format!("stripe:charge:{pi}") } -pub fn transaction_id_stripe_pyr(charge: &stripe::RefundId) -> String { +pub fn transaction_id_stripe_pyr(charge: &stripe_shared::RefundId) -> String { format!("stripe:refund:{charge}") } @@ -44,7 +44,7 @@ pub struct Address { } impl Address { - pub fn from_stripe_address(address: &stripe::Address) -> Self { + pub fn from_stripe_address(address: &stripe_shared::Address) -> Self { Self { country: address.country.clone(), line1: address.line1.clone(), From c26fa5633be62ea1bcaf0834a01d7137fade4cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:16:23 -0500 Subject: [PATCH 2/3] chore: various fixes --- Cargo.lock | 3 +- Cargo.toml | 1 - apps/labrinth/Cargo.toml | 5 +- apps/labrinth/src/queue/billing.rs | 9 +-- apps/labrinth/src/routes/internal/billing.rs | 21 +++---- .../src/routes/internal/billing/payments.rs | 42 +++++++------- apps/labrinth/src/routes/internal/flows.rs | 5 +- apps/labrinth/src/util/mod.rs | 1 + apps/labrinth/src/util/stripe.rs | 55 +++++++++++++++++++ 9 files changed, 93 insertions(+), 49 deletions(-) create mode 100644 apps/labrinth/src/util/stripe.rs diff --git a/Cargo.lock b/Cargo.lock index 3081a1b8d09..80d49105dd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1061,6 +1061,7 @@ dependencies = [ "async-stripe-types", "miniserde", "serde", + "serde_json", "smol_str", "tracing", ] @@ -1073,6 +1074,7 @@ checksum = "97f16db6218479d22bdc9eb78834b37cc25f579815d7e953bb1f28f5f673db66" dependencies = [ "miniserde", "serde", + "serde_json", "smol_str", ] @@ -6779,7 +6781,6 @@ dependencies = [ "lingua", "linkify", "minecraft-meta-model", - "miniserde", "modrinth-content-management", "modrinth-util", "muralpay", diff --git a/Cargo.toml b/Cargo.toml index d62fcba9879..4cee70476f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,7 +149,6 @@ lz4_flex = { version = "0.14.0", default-features = false, features = [ maxminddb = "0.32.0" minecraft-meta-manifest = { path = "packages/minecraft-meta-manifest" } minecraft-meta-model = { path = "packages/minecraft-meta-model" } -miniserde = "0.1.40" modrinth-content-management = { path = "packages/modrinth-content-management" } modrinth-log = { path = "packages/modrinth-log" } modrinth-util = { path = "packages/modrinth-util" } diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 53c57c477ee..ed9af35b337 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -37,8 +37,8 @@ async-stripe-payment = { workspace = true, features = [ "payment_method", "serialize", ] } -async-stripe-shared = { workspace = true, features = ["serialize"] } -async-stripe-types = { workspace = true, features = ["serialize"] } +async-stripe-shared = { workspace = true, features = ["deserialize", "serialize"] } +async-stripe-types = { workspace = true, features = ["deserialize", "serialize"] } async-stripe-webhook = { workspace = true } async-trait = { workspace = true } aws-sdk-s3 = { workspace = true } @@ -93,7 +93,6 @@ lettre = { workspace = true } lingua = { workspace = true } linkify = { workspace = true } minecraft-meta-model = { workspace = true } -miniserde = { workspace = true } modrinth-content-management = { workspace = true } modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] } muralpay = { workspace = true, features = ["client", "mock", "utoipa"] } diff --git a/apps/labrinth/src/queue/billing.rs b/apps/labrinth/src/queue/billing.rs index 59a65e52edd..95fc13871f2 100644 --- a/apps/labrinth/src/queue/billing.rs +++ b/apps/labrinth/src/queue/billing.rs @@ -25,6 +25,7 @@ use crate::util::archon::ArchonClient; use crate::util::archon::{CreateServerRequest, Specs}; use crate::util::error::ApiContext as _; use crate::util::error::Context; +use crate::util::stripe::StripeContext as _; use ariadne::ids::base62_impl::to_base62; use chrono::Utc; use futures::FutureExt; @@ -219,9 +220,7 @@ async fn update_anrok_transactions( .expand(["payment_intent.payment_method".to_owned()]) .send(stripe_client) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + .wrap_stripe_err("communicating with payment provider")?; let pi = refund .payment_intent @@ -246,9 +245,7 @@ async fn update_anrok_transactions( .expand(["payment_method".to_owned()]) .send(stripe_client) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + .wrap_stripe_err("communicating with payment provider")?; let anrok_id = anrok::transaction_id_stripe_pi(&stripe_id); diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 92e03efff2e..48f42964d2b 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -25,6 +25,7 @@ use crate::util::MinecraftMeta; use crate::util::anrok; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; +use crate::util::stripe::StripeContext as _; use ::stripe_webhook::{EventObject, Webhook, WebhookError}; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::base62_impl::{parse_base62, to_base62}; @@ -300,7 +301,7 @@ pub async fn refund_charge( .expand(["payment_method".to_owned()]) .send(&**stripe_client) .await - .wrap_failed_dependency_err( + .wrap_stripe_err( "communicating with payment provider", )?; @@ -355,7 +356,7 @@ pub async fn refund_charge( .expand(["balance_transaction".to_owned()]) .send(&**stripe_client) .await - .wrap_failed_dependency_err( + .wrap_stripe_err( "communicating with payment provider", )?; @@ -562,9 +563,7 @@ pub async fn reprocess_charge_tax( .expand(["payment_method".to_owned()]) .send(&**stripe_client) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + .wrap_stripe_err("communicating with payment provider")?; let Some(billing_address) = pi .payment_method @@ -1388,7 +1387,7 @@ pub async fn add_payment_method_flow( .automatic_payment_methods(automatic_payment_methods) .send(&**stripe_client) .await - .wrap_failed_dependency_err("communicating with payment provider")?; + .wrap_stripe_err("communicating with payment provider")?; Ok(HttpResponse::Ok().json(serde_json::json!({ "client_secret": intent.client_secret @@ -1446,7 +1445,7 @@ pub async fn edit_payment_method( let payment_method = RetrievePaymentMethod::new(payment_method_id) .send(&**stripe_client) .await - .wrap_failed_dependency_err("communicating with payment provider")?; + .wrap_stripe_err("communicating with payment provider")?; if payment_method .customer @@ -1513,7 +1512,7 @@ pub async fn remove_payment_method( let payment_method = RetrievePaymentMethod::new(payment_method_id.clone()) .send(&**stripe_client) .await - .wrap_failed_dependency_err("communicating with payment provider")?; + .wrap_stripe_err("communicating with payment provider")?; let user_subscriptions = user_subscription_item::DBUserSubscription::get_all_user( @@ -1553,9 +1552,7 @@ pub async fn remove_payment_method( DetachPaymentMethod::new(payment_method_id.clone()) .send(&**stripe_client) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + .wrap_stripe_err("communicating with payment provider")?; if let Some(owner_id) = payment_method.customer.map(|x| x.into_id()) && let Err(err) = promote_default_payment_method( @@ -2210,7 +2207,7 @@ pub async fn stripe_webhook( .expand(["balance_transaction".to_owned()]) .send(&**stripe_client) .await - .wrap_failed_dependency_err( + .wrap_stripe_err( "communicating with payment provider", )?; diff --git a/apps/labrinth/src/routes/internal/billing/payments.rs b/apps/labrinth/src/routes/internal/billing/payments.rs index baa268c25a7..b98f6f02ba0 100644 --- a/apps/labrinth/src/routes/internal/billing/payments.rs +++ b/apps/labrinth/src/routes/internal/billing/payments.rs @@ -9,6 +9,7 @@ use crate::models::v3::users::User; use crate::routes::ApiError; use crate::util::anrok; use crate::util::error::{ApiContext as _, Context as _}; +use crate::util::stripe::StripeContext as _; use xredis::RedisPool; use crate::database::PgPool; @@ -28,8 +29,9 @@ use stripe_payment::ConfirmationTokenId; use stripe_payment::confirmation_token::RetrieveConfirmationToken; use stripe_payment::payment_method::RetrievePaymentMethod; use stripe_shared::{ - Customer, CustomerId, PaymentIntent, PaymentIntentId, - PaymentIntentSetupFutureUsage, PaymentMethod, PaymentMethodId, + Customer, CustomerId, PaymentIntent, PaymentIntentCaptureMethod, + PaymentIntentId, PaymentIntentSetupFutureUsage, PaymentMethod, + PaymentMethodId, }; use stripe_types::Currency; @@ -280,9 +282,7 @@ pub async fn create_or_update_payment_intent( RetrievePaymentMethod::new(payment_method_id) .send(stripe_client) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )? + .wrap_stripe_err("communicating with payment provider")? } PaymentSession::Interactive { payment_request_type: @@ -298,9 +298,7 @@ pub async fn create_or_update_payment_intent( let confirmation = RetrieveConfirmationToken::new(token) .send(stripe_client) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + .wrap_stripe_err("communicating with payment provider")?; let preview = confirmation .payment_method_preview @@ -607,7 +605,7 @@ pub async fn create_or_update_payment_intent( update_payment_intent .send(stripe_client) .await - .wrap_failed_dependency_err( + .wrap_stripe_err( "updating database records for `create_or_update_payment_intent`", )?; @@ -619,12 +617,16 @@ pub async fn create_or_update_payment_intent( tax: tax_amount, }) } else { + // Stripe captures asynchronously by default since API version 2024-04-10, + // which leaves the charge without a balance transaction when the + // `payment_intent.succeeded` webhook is handled. let mut intent = CreatePaymentIntent::new( charge_data.amount + tax_amount, charge_data .stripe_currency_code() .wrap_api_err("converting charge currency for Stripe")?, ) + .capture_method(PaymentIntentCaptureMethod::Automatic) .customer(customer_id.as_str()) .metadata(metadata); @@ -639,9 +641,7 @@ pub async fn create_or_update_payment_intent( .set_payment_intent_session_options(intent) .send(stripe_client) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + .wrap_stripe_err("communicating with payment provider")?; Ok(PaymentBootstrapResults { new_payment_intent: Some(payment_intent), @@ -677,9 +677,7 @@ pub async fn get_or_create_customer( let customer = create_customer .send(client) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + .wrap_stripe_err("communicating with payment provider")?; sqlx::query!( " @@ -722,8 +720,7 @@ fn payment_method_from_preview( object.insert("created".to_owned(), 1723183475.into()); object.insert("livemode".to_owned(), false.into()); - miniserde::json::from_str(&value.to_string()) - .map_err(|_| eyre::eyre!("invalid payment method")) + serde_json::from_value(value) .wrap_internal_err("converting payment method preview") } @@ -747,9 +744,8 @@ async fn ensure_payment_intent_owned_by( Ok(payment_intent) => payment_intent, Err(stripe::StripeError::Stripe(_, 404)) => return Err(not_found()), Err(err) => { - return Err(err).wrap_failed_dependency_err( - "communicating with payment provider", - ); + return Err(err) + .wrap_stripe_err("communicating with payment provider"); } }; @@ -774,7 +770,7 @@ pub async fn retrieve_customer( .expand(expand.iter().map(|x| (*x).to_owned()).collect::>()) .send(client) .await - .wrap_failed_dependency_err("communicating with payment provider")?; + .wrap_stripe_err("communicating with payment provider")?; match customer { RetrieveCustomerReturned::Customer(customer) => Ok(customer), @@ -794,7 +790,7 @@ pub async fn list_payment_methods( .limit(limit) .send(client) .await - .wrap_failed_dependency_err("communicating with payment provider")? + .wrap_stripe_err("communicating with payment provider")? .data) } @@ -812,7 +808,7 @@ pub async fn set_default_payment_method( .invoice_settings(invoice_settings) .send(client) .await - .wrap_failed_dependency_err("communicating with payment provider")?; + .wrap_stripe_err("communicating with payment provider")?; Ok(()) } diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 9766650ddcb..1f01d373aec 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -26,6 +26,7 @@ use crate::util::error::Context; use crate::util::ext::get_image_ext; use crate::util::img::upload_image_optimized; use crate::util::neverbounce::{check_email, email_check_error_generic}; +use crate::util::stripe::StripeContext as _; use crate::util::usercheck::{ DecisionAction, check_email_gate, gate_block_error, }; @@ -3328,9 +3329,7 @@ pub async fn set_email( .email(email_address.email.as_str()) .send(&**stripe_client) .await - .wrap_failed_dependency_err( - "communicating with payment provider", - )?; + .wrap_stripe_err("communicating with payment provider")?; } let flow = DBFlow::ConfirmEmail { diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index d00a1741d2f..b767dc50b33 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -23,6 +23,7 @@ pub mod ratelimit; pub mod refreshed_data; pub mod routes; pub mod sentry; +pub mod stripe; pub mod tags; pub mod tiltify; pub mod usercheck; diff --git a/apps/labrinth/src/util/stripe.rs b/apps/labrinth/src/util/stripe.rs new file mode 100644 index 00000000000..09b4f1ca263 --- /dev/null +++ b/apps/labrinth/src/util/stripe.rs @@ -0,0 +1,55 @@ +use std::fmt::{Debug, Display, Write as _}; + +use crate::routes::ApiError; +use crate::util::error::Context as _; + +/// Converts a Stripe client error into a report with a concise message. +/// +/// The `Display` of [`stripe::StripeError`] dumps the whole error object Stripe +/// returned, which can include the payment intent, payment method and client +/// secret involved. Error chains end up in logs and in the `details` of API +/// error responses, so only the type, codes and message are kept. +pub fn stripe_error_report(error: ::stripe::StripeError) -> eyre::Report { + let ::stripe::StripeError::Stripe(error, status) = error else { + return eyre::Report::new(error); + }; + + let mut message = format!("Stripe returned {} ({status})", error.type_); + if let Some(code) = &error.code { + let _ = write!(message, " [{code}]"); + } + if let Some(decline_code) = &error.decline_code { + let _ = write!(message, " [{decline_code}]"); + } + if let Some(param) = &error.param { + let _ = write!(message, " for `{param}`"); + } + if let Some(text) = &error.message { + let _ = write!(message, ": {text}"); + } + + eyre::eyre!(message) +} + +/// Adds context to the result of a Stripe request. +#[allow( + clippy::missing_errors_doc, + reason = "this trait's purpose is improving error handling" +)] +pub trait StripeContext: Sized { + /// Maps the error variant into an [`ApiError::FailedDependency`] with the + /// given message, describing the Stripe error concisely. + fn wrap_stripe_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static; +} + +impl StripeContext for Result { + fn wrap_stripe_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.map_err(stripe_error_report) + .wrap_failed_dependency_err(msg) + } +} From 122f40e8f462bca1c45bad39fb5ee4bec54b2702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:20:08 -0500 Subject: [PATCH 3/3] fix(labrinth): address review of async-stripe bump - Pin `capture_method` to `automatic` on new payment intents, since the pinned Stripe API version defaults to asynchronous capture, which leaves the charge without a balance transaction when `payment_intent.succeeded` is handled - Wrap Stripe errors with a concise type/code/message description instead of the SDK's full debug dump, which could include payment intents, payment methods and client secrets in logs and API error details - Enable the `deserialize` feature on the Stripe crates and drop the direct `miniserde` dependency - Ignore the `ba_` payment method ID prefix in the typos check --- apps/labrinth/src/routes/internal/billing/payments.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/internal/billing/payments.rs b/apps/labrinth/src/routes/internal/billing/payments.rs index b98f6f02ba0..46118aa75c2 100644 --- a/apps/labrinth/src/routes/internal/billing/payments.rs +++ b/apps/labrinth/src/routes/internal/billing/payments.rs @@ -55,7 +55,7 @@ pub const MODRINTH_PAYMENT_METADATA: &str = "modrinth_payment_metadata"; /// Prefixes of the IDs Stripe accepts as payment method IDs. pub const PAYMENT_METHOD_ID_PREFIXES: &[&str] = - &["pm_", "card_", "src_", "ba_"]; + &["pm_", "card_", "src_", "ba_"]; // typos:ignore /// Parses a Stripe object ID that came from a client, checking that it has one of /// `prefixes` and only contains characters Stripe uses in IDs.