From 20aa55f7c6209f300fa4834cd451d4d37b137825 Mon Sep 17 00:00:00 2001 From: Kibet arap Sum Date: Tue, 14 Jul 2026 16:05:18 +0300 Subject: [PATCH 1/9] feat(payments): configure the Stripe client Pin async-stripe 0.41.0 with Checkout, webhook, and compatible Connect resources while retaining the stable pre-1.0 API. Load Stripe secrets and hosted Checkout settings from explicit APP_STRIPE environment variables, redact credentials from debug output, and disable payments cleanly when configuration is incomplete. Construct one reusable Stripe client in AppContext and cover optional and environment-backed configuration with serial tests. --- service/Cargo.lock | 686 ++++++++++++++++++++++++++++---- service/Cargo.toml | 6 + service/config/development.yaml | 5 + service/src/config/mod.rs | 15 + service/src/config/stripe.rs | 95 +++++ service/src/context.rs | 35 +- service/tests/config/mod.rs | 45 +++ 7 files changed, 806 insertions(+), 81 deletions(-) create mode 100644 service/src/config/stripe.rs diff --git a/service/Cargo.lock b/service/Cargo.lock index 2e83ee5..df75736 100644 --- a/service/Cargo.lock +++ b/service/Cargo.lock @@ -112,7 +112,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -123,7 +123,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -142,7 +142,7 @@ dependencies = [ "futures", "pin-project-lite", "serde", - "thiserror", + "thiserror 2.0.18", "tower", "tracing", "tracing-futures", @@ -159,7 +159,7 @@ dependencies = [ "pin-project-lite", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tower", "ulid", ] @@ -176,7 +176,7 @@ dependencies = [ "log", "redis 0.32.7", "serde", - "thiserror", + "thiserror 2.0.18", "tokio", ] @@ -219,17 +219,52 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[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-lock" version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] +[[package]] +name = "async-stripe" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecbddf002ad7a13d2041eadf1b234cb3f57653ffdd901a01bc3f1c65aa77440" +dependencies = [ + "chrono", + "futures-util", + "hex", + "hmac 0.12.1", + "http-types", + "hyper 0.14.32", + "hyper-rustls", + "serde", + "serde_json", + "serde_path_to_error", + "serde_qs 0.10.1", + "sha2 0.10.9", + "smart-default", + "smol_str", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -269,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", - "untrusted", + "untrusted 0.7.1", "zeroize", ] @@ -296,10 +331,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.10.1", "hyper-util", "itoa", "matchit", @@ -327,8 +362,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -353,8 +388,8 @@ dependencies = [ "futures-core", "futures-util", "headers", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "http-body-util", "mime", "percent-encoding", @@ -391,9 +426,9 @@ dependencies = [ "bytesize", "cookie", "expect-json", - "http", + "http 1.4.2", "http-body-util", - "hyper", + "hyper 1.10.1", "hyper-util", "mime", "pretty_assertions", @@ -413,7 +448,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "fastrand", + "fastrand 2.4.1", ] [[package]] @@ -431,6 +466,18 @@ dependencies = [ "windows-link", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -749,7 +796,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -804,6 +851,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1074,7 +1131,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64", + "base64 0.22.1", "memchr", ] @@ -1135,7 +1192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1145,9 +1202,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "windows-sys", + "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.1" @@ -1165,7 +1228,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.1", "pin-project-lite", ] @@ -1182,7 +1245,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "typetag", "uuid", ] @@ -1214,6 +1277,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[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.4.1" @@ -1350,6 +1422,21 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[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-macro" version = "0.3.32" @@ -1406,6 +1493,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1414,7 +1512,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] @@ -1467,6 +1565,25 @@ dependencies = [ "smallvec", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "handlebars" version = "6.4.2" @@ -1480,7 +1597,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "walkdir", ] @@ -1540,10 +1657,10 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", - "http", + "http 1.4.2", "httpdate", "mime", "sha1 0.10.6", @@ -1555,7 +1672,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", + "http 1.4.2", ] [[package]] @@ -1576,7 +1693,16 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -1599,6 +1725,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.2" @@ -1609,6 +1746,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1616,7 +1764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.2", ] [[package]] @@ -1627,11 +1775,32 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "pin-project-lite", ] +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel", + "base64 0.13.1", + "futures-lite", + "http 0.2.12", + "infer", + "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" @@ -1653,6 +1822,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.10.1" @@ -1663,8 +1856,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1674,6 +1867,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1683,12 +1892,12 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.2", + "http-body 1.0.1", + "hyper 1.10.1", "libc", "pin-project-lite", - "socket2", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -1851,6 +2060,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + [[package]] name = "insta" version = "1.48.0" @@ -1868,6 +2083,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "inventory" version = "0.3.24" @@ -1928,7 +2152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "aws-lc-rs", - "base64", + "base64 0.22.1", "getrandom 0.2.17", "js-sys", "pem", @@ -1962,10 +2186,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "email-encoding", "email_address", - "fastrand", + "fastrand 2.4.1", "futures-io", "futures-util", "hostname", @@ -1976,7 +2200,7 @@ dependencies = [ "nom", "percent-encoding", "quoted_printable", - "socket2", + "socket2 0.6.4", "tokio", "tokio-native-tls", "tracing", @@ -2085,8 +2309,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi", - "windows-sys", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", ] [[package]] @@ -2098,10 +2322,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.2.1", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -2127,7 +2351,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2287,6 +2511,12 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -2373,7 +2603,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -2474,11 +2704,11 @@ version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ - "base64", + "base64 0.22.1", "byteorder", "bytes", "fallible-iterator", - "hmac", + "hmac 0.13.0", "md-5", "memchr", "rand 0.10.1", @@ -2634,6 +2864,19 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + [[package]] name = "rand" version = "0.8.6" @@ -2666,6 +2909,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2686,6 +2939,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2710,6 +2972,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + [[package]] name = "redis" version = "0.32.7" @@ -2729,7 +3000,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2", + "socket2 0.6.4", "tokio", "tokio-util", "url", @@ -2753,7 +3024,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2", + "socket2 0.6.4", "tokio", "tokio-util", "url", @@ -2819,7 +3090,21 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" dependencies = [ - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", ] [[package]] @@ -2933,10 +3218,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", + "http 1.4.2", "mime", "rand 0.10.1", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2993,7 +3278,50 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[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", + "schannel", + "security-framework 2.11.1", +] + +[[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", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted 0.9.0", ] [[package]] @@ -3023,7 +3351,7 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3032,12 +3360,35 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + [[package]] name = "seahash" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3045,7 +3396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3085,7 +3436,7 @@ checksum = "5897b4c3faadadd35fdb6689f015641f3bc481d5adaaac56231ea15aeb243db3" dependencies = [ "ahash 0.8.12", "annotate-snippets", - "base64", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -3166,6 +3517,28 @@ dependencies = [ "serde_core", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cac3f1e2ca2fe333923a1ae72caca910b98ed0630bb35ef6f8c8517d6e81afa" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3219,6 +3592,7 @@ dependencies = [ "apalis", "apalis-redis", "argon2", + "async-stripe", "axum", "axum-extra", "axum-test", @@ -3244,7 +3618,7 @@ dependencies = [ "sha1 0.10.6", "sha2 0.11.0", "sqlx", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tower", @@ -3362,7 +3736,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -3381,6 +3755,36 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -3388,7 +3792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3429,14 +3833,14 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if", "chrono", "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.4.1", "futures-core", "futures-intrusive", "futures-io", @@ -3453,7 +3857,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -3495,7 +3899,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.117", - "thiserror", + "thiserror 2.0.18", "tokio", "url", ] @@ -3524,7 +3928,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", ] @@ -3536,7 +3940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "chrono", @@ -3548,7 +3952,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.13.0", "itoa", "log", "md-5", @@ -3561,7 +3965,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -3587,7 +3991,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -3688,11 +4092,20 @@ version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand", + "fastrand 2.4.1", "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", ] [[package]] @@ -3701,7 +4114,18 @@ version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3800,9 +4224,9 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3826,6 +4250,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -3917,8 +4351,8 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", - "http", - "http-body", + "http 1.4.2", + "http-body 1.0.1", "pin-project-lite", "tower-layer", "tower-service", @@ -3957,7 +4391,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror", + "thiserror 2.0.18", "time", "tracing-subscriber", ] @@ -4153,6 +4587,12 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4163,6 +4603,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4246,6 +4687,12 @@ dependencies = [ "memchr", ] +[[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" @@ -4265,6 +4712,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4391,7 +4844,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -4453,6 +4906,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4462,6 +4924,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.3" diff --git a/service/Cargo.toml b/service/Cargo.toml index cd7d2fa..acba978 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -15,6 +15,12 @@ path = "src/lib.rs" apalis = { version = "0.7.4", features = ["retry"] } apalis-redis = "0.7.4" argon2 = { version = "0.5.3", features = ["std"] } +async-stripe = { version = "=0.41.0", default-features = false, features = [ + "runtime-tokio-hyper-rustls", + "checkout", + "connect", + "webhook-events", +] } axum = { version = "0.8.9", features = ["macros"] } axum-extra = { version = "0.12.6", features = [ "cookie", diff --git a/service/config/development.yaml b/service/config/development.yaml index 4397d5f..08efa54 100644 --- a/service/config/development.yaml +++ b/service/config/development.yaml @@ -49,3 +49,8 @@ cors: allowed_origin: - http://127.0.0.1:5173 - http://localhost:5173 + +stripe: + checkout_success_url: http://localhost:3000/checkout/success?session_id={CHECKOUT_SESSION_ID} + checkout_cancel_url: http://localhost:3000/checkout/cancel + checkout_ttl_seconds: 1800 diff --git a/service/src/config/mod.rs b/service/src/config/mod.rs index 8cdbcde..9b41399 100644 --- a/service/src/config/mod.rs +++ b/service/src/config/mod.rs @@ -1,5 +1,6 @@ mod log; mod mailer; +mod stripe; use std::{ fmt::{self, Display}, @@ -17,6 +18,7 @@ use crate::Result; pub use self::{ log::Logger, mailer::{MailerAuthConfig, MailerConfig, SmtpConfig}, + stripe::StripeConfig, }; #[derive(Debug, Deserialize, Clone)] @@ -27,6 +29,8 @@ pub struct Config { auth: AuthConfig, #[serde(default)] cloudinary: Option, + #[serde(default)] + stripe: Option, mailer: MailerConfig, redis: RedisConfig, cors: CorsConfig, @@ -71,6 +75,12 @@ impl Config { config.cloudinary = None; } } + if let Some(stripe) = config.stripe.as_mut() { + stripe.apply_environment(); + if !stripe.is_configured() { + config.stripe = None; + } + } Ok(config) } @@ -114,6 +124,11 @@ impl Config { pub const fn cloudinary(&self) -> Option<&CloudinaryConfig> { self.cloudinary.as_ref() } + + #[must_use] + pub const fn stripe(&self) -> Option<&StripeConfig> { + self.stripe.as_ref() + } } #[derive(Debug, Clone, Deserialize)] diff --git a/service/src/config/stripe.rs b/service/src/config/stripe.rs new file mode 100644 index 0000000..c2d1fbb --- /dev/null +++ b/service/src/config/stripe.rs @@ -0,0 +1,95 @@ +use std::fmt; + +use serde::Deserialize; + +const DEFAULT_CHECKOUT_TTL_SECONDS: i64 = 30 * 60; + +/// Runtime settings for Stripe-hosted Checkout and signed webhooks. +/// +/// Secret values are loaded from the process environment and deliberately +/// redacted from debug output. Stripe remains disabled when any required value +/// is missing, allowing non-payment commands and tests to run without Stripe. +#[derive(Clone, Deserialize)] +pub struct StripeConfig { + #[serde(default)] + secret_key: String, + #[serde(default)] + webhook_secret: String, + #[serde(default)] + checkout_success_url: String, + #[serde(default)] + checkout_cancel_url: String, + #[serde(default = "default_checkout_ttl_seconds")] + checkout_ttl_seconds: i64, +} + +impl StripeConfig { + pub(super) fn apply_environment(&mut self) { + if let Ok(value) = std::env::var("APP_STRIPE_SECRET_KEY") { + self.secret_key = value; + } + if let Ok(value) = std::env::var("APP_STRIPE_WEBHOOK_SECRET") { + self.webhook_secret = value; + } + if let Ok(value) = std::env::var("APP_STRIPE_CHECKOUT_SUCCESS_URL") { + self.checkout_success_url = value; + } + if let Ok(value) = std::env::var("APP_STRIPE_CHECKOUT_CANCEL_URL") { + self.checkout_cancel_url = value; + } + if let Ok(value) = std::env::var("APP_STRIPE_CHECKOUT_TTL_SECONDS") + && let Ok(value) = value.parse() + { + self.checkout_ttl_seconds = value; + } + } + + pub(super) fn is_configured(&self) -> bool { + !self.secret_key.trim().is_empty() + && !self.webhook_secret.trim().is_empty() + && !self.checkout_success_url.trim().is_empty() + && !self.checkout_cancel_url.trim().is_empty() + } + + #[must_use] + pub fn secret_key(&self) -> &str { + &self.secret_key + } + + #[must_use] + pub fn webhook_secret(&self) -> &str { + &self.webhook_secret + } + + #[must_use] + pub fn checkout_success_url(&self) -> &str { + &self.checkout_success_url + } + + #[must_use] + pub fn checkout_cancel_url(&self) -> &str { + &self.checkout_cancel_url + } + + #[must_use] + pub const fn checkout_ttl_seconds(&self) -> i64 { + self.checkout_ttl_seconds + } +} + +impl fmt::Debug for StripeConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StripeConfig") + .field("secret_key", &"[REDACTED]") + .field("webhook_secret", &"[REDACTED]") + .field("checkout_success_url", &self.checkout_success_url) + .field("checkout_cancel_url", &self.checkout_cancel_url) + .field("checkout_ttl_seconds", &self.checkout_ttl_seconds) + .finish() + } +} + +const fn default_checkout_ttl_seconds() -> i64 { + DEFAULT_CHECKOUT_TTL_SECONDS +} diff --git a/service/src/context.rs b/service/src/context.rs index 7d87d3e..8ffdfad 100644 --- a/service/src/context.rs +++ b/service/src/context.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::{ Config, Error, - config::{AuthConfig, JwtConfig, RedisConfig}, + config::{AuthConfig, JwtConfig, RedisConfig, StripeConfig}, workers::MailQueue, }; @@ -22,6 +22,7 @@ pub struct AppContext { db: PgPool, queue: Arc>, redis: redis::Client, + stripe: Option, } impl AppContext { @@ -76,6 +77,11 @@ impl AppContext { &self.redis } + #[must_use] + pub const fn stripe(&self) -> Option<&StripeContext> { + self.stripe.as_ref() + } + /// Stores a newly issued refresh token identifier until the token expires. /// /// # Errors @@ -167,10 +173,37 @@ impl TryFrom<&Config> for AppContext { config: cfg.clone(), queue: Arc::new(OnceLock::new()), redis: RedisConfig::connection(cfg.redis())?, + stripe: cfg.stripe().map(StripeContext::new), }) } } +#[derive(Clone)] +pub struct StripeContext { + client: stripe::Client, + webhook_secret: Arc, +} + +impl StripeContext { + #[must_use] + pub fn new(config: &StripeConfig) -> Self { + Self { + client: stripe::Client::new(config.secret_key().to_owned()), + webhook_secret: Arc::from(config.webhook_secret()), + } + } + + #[must_use] + pub const fn client(&self) -> &stripe::Client { + &self.client + } + + #[must_use] + pub const fn webhook_secret(&self) -> &Arc { + &self.webhook_secret + } +} + #[derive(Clone)] pub struct AuthContext { access: JwtContext, diff --git a/service/tests/config/mod.rs b/service/tests/config/mod.rs index b915bf3..3650df0 100644 --- a/service/tests/config/mod.rs +++ b/service/tests/config/mod.rs @@ -218,6 +218,51 @@ fn cloudinary_credentials_are_loaded_from_environment() { assert_eq!(cloudinary.api_secret(), "test-api-secret"); } +#[test] +#[serial] +fn stripe_is_disabled_when_credentials_are_missing() { + let config = Config::from_env(&Environment::Testing).unwrap(); + + assert!(config.stripe().is_none()); +} + +#[test] +#[serial] +fn stripe_configuration_is_loaded_from_environment() { + let _guard = EnvGuard::set(&[ + ("APP_STRIPE_SECRET_KEY", "sk_test_example"), + ("APP_STRIPE_WEBHOOK_SECRET", "whsec_example"), + ( + "APP_STRIPE_CHECKOUT_SUCCESS_URL", + "http://localhost:3000/checkout/success?session_id={CHECKOUT_SESSION_ID}", + ), + ( + "APP_STRIPE_CHECKOUT_CANCEL_URL", + "http://localhost:3000/checkout/cancel", + ), + ("APP_STRIPE_CHECKOUT_TTL_SECONDS", "3600"), + ]); + + let config = Config::from_env(&Environment::Testing).unwrap(); + let stripe = config.stripe().expect("Stripe config should be present"); + + assert_eq!(stripe.secret_key(), "sk_test_example"); + assert_eq!(stripe.webhook_secret(), "whsec_example"); + assert_eq!( + stripe.checkout_success_url(), + "http://localhost:3000/checkout/success?session_id={CHECKOUT_SESSION_ID}" + ); + assert_eq!( + stripe.checkout_cancel_url(), + "http://localhost:3000/checkout/cancel" + ); + assert_eq!(stripe.checkout_ttl_seconds(), 3600); + + let debug = format!("{stripe:?}"); + assert!(!debug.contains("sk_test_example")); + assert!(!debug.contains("whsec_example")); +} + #[test] #[serial] fn missing_environment_file_returns_error() { From a21b46245dbeb460f514749055325a453693183e Mon Sep 17 00:00:00 2001 From: Kibet arap Sum Date: Tue, 14 Jul 2026 17:03:08 +0300 Subject: [PATCH 2/9] feat(payments): persist Stripe payment lifecycles Add payment attempt and webhook event tables with active-attempt uniqueness, provider state constraints, event deduplication, and supporting indexes. Model locked, idempotent payment transitions that confirm orders, reject terminal regressions, compensate failed Checkout creation, and restore reserved inventory exactly once on expiry. Cover conflicts, replay behavior, terminal transition boundaries, paid-versus-expired races, error metadata, and webhook deduplication with rstest and reviewed insta snapshots. --- ...0000_create_stripe_payment_tables.down.sql | 2 + ...120000_create_stripe_payment_tables.up.sql | 61 +++ service/src/models/error.rs | 26 +- service/src/models/mod.rs | 4 + service/src/models/payment_attempts.rs | 406 ++++++++++++++++++ service/src/models/stripe_webhook_events.rs | 66 +++ service/tests/models/mod.rs | 2 + service/tests/models/payment_attempts.rs | 313 ++++++++++++++ ...ession_is_idempotent@payment_attempts.snap | 54 +++ ...ve_attempt_per_order@payment_attempts.snap | 30 ++ ...ventory_exactly_once@payment_attempts.snap | 66 +++ .../failed to succeeded@payment_attempts.snap | 10 + ...a_successful_payment@payment_attempts.snap | 69 +++ ...tempt_error_metadata@payment_attempts.snap | 24 ++ .../succeeded to failed@payment_attempts.snap | 10 + ...ceeded to processing@payment_attempts.snap | 10 + ...ripe_event_once@stripe_webhook_events.snap | 22 + service/tests/models/stripe_webhook_events.rs | 58 +++ 18 files changed, 1229 insertions(+), 4 deletions(-) create mode 100644 service/migrations/20260714120000_create_stripe_payment_tables.down.sql create mode 100644 service/migrations/20260714120000_create_stripe_payment_tables.up.sql create mode 100644 service/src/models/payment_attempts.rs create mode 100644 service/src/models/stripe_webhook_events.rs create mode 100644 service/tests/models/payment_attempts.rs create mode 100644 service/tests/models/snapshots/payment_attempts/attaching_the_same_checkout_session_is_idempotent@payment_attempts.snap create mode 100644 service/tests/models/snapshots/payment_attempts/creates_one_active_attempt_per_order@payment_attempts.snap create mode 100644 service/tests/models/snapshots/payment_attempts/expiration_releases_reserved_inventory_exactly_once@payment_attempts.snap create mode 100644 service/tests/models/snapshots/payment_attempts/failed to succeeded@payment_attempts.snap create mode 100644 service/tests/models/snapshots/payment_attempts/late_expiry_cannot_revert_a_successful_payment@payment_attempts.snap create mode 100644 service/tests/models/snapshots/payment_attempts/payment_attempt_error_metadata@payment_attempts.snap create mode 100644 service/tests/models/snapshots/payment_attempts/succeeded to failed@payment_attempts.snap create mode 100644 service/tests/models/snapshots/payment_attempts/succeeded to processing@payment_attempts.snap create mode 100644 service/tests/models/snapshots/stripe_webhook_events/records_each_stripe_event_once@stripe_webhook_events.snap create mode 100644 service/tests/models/stripe_webhook_events.rs diff --git a/service/migrations/20260714120000_create_stripe_payment_tables.down.sql b/service/migrations/20260714120000_create_stripe_payment_tables.down.sql new file mode 100644 index 0000000..ea49309 --- /dev/null +++ b/service/migrations/20260714120000_create_stripe_payment_tables.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS stripe_webhook_events; +DROP TABLE IF EXISTS payment_attempts; diff --git a/service/migrations/20260714120000_create_stripe_payment_tables.up.sql b/service/migrations/20260714120000_create_stripe_payment_tables.up.sql new file mode 100644 index 0000000..b542e7b --- /dev/null +++ b/service/migrations/20260714120000_create_stripe_payment_tables.up.sql @@ -0,0 +1,61 @@ +CREATE TABLE payment_attempts ( + id SERIAL PRIMARY KEY, + pid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + order_id INTEGER NOT NULL REFERENCES orders (id) ON DELETE RESTRICT, + + provider TEXT NOT NULL DEFAULT 'stripe' + CONSTRAINT payment_attempts_provider_check CHECK (provider IN ('stripe')), + status TEXT NOT NULL DEFAULT 'initiated' + CONSTRAINT payment_attempts_status_check CHECK ( + status IN ( + 'initiated', + 'session_created', + 'processing', + 'succeeded', + 'failed', + 'expired', + 'cancelled', + 'refunded' + ) + ), + amount NUMERIC(12, 2) NOT NULL + CONSTRAINT payment_attempts_amount_check CHECK (amount >= 0), + currency VARCHAR(3) NOT NULL + CONSTRAINT payment_attempts_currency_check CHECK (currency ~ '^[A-Z]{3}$'), + + stripe_checkout_session_id TEXT UNIQUE, + stripe_payment_intent_id TEXT UNIQUE, + checkout_url TEXT, + expires_at TIMESTAMPTZ, + failure_code TEXT, + failure_message TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); + +CREATE TRIGGER payment_attempts_updated_at_trigger +BEFORE UPDATE ON payment_attempts +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX payment_attempts_order_created_idx +ON payment_attempts (order_id, created_at DESC); + +CREATE UNIQUE INDEX one_active_payment_attempt_per_order +ON payment_attempts (order_id) +WHERE status IN ('initiated', 'session_created', 'processing'); + +CREATE TABLE stripe_webhook_events ( + stripe_event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + api_version TEXT, + payload JSONB NOT NULL + CONSTRAINT stripe_webhook_events_payload_check CHECK (jsonb_typeof(payload) = 'object'), + processed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX stripe_webhook_events_type_created_idx +ON stripe_webhook_events (event_type, created_at DESC); diff --git a/service/src/models/error.rs b/service/src/models/error.rs index 66aff21..1630c64 100644 --- a/service/src/models/error.rs +++ b/service/src/models/error.rs @@ -46,6 +46,15 @@ pub enum ModelError { }, #[error("Order items can only be changed while an order is pending")] OrderNotEditable, + #[error("This order already has a payment in progress")] + PaymentInProgress, + #[error("Payment attempt does not exist")] + PaymentAttemptNotFound, + #[error("Payment cannot transition from {current} to {target}")] + InvalidPaymentTransition { + current: String, + target: &'static str, + }, #[error("Invalid claims key")] InvalidClaimsKey, #[error("Invalid credentials provided")] @@ -96,6 +105,10 @@ impl ModelError { return Some(order_error); } + if error.constraint() == Some("one_active_payment_attempt_per_order") { + return Some(Self::PaymentInProgress); + } + match error.kind() { SqlxErrorKind::UniqueViolation => Some(Self::EntityAlreadyExists( Self::constraint_message(error, "A record with the supplied value already exists."), @@ -238,7 +251,6 @@ impl ModelError { #[must_use] pub fn response_body(&self) -> (StatusCode, String) { let (status, message) = match self { - Self::CategoryHasProducts => (StatusCode::CONFLICT, self.to_string()), Self::EntityAlreadyExists(message) => (StatusCode::CONFLICT, message.clone()), Self::EntityNotFound => (StatusCode::NOT_FOUND, "Entity not found".to_string()), Self::InvalidInput(message) => (StatusCode::BAD_REQUEST, message.clone()), @@ -266,9 +278,12 @@ impl ModelError { StatusCode::NOT_FOUND, "One or more product variants do not exist".to_string(), ), - Self::InsufficientStock { .. } | Self::OrderNotEditable => { - (StatusCode::CONFLICT, self.to_string()) - } + Self::CategoryHasProducts + | Self::InsufficientStock { .. } + | Self::OrderNotEditable + | Self::PaymentInProgress + | Self::InvalidPaymentTransition { .. } => (StatusCode::CONFLICT, self.to_string()), + Self::PaymentAttemptNotFound => (StatusCode::NOT_FOUND, self.to_string()), Self::InvalidClaimsKey => (StatusCode::UNAUTHORIZED, "Invalid claims key".to_string()), Self::InvalidCredentials => ( StatusCode::UNAUTHORIZED, @@ -301,6 +316,9 @@ impl ModelError { Self::ProductVariantUnavailable => "product_variant_unavailable", Self::InsufficientStock { .. } => "insufficient_stock", Self::OrderNotEditable => "order_not_editable", + Self::PaymentInProgress => "payment_in_progress", + Self::PaymentAttemptNotFound => "payment_attempt_not_found", + Self::InvalidPaymentTransition { .. } => "invalid_payment_transition", Self::EntityAlreadyExists(_) => "entity_already_exists", Self::EntityNotFound => "entity_not_found", Self::InvalidInput(_) => "invalid_input", diff --git a/service/src/models/mod.rs b/service/src/models/mod.rs index 48d3090..a58d90c 100644 --- a/service/src/models/mod.rs +++ b/service/src/models/mod.rs @@ -4,11 +4,13 @@ pub mod error; pub mod media_assets; pub mod order_items; pub mod orders; +pub mod payment_attempts; pub mod permissions; pub mod products; pub mod roles; pub mod roles_permissions; pub mod seed; +pub mod stripe_webhook_events; pub mod user; pub mod user_roles; @@ -23,6 +25,7 @@ pub use self::{ media_assets::{FinalizeMediaAsset, MediaAsset}, order_items::OrderItem, orders::{Order, OrderWithItems}, + payment_attempts::{CheckoutSessionDetails, PaymentAttempt}, permissions::Permission, products::{ Attribute, AttributeValue, NewPicture, NewProductOption, NewVariant, @@ -32,6 +35,7 @@ pub use self::{ roles::Role, roles_permissions::RolePermission, seed::Seedable, + stripe_webhook_events::StripeWebhookEvent, user::{User, UserAccess}, user_roles::UserRole, }; diff --git a/service/src/models/payment_attempts.rs b/service/src/models/payment_attempts.rs new file mode 100644 index 0000000..8ad094a --- /dev/null +++ b/service/src/models/payment_attempts.rs @@ -0,0 +1,406 @@ +use chrono::{DateTime, FixedOffset}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::{FromRow, Postgres, Transaction}; +use uuid::Uuid; + +use super::{ModelError, ModelResult}; + +const ACTIVE_STATUSES: &[&str] = &["initiated", "session_created", "processing"]; + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAttempt { + id: i32, + pid: Uuid, + order_id: i32, + provider: String, + status: String, + amount: Decimal, + currency: String, + stripe_checkout_session_id: Option, + stripe_payment_intent_id: Option, + checkout_url: Option, + expires_at: Option>, + failure_code: Option, + failure_message: Option, + created_at: DateTime, + updated_at: DateTime, + completed_at: Option>, +} + +#[derive(Debug, Clone)] +pub struct CheckoutSessionDetails<'a> { + pub session_id: &'a str, + pub checkout_url: &'a str, + pub expires_at: DateTime, +} + +impl PaymentAttempt { + /// Creates the sole active payment attempt for an order. + /// + /// The order amount and currency are copied into the attempt so webhook + /// processing can reconcile Stripe's values without trusting event metadata. + /// + /// # Errors + /// Returns [`ModelError::PaymentInProgress`] when the order already has an + /// active attempt, or a database error when the attempt cannot be persisted. + pub async fn create_for_order( + txn: &mut Transaction<'_, Postgres>, + order_id: i32, + amount: Decimal, + currency: &str, + ) -> ModelResult { + Ok(sqlx::query_as::<_, Self>( + r"INSERT INTO payment_attempts (order_id, amount, currency) + VALUES ($1, $2, $3) + RETURNING *", + ) + .bind(order_id) + .bind(amount) + .bind(currency) + .fetch_one(&mut **txn) + .await?) + } + + /// Stores the Stripe-hosted Checkout Session created for this attempt. + /// + /// Reattaching the same Session is an idempotent no-op. Any different or + /// terminal-state transition is rejected. + /// + /// # Errors + /// Returns an attempt-not-found or invalid-transition error, or a database + /// error when the row cannot be locked or updated. + pub async fn attach_checkout_session( + txn: &mut Transaction<'_, Postgres>, + attempt_pid: Uuid, + session: &CheckoutSessionDetails<'_>, + ) -> ModelResult { + let attempt = Self::lock_by_pid(txn, attempt_pid).await?; + if attempt.status == "session_created" + && attempt.stripe_checkout_session_id.as_deref() == Some(session.session_id) + { + return Ok(attempt); + } + attempt.require_status(&["initiated"], "session_created")?; + + Ok(sqlx::query_as::<_, Self>( + r"UPDATE payment_attempts + SET status = 'session_created', + stripe_checkout_session_id = $2, + checkout_url = $3, + expires_at = $4 + WHERE pid = $1 + RETURNING *", + ) + .bind(attempt_pid) + .bind(session.session_id) + .bind(session.checkout_url) + .bind(session.expires_at) + .fetch_one(&mut **txn) + .await?) + } + + /// Marks a Checkout Session as waiting for delayed payment confirmation. + /// + /// # Errors + /// Returns an attempt-not-found or invalid-transition error, or a database + /// error when the attempt cannot be updated. + pub async fn mark_processing( + txn: &mut Transaction<'_, Postgres>, + session_id: &str, + ) -> ModelResult { + let attempt = Self::lock_by_session(txn, session_id).await?; + if attempt.status == "processing" { + return Ok(attempt); + } + attempt.require_status(&["session_created"], "processing")?; + Self::set_status(txn, attempt.pid, "processing").await + } + + /// Marks an attempt successful and confirms its linked order atomically. + /// + /// Replaying success for an already successful attempt is idempotent. + /// Fulfilment is deliberately left unchanged. + /// + /// # Errors + /// Returns an attempt-not-found or invalid-transition error, or a database + /// error when the attempt or order cannot be updated. + pub async fn mark_succeeded( + txn: &mut Transaction<'_, Postgres>, + session_id: &str, + payment_intent_id: Option<&str>, + ) -> ModelResult { + let attempt = Self::lock_by_session(txn, session_id).await?; + if attempt.status == "succeeded" { + return Ok(attempt); + } + attempt.require_status(&["session_created", "processing"], "succeeded")?; + + let updated = sqlx::query_as::<_, Self>( + r"UPDATE payment_attempts + SET status = 'succeeded', + stripe_payment_intent_id = COALESCE($2, stripe_payment_intent_id), + failure_code = NULL, + failure_message = NULL, + completed_at = now() + WHERE pid = $1 + RETURNING *", + ) + .bind(attempt.pid) + .bind(payment_intent_id) + .fetch_one(&mut **txn) + .await?; + + sqlx::query( + r"UPDATE orders + SET status = 'confirmed', payment_status = 'paid' + WHERE id = $1", + ) + .bind(attempt.order_id) + .execute(&mut **txn) + .await?; + + Ok(updated) + } + + /// Records a definitive provider failure without releasing inventory. + /// + /// Inventory remains reserved because a failed asynchronous payment can be + /// retried through a fresh attempt after the order is reconciled. + /// + /// # Errors + /// Returns an attempt-not-found or invalid-transition error, or a database + /// error when the attempt cannot be updated. + pub async fn mark_failed( + txn: &mut Transaction<'_, Postgres>, + session_id: &str, + code: Option<&str>, + message: Option<&str>, + ) -> ModelResult { + let attempt = Self::lock_by_session(txn, session_id).await?; + if attempt.status == "failed" { + return Ok(attempt); + } + attempt.require_status(&["session_created", "processing"], "failed")?; + + let updated = sqlx::query_as::<_, Self>( + r"UPDATE payment_attempts + SET status = 'failed', failure_code = $2, failure_message = $3 + WHERE pid = $1 + RETURNING *", + ) + .bind(attempt.pid) + .bind(code) + .bind(message) + .fetch_one(&mut **txn) + .await?; + + sqlx::query("UPDATE orders SET payment_status = 'failed' WHERE id = $1") + .bind(attempt.order_id) + .execute(&mut **txn) + .await?; + + Ok(updated) + } + + /// Expires an unpaid attempt, cancels its order, and restores stock once. + /// + /// Replayed expiry events are idempotent. A successful payment is never + /// reverted by a late or out-of-order expiry event. + /// + /// # Errors + /// Returns an attempt-not-found or invalid-transition error, or a database + /// error when locking, inventory restoration, or state updates fail. + pub async fn expire_and_release_inventory( + txn: &mut Transaction<'_, Postgres>, + session_id: &str, + ) -> ModelResult { + let attempt = Self::lock_by_session(txn, session_id).await?; + if matches!(attempt.status.as_str(), "expired" | "succeeded") { + return Ok(attempt); + } + attempt.require_status(ACTIVE_STATUSES, "expired")?; + + restore_inventory(txn, attempt.order_id).await?; + cancel_order(txn, attempt.order_id).await?; + + sqlx::query_as::<_, Self>( + r"UPDATE payment_attempts + SET status = 'expired', completed_at = now() + WHERE pid = $1 + RETURNING *", + ) + .bind(attempt.pid) + .fetch_one(&mut **txn) + .await + .map_err(Into::into) + } + + /// Cancels an initiated attempt after Stripe definitively rejects Session creation. + /// + /// # Errors + /// Returns an attempt-not-found or invalid-transition error, or a database + /// error when compensation cannot be completed. + pub async fn cancel_and_release_inventory( + txn: &mut Transaction<'_, Postgres>, + attempt_pid: Uuid, + code: Option<&str>, + message: Option<&str>, + ) -> ModelResult { + let attempt = Self::lock_by_pid(txn, attempt_pid).await?; + if attempt.status == "cancelled" { + return Ok(attempt); + } + attempt.require_status(&["initiated"], "cancelled")?; + + restore_inventory(txn, attempt.order_id).await?; + cancel_order(txn, attempt.order_id).await?; + + Ok(sqlx::query_as::<_, Self>( + r"UPDATE payment_attempts + SET status = 'cancelled', + failure_code = $2, + failure_message = $3, + completed_at = now() + WHERE pid = $1 + RETURNING *", + ) + .bind(attempt.pid) + .bind(code) + .bind(message) + .fetch_one(&mut **txn) + .await?) + } + + /// Finds an attempt by its public identifier. + /// + /// # Errors + /// Returns [`ModelError::PaymentAttemptNotFound`] when no row exists, or a + /// database error when the query fails. + pub async fn find_by_pid(db: &sqlx::PgPool, attempt_pid: Uuid) -> ModelResult { + sqlx::query_as::<_, Self>("SELECT * FROM payment_attempts WHERE pid = $1") + .bind(attempt_pid) + .fetch_optional(db) + .await? + .ok_or(ModelError::PaymentAttemptNotFound) + } + + #[must_use] + pub const fn pid(&self) -> Uuid { + self.pid + } + + #[must_use] + pub fn status(&self) -> &str { + &self.status + } + + #[must_use] + pub const fn order_id(&self) -> i32 { + self.order_id + } + + #[must_use] + pub const fn amount(&self) -> Decimal { + self.amount + } + + #[must_use] + pub fn currency(&self) -> &str { + &self.currency + } + + async fn lock_by_pid( + txn: &mut Transaction<'_, Postgres>, + attempt_pid: Uuid, + ) -> ModelResult { + sqlx::query_as::<_, Self>( + r"SELECT * + FROM payment_attempts + WHERE pid = $1 + FOR UPDATE", + ) + .bind(attempt_pid) + .fetch_optional(&mut **txn) + .await? + .ok_or(ModelError::PaymentAttemptNotFound) + } + + async fn lock_by_session( + txn: &mut Transaction<'_, Postgres>, + session_id: &str, + ) -> ModelResult { + sqlx::query_as::<_, Self>( + r"SELECT * + FROM payment_attempts + WHERE stripe_checkout_session_id = $1 + FOR UPDATE", + ) + .bind(session_id) + .fetch_optional(&mut **txn) + .await? + .ok_or(ModelError::PaymentAttemptNotFound) + } + + fn require_status(&self, allowed: &[&str], target: &'static str) -> ModelResult<()> { + if allowed.contains(&self.status.as_str()) { + Ok(()) + } else { + Err(ModelError::InvalidPaymentTransition { + current: self.status.clone(), + target, + }) + } + } + + async fn set_status( + txn: &mut Transaction<'_, Postgres>, + attempt_pid: Uuid, + status: &str, + ) -> ModelResult { + Ok(sqlx::query_as::<_, Self>( + r"UPDATE payment_attempts + SET status = $2 + WHERE pid = $1 + RETURNING *", + ) + .bind(attempt_pid) + .bind(status) + .fetch_one(&mut **txn) + .await?) + } +} + +async fn restore_inventory(txn: &mut Transaction<'_, Postgres>, order_id: i32) -> ModelResult<()> { + sqlx::query( + r"UPDATE product_variants variant + SET stock_quantity = variant.stock_quantity + restored.quantity + FROM ( + SELECT variant_id, SUM(quantity)::INTEGER AS quantity + FROM order_items + WHERE order_id = $1 AND variant_id IS NOT NULL + GROUP BY variant_id + ) restored + WHERE variant.id = restored.variant_id", + ) + .bind(order_id) + .execute(&mut **txn) + .await?; + Ok(()) +} + +async fn cancel_order(txn: &mut Transaction<'_, Postgres>, order_id: i32) -> ModelResult<()> { + sqlx::query( + r"UPDATE orders + SET status = 'cancelled', + payment_status = 'failed', + fulfillment_status = 'cancelled', + cancelled_at = COALESCE(cancelled_at, now()) + WHERE id = $1", + ) + .bind(order_id) + .execute(&mut **txn) + .await?; + Ok(()) +} diff --git a/service/src/models/stripe_webhook_events.rs b/service/src/models/stripe_webhook_events.rs new file mode 100644 index 0000000..75150d8 --- /dev/null +++ b/service/src/models/stripe_webhook_events.rs @@ -0,0 +1,66 @@ +use serde::Serialize; +use serde_json::Value as JsonValue; +use sqlx::{FromRow, Postgres, Transaction, types::Json}; + +use super::ModelResult; + +#[derive(Debug, Clone, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct StripeWebhookEvent { + stripe_event_id: String, + event_type: String, + api_version: Option, + payload: Json, + processed_at: chrono::DateTime, + created_at: chrono::DateTime, +} + +impl StripeWebhookEvent { + /// Records a verified Stripe event as the transaction's idempotency guard. + /// + /// The return value is `true` only for the first delivery. Duplicate event + /// IDs return `false` and should be acknowledged without reprocessing. + /// + /// # Errors + /// Returns a database error when the event cannot be inserted. + pub async fn record_once( + txn: &mut Transaction<'_, Postgres>, + event_id: &str, + event_type: &str, + api_version: Option<&str>, + payload: &JsonValue, + ) -> ModelResult { + let inserted = sqlx::query_scalar::<_, String>( + r"INSERT INTO stripe_webhook_events ( + stripe_event_id, + event_type, + api_version, + payload + ) + VALUES ($1, $2, $3, $4) + ON CONFLICT (stripe_event_id) DO NOTHING + RETURNING stripe_event_id", + ) + .bind(event_id) + .bind(event_type) + .bind(api_version) + .bind(Json(payload)) + .fetch_optional(&mut **txn) + .await?; + + Ok(inserted.is_some()) + } + + /// Finds a previously recorded event by its Stripe identifier. + /// + /// # Errors + /// Returns a database error when the lookup fails. + pub async fn find_by_id(db: &sqlx::PgPool, event_id: &str) -> ModelResult> { + Ok(sqlx::query_as::<_, Self>( + "SELECT * FROM stripe_webhook_events WHERE stripe_event_id = $1", + ) + .bind(event_id) + .fetch_optional(db) + .await?) + } +} diff --git a/service/tests/models/mod.rs b/service/tests/models/mod.rs index 7fd2304..fdf5c16 100644 --- a/service/tests/models/mod.rs +++ b/service/tests/models/mod.rs @@ -3,10 +3,12 @@ mod categories; mod media_assets; mod order_items; mod orders; +mod payment_attempts; mod permissions; mod products; mod roles; mod roles_permissions; mod seed; +mod stripe_webhook_events; mod user; mod user_roles; diff --git a/service/tests/models/payment_attempts.rs b/service/tests/models/payment_attempts.rs new file mode 100644 index 0000000..51bdb4e --- /dev/null +++ b/service/tests/models/payment_attempts.rs @@ -0,0 +1,313 @@ +use chrono::DateTime; +use insta::{Settings, assert_debug_snapshot, with_settings}; +use rstest::rstest; +use rust_decimal::Decimal; +use serial_test::serial; +use service::{ + models::{CheckoutSessionDetails, ModelError, Order, PaymentAttempt}, + schemas::NewOrder, +}; +use uuid::Uuid; + +use crate::{ + boot_test, seed_data, + utils::{cleanup_date, cleanup_id, cleanup_uuid}, +}; + +macro_rules! configure_insta { + () => { + let mut settings = Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("payment_attempts"); + settings.set_snapshot_path("snapshots/payment_attempts"); + let _guard = settings.bind_to_scope(); + }; +} + +fn checkout_order() -> NewOrder { + serde_json::from_value(serde_json::json!({ + "customerPid": "bd6f7c26-d2c9-487e-b837-8f77be468033", + "items": [{ + "variantPid": "db365773-2ac1-49aa-a4b9-03dcf8ac3401", + "quantity": 2 + }] + })) + .expect("checkout order should deserialize") +} + +fn session<'a>(session_id: &'a str) -> CheckoutSessionDetails<'a> { + CheckoutSessionDetails { + session_id, + checkout_url: "https://checkout.stripe.com/c/pay/test-session", + expires_at: DateTime::parse_from_rfc3339("2026-07-14T12:30:00+03:00") + .expect("expiry should parse"), + } +} + +async fn create_order_and_attempt(ctx: &service::AppContext) -> (Order, PaymentAttempt, i32) { + let order = Order::create(ctx.db(), &checkout_order()) + .await + .expect("order should create"); + let order_id = sqlx::query_scalar::<_, i32>("SELECT id FROM orders WHERE pid = $1") + .bind(order.pid()) + .fetch_one(ctx.db()) + .await + .expect("order id should load"); + let mut txn = ctx.db().begin().await.expect("transaction should begin"); + let attempt = + PaymentAttempt::create_for_order(&mut txn, order_id, Decimal::new(17_998, 2), "USD") + .await + .expect("payment attempt should create"); + txn.commit().await.expect("transaction should commit"); + (order, attempt, order_id) +} + +#[tokio::test] +#[serial] +async fn creates_one_active_attempt_per_order() { + configure_insta!(); + let ctx = boot_test().await.expect("test context should boot"); + seed_data(ctx.db()).await.expect("seed should complete"); + let (_order, attempt, order_id) = create_order_and_attempt(&ctx).await; + + let mut txn = ctx.db().begin().await.expect("transaction should begin"); + let duplicate = + PaymentAttempt::create_for_order(&mut txn, order_id, Decimal::new(17_998, 2), "USD") + .await + .map_err(|error| (error.code(), error.to_string())); + txn.rollback().await.expect("transaction should roll back"); + + with_settings!({ filters => { let mut filters = cleanup_uuid().to_vec(); filters.extend(cleanup_date()); filters.extend(cleanup_id()); filters } }, { + assert_debug_snapshot!("creates_one_active_attempt_per_order", (attempt, duplicate)); + }); +} + +#[tokio::test] +#[serial] +async fn attaching_the_same_checkout_session_is_idempotent() { + configure_insta!(); + let ctx = boot_test().await.expect("test context should boot"); + seed_data(ctx.db()).await.expect("seed should complete"); + let (_order, attempt, _order_id) = create_order_and_attempt(&ctx).await; + let mut txn = ctx.db().begin().await.expect("transaction should begin"); + let attached = PaymentAttempt::attach_checkout_session( + &mut txn, + attempt.pid(), + &session("cs_test_idempotent"), + ) + .await + .expect("session should attach"); + let replayed = PaymentAttempt::attach_checkout_session( + &mut txn, + attempt.pid(), + &session("cs_test_idempotent"), + ) + .await + .expect("same session should be idempotent"); + txn.commit().await.expect("transaction should commit"); + + with_settings!({ filters => { let mut filters = cleanup_uuid().to_vec(); filters.extend(cleanup_date()); filters.extend(cleanup_id()); filters } }, { + assert_debug_snapshot!("attaching_the_same_checkout_session_is_idempotent", (attached, replayed)); + }); +} + +#[derive(Clone, Copy)] +enum ForbiddenTransition { + SucceededToProcessing, + FailedToSucceeded, + SucceededToFailed, +} + +#[rstest] +#[case("succeeded to processing", ForbiddenTransition::SucceededToProcessing)] +#[case("failed to succeeded", ForbiddenTransition::FailedToSucceeded)] +#[case("succeeded to failed", ForbiddenTransition::SucceededToFailed)] +#[tokio::test] +#[serial] +async fn rejects_terminal_payment_state_regressions( + #[case] snapshot: &str, + #[case] transition: ForbiddenTransition, +) { + configure_insta!(); + let ctx = boot_test().await.expect("test context should boot"); + seed_data(ctx.db()).await.expect("seed should complete"); + let (_order, attempt, _order_id) = create_order_and_attempt(&ctx).await; + let mut txn = ctx.db().begin().await.expect("transaction should begin"); + PaymentAttempt::attach_checkout_session( + &mut txn, + attempt.pid(), + &session("cs_test_transition"), + ) + .await + .expect("session should attach"); + + let result = match transition { + ForbiddenTransition::SucceededToProcessing => { + PaymentAttempt::mark_succeeded(&mut txn, "cs_test_transition", Some("pi_test")) + .await + .expect("success should apply"); + PaymentAttempt::mark_processing(&mut txn, "cs_test_transition").await + } + ForbiddenTransition::FailedToSucceeded => { + PaymentAttempt::mark_failed( + &mut txn, + "cs_test_transition", + Some("card_declined"), + Some("Payment was declined"), + ) + .await + .expect("failure should apply"); + PaymentAttempt::mark_succeeded(&mut txn, "cs_test_transition", Some("pi_test")).await + } + ForbiddenTransition::SucceededToFailed => { + PaymentAttempt::mark_succeeded(&mut txn, "cs_test_transition", Some("pi_test")) + .await + .expect("success should apply"); + PaymentAttempt::mark_failed(&mut txn, "cs_test_transition", Some("late_failure"), None) + .await + } + } + .map_err(|error| (error.code(), error.to_string())); + txn.rollback().await.expect("transaction should roll back"); + + assert_debug_snapshot!(snapshot, result); +} + +#[tokio::test] +#[serial] +async fn expiration_releases_reserved_inventory_exactly_once() { + configure_insta!(); + let ctx = boot_test().await.expect("test context should boot"); + seed_data(ctx.db()).await.expect("seed should complete"); + let variant_pid = Uuid::parse_str("db365773-2ac1-49aa-a4b9-03dcf8ac3401").expect("variant pid"); + let stock_before = + sqlx::query_scalar::<_, i32>("SELECT stock_quantity FROM product_variants WHERE pid = $1") + .bind(variant_pid) + .fetch_one(ctx.db()) + .await + .expect("stock should load"); + let (order, attempt, _order_id) = create_order_and_attempt(&ctx).await; + let stock_reserved = + sqlx::query_scalar::<_, i32>("SELECT stock_quantity FROM product_variants WHERE pid = $1") + .bind(variant_pid) + .fetch_one(ctx.db()) + .await + .expect("reserved stock should load"); + + let mut txn = ctx.db().begin().await.expect("transaction should begin"); + PaymentAttempt::attach_checkout_session(&mut txn, attempt.pid(), &session("cs_test_expiry")) + .await + .expect("session should attach"); + let expired = PaymentAttempt::expire_and_release_inventory(&mut txn, "cs_test_expiry") + .await + .expect("attempt should expire"); + let replayed = PaymentAttempt::expire_and_release_inventory(&mut txn, "cs_test_expiry") + .await + .expect("expiry replay should be idempotent"); + txn.commit().await.expect("transaction should commit"); + + let stock_after = + sqlx::query_scalar::<_, i32>("SELECT stock_quantity FROM product_variants WHERE pid = $1") + .bind(variant_pid) + .fetch_one(ctx.db()) + .await + .expect("restored stock should load"); + let state = sqlx::query_as::<_, (String, String, String)>( + "SELECT status, payment_status, fulfillment_status FROM orders WHERE pid = $1", + ) + .bind(order.pid()) + .fetch_one(ctx.db()) + .await + .expect("order state should load"); + + with_settings!({ filters => { let mut filters = cleanup_uuid().to_vec(); filters.extend(cleanup_date()); filters.extend(cleanup_id()); filters } }, { + assert_debug_snapshot!("expiration_releases_reserved_inventory_exactly_once", ( + expired, + replayed, + stock_before, + stock_reserved, + stock_after, + state, + )); + }); +} + +#[tokio::test] +#[serial] +async fn late_expiry_cannot_revert_a_successful_payment() { + configure_insta!(); + let ctx = boot_test().await.expect("test context should boot"); + seed_data(ctx.db()).await.expect("seed should complete"); + let variant_pid = Uuid::parse_str("db365773-2ac1-49aa-a4b9-03dcf8ac3401").expect("variant pid"); + let (_order, attempt, order_id) = create_order_and_attempt(&ctx).await; + let stock_reserved = + sqlx::query_scalar::<_, i32>("SELECT stock_quantity FROM product_variants WHERE pid = $1") + .bind(variant_pid) + .fetch_one(ctx.db()) + .await + .expect("reserved stock should load"); + + let mut txn = ctx.db().begin().await.expect("transaction should begin"); + PaymentAttempt::attach_checkout_session( + &mut txn, + attempt.pid(), + &session("cs_test_paid_then_expired"), + ) + .await + .expect("session should attach"); + let succeeded = + PaymentAttempt::mark_succeeded(&mut txn, "cs_test_paid_then_expired", Some("pi_test_paid")) + .await + .expect("payment should succeed"); + let after_expiry = + PaymentAttempt::expire_and_release_inventory(&mut txn, "cs_test_paid_then_expired") + .await + .expect("late expiry should be acknowledged"); + txn.commit().await.expect("transaction should commit"); + + let stock_after = + sqlx::query_scalar::<_, i32>("SELECT stock_quantity FROM product_variants WHERE pid = $1") + .bind(variant_pid) + .fetch_one(ctx.db()) + .await + .expect("stock should remain reserved"); + let state = sqlx::query_as::<_, (String, String, String)>( + "SELECT status, payment_status, fulfillment_status FROM orders WHERE id = $1", + ) + .bind(order_id) + .fetch_one(ctx.db()) + .await + .expect("order state should load"); + + with_settings!({ filters => { let mut filters = cleanup_uuid().to_vec(); filters.extend(cleanup_date()); filters.extend(cleanup_id()); filters } }, { + assert_debug_snapshot!("late_expiry_cannot_revert_a_successful_payment", ( + succeeded, + after_expiry, + stock_reserved, + stock_after, + state, + )); + }); +} + +#[test] +fn payment_attempt_errors_expose_stable_metadata() { + configure_insta!(); + let errors = [ + ModelError::PaymentInProgress, + ModelError::PaymentAttemptNotFound, + ModelError::InvalidPaymentTransition { + current: "succeeded".to_string(), + target: "expired", + }, + ]; + let metadata = errors + .iter() + .map(|error| { + let (status, message) = error.response_body(); + (error.code(), error.field(), status, message) + }) + .collect::>(); + + assert_debug_snapshot!("payment_attempt_error_metadata", metadata); +} diff --git a/service/tests/models/snapshots/payment_attempts/attaching_the_same_checkout_session_is_idempotent@payment_attempts.snap b/service/tests/models/snapshots/payment_attempts/attaching_the_same_checkout_session_is_idempotent@payment_attempts.snap new file mode 100644 index 0000000..7343eda --- /dev/null +++ b/service/tests/models/snapshots/payment_attempts/attaching_the_same_checkout_session_is_idempotent@payment_attempts.snap @@ -0,0 +1,54 @@ +--- +source: tests/models/payment_attempts.rs +expression: "(attached, replayed)" +--- +( + PaymentAttempt { + id: ID, + pid: PID, + order_id: ID, + provider: "stripe", + status: "session_created", + amount: 179.98, + currency: "USD", + stripe_checkout_session_id: Some( + "cs_test_idempotent", + ), + stripe_payment_intent_id: None, + checkout_url: Some( + "https://checkout.stripe.com/c/pay/test-session", + ), + expires_at: Some( + DATE, + ), + failure_code: None, + failure_message: None, + created_at: DATE, + updated_at: DATE, + completed_at: None, + }, + PaymentAttempt { + id: ID, + pid: PID, + order_id: ID, + provider: "stripe", + status: "session_created", + amount: 179.98, + currency: "USD", + stripe_checkout_session_id: Some( + "cs_test_idempotent", + ), + stripe_payment_intent_id: None, + checkout_url: Some( + "https://checkout.stripe.com/c/pay/test-session", + ), + expires_at: Some( + DATE, + ), + failure_code: None, + failure_message: None, + created_at: DATE, + updated_at: DATE, + completed_at: None, + }, +) diff --git a/service/tests/models/snapshots/payment_attempts/creates_one_active_attempt_per_order@payment_attempts.snap b/service/tests/models/snapshots/payment_attempts/creates_one_active_attempt_per_order@payment_attempts.snap new file mode 100644 index 0000000..d4499d6 --- /dev/null +++ b/service/tests/models/snapshots/payment_attempts/creates_one_active_attempt_per_order@payment_attempts.snap @@ -0,0 +1,30 @@ +--- +source: tests/models/payment_attempts.rs +expression: "(attempt, duplicate)" +--- +( + PaymentAttempt { + id: ID, + pid: PID, + order_id: ID, + provider: "stripe", + status: "initiated", + amount: 179.98, + currency: "USD", + stripe_checkout_session_id: None, + stripe_payment_intent_id: None, + checkout_url: None, + expires_at: None, + failure_code: None, + failure_message: None, + created_at: DATE, + updated_at: DATE, + completed_at: None, + }, + Err( + ( + "payment_in_progress", + "This order already has a payment in progress", + ), + ), +) diff --git a/service/tests/models/snapshots/payment_attempts/expiration_releases_reserved_inventory_exactly_once@payment_attempts.snap b/service/tests/models/snapshots/payment_attempts/expiration_releases_reserved_inventory_exactly_once@payment_attempts.snap new file mode 100644 index 0000000..1c1cb65 --- /dev/null +++ b/service/tests/models/snapshots/payment_attempts/expiration_releases_reserved_inventory_exactly_once@payment_attempts.snap @@ -0,0 +1,66 @@ +--- +source: tests/models/payment_attempts.rs +expression: "(expired, replayed, stock_before, stock_reserved, stock_after, state,)" +--- +( + PaymentAttempt { + id: ID, + pid: PID, + order_id: ID, + provider: "stripe", + status: "expired", + amount: 179.98, + currency: "USD", + stripe_checkout_session_id: Some( + "cs_test_expiry", + ), + stripe_payment_intent_id: None, + checkout_url: Some( + "https://checkout.stripe.com/c/pay/test-session", + ), + expires_at: Some( + DATE, + ), + failure_code: None, + failure_message: None, + created_at: DATE, + updated_at: DATE, + completed_at: Some( + DATE, + ), + }, + PaymentAttempt { + id: ID, + pid: PID, + order_id: ID, + provider: "stripe", + status: "expired", + amount: 179.98, + currency: "USD", + stripe_checkout_session_id: Some( + "cs_test_expiry", + ), + stripe_payment_intent_id: None, + checkout_url: Some( + "https://checkout.stripe.com/c/pay/test-session", + ), + expires_at: Some( + DATE, + ), + failure_code: None, + failure_message: None, + created_at: DATE, + updated_at: DATE, + completed_at: Some( + DATE, + ), + }, + 32, + 30, + 32, + ( + "cancelled", + "failed", + "cancelled", + ), +) diff --git a/service/tests/models/snapshots/payment_attempts/failed to succeeded@payment_attempts.snap b/service/tests/models/snapshots/payment_attempts/failed to succeeded@payment_attempts.snap new file mode 100644 index 0000000..848d8bb --- /dev/null +++ b/service/tests/models/snapshots/payment_attempts/failed to succeeded@payment_attempts.snap @@ -0,0 +1,10 @@ +--- +source: tests/models/payment_attempts.rs +expression: result +--- +Err( + ( + "invalid_payment_transition", + "Payment cannot transition from failed to succeeded", + ), +) diff --git a/service/tests/models/snapshots/payment_attempts/late_expiry_cannot_revert_a_successful_payment@payment_attempts.snap b/service/tests/models/snapshots/payment_attempts/late_expiry_cannot_revert_a_successful_payment@payment_attempts.snap new file mode 100644 index 0000000..601fb4e --- /dev/null +++ b/service/tests/models/snapshots/payment_attempts/late_expiry_cannot_revert_a_successful_payment@payment_attempts.snap @@ -0,0 +1,69 @@ +--- +source: tests/models/payment_attempts.rs +expression: "(succeeded, after_expiry, stock_reserved, stock_after, state,)" +--- +( + PaymentAttempt { + id: ID, + pid: PID, + order_id: ID, + provider: "stripe", + status: "succeeded", + amount: 179.98, + currency: "USD", + stripe_checkout_session_id: Some( + "cs_test_paid_then_expired", + ), + stripe_payment_intent_id: Some( + "pi_test_paid", + ), + checkout_url: Some( + "https://checkout.stripe.com/c/pay/test-session", + ), + expires_at: Some( + DATE, + ), + failure_code: None, + failure_message: None, + created_at: DATE, + updated_at: DATE, + completed_at: Some( + DATE, + ), + }, + PaymentAttempt { + id: ID, + pid: PID, + order_id: ID, + provider: "stripe", + status: "succeeded", + amount: 179.98, + currency: "USD", + stripe_checkout_session_id: Some( + "cs_test_paid_then_expired", + ), + stripe_payment_intent_id: Some( + "pi_test_paid", + ), + checkout_url: Some( + "https://checkout.stripe.com/c/pay/test-session", + ), + expires_at: Some( + DATE, + ), + failure_code: None, + failure_message: None, + created_at: DATE, + updated_at: DATE, + completed_at: Some( + DATE, + ), + }, + 30, + 30, + ( + "confirmed", + "paid", + "unfulfilled", + ), +) diff --git a/service/tests/models/snapshots/payment_attempts/payment_attempt_error_metadata@payment_attempts.snap b/service/tests/models/snapshots/payment_attempts/payment_attempt_error_metadata@payment_attempts.snap new file mode 100644 index 0000000..f622972 --- /dev/null +++ b/service/tests/models/snapshots/payment_attempts/payment_attempt_error_metadata@payment_attempts.snap @@ -0,0 +1,24 @@ +--- +source: tests/models/payment_attempts.rs +expression: metadata +--- +[ + ( + "payment_in_progress", + None, + 409, + "This order already has a payment in progress", + ), + ( + "payment_attempt_not_found", + None, + 404, + "Payment attempt does not exist", + ), + ( + "invalid_payment_transition", + None, + 409, + "Payment cannot transition from succeeded to expired", + ), +] diff --git a/service/tests/models/snapshots/payment_attempts/succeeded to failed@payment_attempts.snap b/service/tests/models/snapshots/payment_attempts/succeeded to failed@payment_attempts.snap new file mode 100644 index 0000000..b9ab254 --- /dev/null +++ b/service/tests/models/snapshots/payment_attempts/succeeded to failed@payment_attempts.snap @@ -0,0 +1,10 @@ +--- +source: tests/models/payment_attempts.rs +expression: result +--- +Err( + ( + "invalid_payment_transition", + "Payment cannot transition from succeeded to failed", + ), +) diff --git a/service/tests/models/snapshots/payment_attempts/succeeded to processing@payment_attempts.snap b/service/tests/models/snapshots/payment_attempts/succeeded to processing@payment_attempts.snap new file mode 100644 index 0000000..b211d45 --- /dev/null +++ b/service/tests/models/snapshots/payment_attempts/succeeded to processing@payment_attempts.snap @@ -0,0 +1,10 @@ +--- +source: tests/models/payment_attempts.rs +expression: result +--- +Err( + ( + "invalid_payment_transition", + "Payment cannot transition from succeeded to processing", + ), +) diff --git a/service/tests/models/snapshots/stripe_webhook_events/records_each_stripe_event_once@stripe_webhook_events.snap b/service/tests/models/snapshots/stripe_webhook_events/records_each_stripe_event_once@stripe_webhook_events.snap new file mode 100644 index 0000000..a9cd615 --- /dev/null +++ b/service/tests/models/snapshots/stripe_webhook_events/records_each_stripe_event_once@stripe_webhook_events.snap @@ -0,0 +1,22 @@ +--- +source: tests/models/stripe_webhook_events.rs +expression: "(first, duplicate, stored)" +--- +( + true, + false, + { + "stripeEventId": String("evt_test_completed"), + "eventType": String("checkout.session.completed"), + "apiVersion": String("2025-02-24.acacia"), + "payload": Object { + "id": String("evt_test_completed"), + "data": Object { + "object": Object { + "id": String("cs_test_completed"), + }, + }, + "type": String("checkout.session.completed"), + }, + }, +) diff --git a/service/tests/models/stripe_webhook_events.rs b/service/tests/models/stripe_webhook_events.rs new file mode 100644 index 0000000..ff1b092 --- /dev/null +++ b/service/tests/models/stripe_webhook_events.rs @@ -0,0 +1,58 @@ +use insta::{Settings, assert_debug_snapshot}; +use serial_test::serial; +use service::models::StripeWebhookEvent; + +use crate::boot_test; + +macro_rules! configure_insta { + () => { + let mut settings = Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("stripe_webhook_events"); + settings.set_snapshot_path("snapshots/stripe_webhook_events"); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn records_each_stripe_event_once() { + configure_insta!(); + let ctx = boot_test().await.expect("test context should boot"); + let payload = serde_json::json!({ + "id": "evt_test_completed", + "type": "checkout.session.completed", + "data": { "object": { "id": "cs_test_completed" } } + }); + let mut txn = ctx.db().begin().await.expect("transaction should begin"); + let first = StripeWebhookEvent::record_once( + &mut txn, + "evt_test_completed", + "checkout.session.completed", + Some("2025-02-24.acacia"), + &payload, + ) + .await + .expect("first event should record"); + let duplicate = StripeWebhookEvent::record_once( + &mut txn, + "evt_test_completed", + "checkout.session.completed", + Some("2025-02-24.acacia"), + &payload, + ) + .await + .expect("duplicate should be acknowledged"); + txn.commit().await.expect("transaction should commit"); + let stored = StripeWebhookEvent::find_by_id(ctx.db(), "evt_test_completed") + .await + .expect("event lookup should succeed"); + let mut stored = serde_json::to_value(stored).expect("stored event should serialize"); + let stored = stored + .as_object_mut() + .expect("stored event should be an object"); + stored.remove("processedAt"); + stored.remove("createdAt"); + + assert_debug_snapshot!("records_each_stripe_event_once", (first, duplicate, stored)); +} From df9c4d45a18c6ae902556918b786c0c9e2adf558 Mon Sep 17 00:00:00 2001 From: Kibet arap Sum Date: Tue, 14 Jul 2026 17:20:51 +0300 Subject: [PATCH 3/9] feat(payments): create customer checkout sessions Add a customer-only checkout endpoint protected by the existing RBAC middleware and derive customer ownership from authenticated claims. Create Stripe-hosted Checkout Sessions from server-priced order snapshots, persist the session against an idempotent payment attempt, and compensate inventory after definitive failures. Cover monetary conversion and checkout authorization boundaries with rstest cases and reviewed Insta snapshots. --- service/src/controllers/orders.rs | 92 +++++++++- service/src/error/mod.rs | 6 + service/src/error/payment.rs | 80 +++++++++ service/src/error/response.rs | 11 +- service/src/lib.rs | 1 + service/src/middlewares/rbac.rs | 60 +++++-- service/src/models/order_items.rs | 15 ++ service/src/models/orders.rs | 170 +++++++++++++----- service/src/payments/mod.rs | 157 ++++++++++++++++ service/src/schemas/mod.rs | 2 +- service/src/schemas/orders.rs | 38 ++++ service/tests/mod.rs | 1 + service/tests/payments.rs | 30 ++++ service/tests/requests/orders.rs | 41 +++++ ...heckout_rejects_staff_accounts@orders.snap | 8 + ...ut_reports_unconfigured_stripe@orders.snap | 8 + ...eckout_requires_authentication@orders.snap | 8 + .../payments/converts_cents@payments.snap | 7 + .../converts_whole_dollars@payments.snap | 7 + .../payments/converts_zero@payments.snap | 7 + .../rejects_fractional_cents@payments.snap | 7 + .../rejects_negative_amounts@payments.snap | 7 + ...rejects_unsupported_currency@payments.snap | 9 + 23 files changed, 708 insertions(+), 64 deletions(-) create mode 100644 service/src/error/payment.rs create mode 100644 service/src/payments/mod.rs create mode 100644 service/tests/payments.rs create mode 100644 service/tests/requests/snapshots/orders/checkout_rejects_staff_accounts@orders.snap create mode 100644 service/tests/requests/snapshots/orders/checkout_reports_unconfigured_stripe@orders.snap create mode 100644 service/tests/requests/snapshots/orders/checkout_requires_authentication@orders.snap create mode 100644 service/tests/snapshots/payments/converts_cents@payments.snap create mode 100644 service/tests/snapshots/payments/converts_whole_dollars@payments.snap create mode 100644 service/tests/snapshots/payments/converts_zero@payments.snap create mode 100644 service/tests/snapshots/payments/rejects_fractional_cents@payments.snap create mode 100644 service/tests/snapshots/payments/rejects_negative_amounts@payments.snap create mode 100644 service/tests/snapshots/payments/rejects_unsupported_currency@payments.snap diff --git a/service/src/controllers/orders.rs b/service/src/controllers/orders.rs index 88bd25e..09fef0f 100644 --- a/service/src/controllers/orders.rs +++ b/service/src/controllers/orders.rs @@ -3,8 +3,9 @@ use axum::{ extract::State, http::StatusCode, response::{IntoResponse, Response}, - routing::{get, patch}, + routing::{get, patch, post}, }; +use serde::Serialize; use uuid::Uuid; use crate::{ @@ -12,11 +13,20 @@ use crate::{ access_control::permissions, context::Claims, middlewares::{AuthLayer, RbacLayer}, - models::{Order, User}, - schemas::{OrderListQuery, UpdateOrder, Validator}, + models::{CheckoutSessionDetails, Order, PaymentAttempt, User}, + payments::create_hosted_checkout_session, + schemas::{CheckoutOrder, OrderListQuery, UpdateOrder, Validator}, utils::{AppExtension, AppJson, AppPath, AppQuery}, }; +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CheckoutResponse { + order_pid: Uuid, + checkout_url: String, + expires_at: chrono::DateTime, +} + async fn actor(ctx: &AppState, claims: &Claims) -> Result<(Uuid, bool)> { let pid = Uuid::parse_str(claims.sub()).map_err(|_| Error::Forbidden)?; let is_customer = User::has_role(ctx.db(), pid, "customer").await?; @@ -71,8 +81,84 @@ async fn update( Ok((StatusCode::OK, Json(order)).into_response()) } +#[tracing::instrument(skip(ctx, claims, params))] +#[debug_handler] +async fn checkout( + State(ctx): State, + AppExtension(claims): AppExtension, + AppJson(params): AppJson, +) -> Result { + let stripe = ctx + .stripe() + .ok_or(crate::error::PaymentError::NotConfigured)?; + let stripe_config = ctx + .config() + .stripe() + .ok_or(crate::error::PaymentError::NotConfigured)?; + let customer_pid = Uuid::parse_str(claims.sub()).map_err(|_| Error::Forbidden)?; + let validator = Validator::new(params); + let validated = validator.validate()?; + + let mut txn = ctx.db().begin().await?; + let order = Order::create_checkout_order(&mut txn, customer_pid, validated).await?; + let attempt = PaymentAttempt::create_for_order( + &mut txn, + order.order.row_id(), + order.order.grand_total(), + order.order.currency(), + ) + .await?; + txn.commit().await?; + + let session = + match create_hosted_checkout_session(stripe, stripe_config, &order, &attempt).await { + Ok(session) => session, + Err(error) => { + if error.should_compensate_checkout() { + let mut txn = ctx.db().begin().await?; + PaymentAttempt::cancel_and_release_inventory( + &mut txn, + attempt.pid(), + Some(error.code()), + None, + ) + .await?; + txn.commit().await?; + } + return Err(Error::Payment(error).into()); + } + }; + + let mut txn = ctx.db().begin().await?; + PaymentAttempt::attach_checkout_session( + &mut txn, + attempt.pid(), + &CheckoutSessionDetails { + session_id: session.session_id(), + checkout_url: session.checkout_url(), + expires_at: session.expires_at(), + }, + ) + .await?; + txn.commit().await?; + + Ok(( + StatusCode::CREATED, + Json(CheckoutResponse { + order_pid: order.order.pid(), + checkout_url: session.checkout_url().to_owned(), + expires_at: session.expires_at(), + }), + ) + .into_response()) +} + pub fn router(ctx: &AppState) -> Router { Router::new() + .route( + "/checkout", + post(checkout).layer(RbacLayer::customers_only(ctx.clone())), + ) .route( "/", get(list) diff --git a/service/src/error/mod.rs b/service/src/error/mod.rs index de288b7..1375b5d 100644 --- a/service/src/error/mod.rs +++ b/service/src/error/mod.rs @@ -6,8 +6,11 @@ use serde_json::Value; use crate::models::ModelError; +mod payment; mod response; +pub use payment::PaymentError; + /// JSON body returned when an API request cannot be completed. /// /// `error` is intended for people, while `code` is a stable identifier that @@ -77,6 +80,8 @@ pub enum Error { #[error(transparent)] PathRejection(#[from] axum::extract::rejection::PathRejection), #[error(transparent)] + Payment(#[from] PaymentError), + #[error(transparent)] QueryRejection(#[from] axum::extract::rejection::QueryRejection), #[error(transparent)] Redis(#[from] redis::RedisError), @@ -128,6 +133,7 @@ impl Error { Self::InvalidToken | Self::Jwt(_) => "invalid_token", Self::MissingCredentials => "missing_credentials", Self::Model(error) => error.code(), + Self::Payment(error) => error.code(), Self::ValidationError(_) => "validation_error", Self::JsonRejection(_) => "invalid_json", Self::PathRejection(_) => "invalid_path_parameter", diff --git a/service/src/error/payment.rs b/service/src/error/payment.rs new file mode 100644 index 0000000..a35c6e2 --- /dev/null +++ b/service/src/error/payment.rs @@ -0,0 +1,80 @@ +use axum::http::StatusCode; + +/// Failures raised while preparing or communicating with the payment provider. +#[derive(Debug, thiserror::Error)] +pub enum PaymentError { + #[error("Stripe payment processing is not configured")] + NotConfigured, + #[error("unsupported checkout currency: {0}")] + UnsupportedCurrency(String), + #[error("checkout amount cannot be represented in the currency's minor unit")] + InvalidAmount, + #[error("Stripe rejected the checkout session request: {0}")] + ProviderRejected(#[source] stripe::StripeError), + #[error("Stripe could not be reached: {0}")] + ProviderUnavailable(#[source] stripe::StripeError), + #[error("Stripe returned a checkout session without a redirect URL")] + InvalidProviderResponse, +} + +impl PaymentError { + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::NotConfigured => "stripe_not_configured", + Self::UnsupportedCurrency(_) => "unsupported_checkout_currency", + Self::InvalidAmount => "invalid_checkout_amount", + Self::ProviderRejected(_) => "checkout_session_rejected", + Self::ProviderUnavailable(_) => "payment_provider_unavailable", + Self::InvalidProviderResponse => "invalid_payment_provider_response", + } + } + + #[must_use] + pub const fn response_body(&self) -> (StatusCode, &'static str) { + match self { + Self::NotConfigured => ( + StatusCode::SERVICE_UNAVAILABLE, + "Payments are temporarily unavailable. Please try again later.", + ), + Self::UnsupportedCurrency(_) => ( + StatusCode::UNPROCESSABLE_ENTITY, + "This order's currency is not currently supported for checkout.", + ), + Self::InvalidAmount => ( + StatusCode::UNPROCESSABLE_ENTITY, + "One or more order prices cannot be processed in this currency.", + ), + Self::ProviderRejected(_) | Self::InvalidProviderResponse => ( + StatusCode::BAD_GATEWAY, + "The payment provider could not create this checkout session.", + ), + Self::ProviderUnavailable(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + "The payment provider is temporarily unavailable. Please try again.", + ), + } + } + + /// Classifies a Stripe API failure without exposing provider internals to clients. + #[must_use] + pub const fn from_stripe(error: stripe::StripeError) -> Self { + match &error { + stripe::StripeError::Timeout | stripe::StripeError::ClientError(_) => { + Self::ProviderUnavailable(error) + } + stripe::StripeError::Stripe(request) if request.http_status == 429 => { + Self::ProviderUnavailable(error) + } + _ => Self::ProviderRejected(error), + } + } + + #[must_use] + pub const fn should_compensate_checkout(&self) -> bool { + matches!( + self, + Self::UnsupportedCurrency(_) | Self::InvalidAmount | Self::ProviderRejected(_) + ) + } +} diff --git a/service/src/error/response.rs b/service/src/error/response.rs index 17e8de4..0b91a31 100644 --- a/service/src/error/response.rs +++ b/service/src/error/response.rs @@ -5,7 +5,7 @@ use axum::{ response::{IntoResponse, Response}, }; -use super::{Error, ErrorResponse, Report}; +use super::{Error, ErrorResponse, PaymentError, Report}; impl IntoResponse for Report { fn into_response(self) -> Response { @@ -19,6 +19,11 @@ impl IntoResponse for Report { let status = error.response_body().0; log_report(&report, status, error.code()); return error.response(); + } else if let Some(error) = report.downcast_ref::() { + let (status, message) = error.response_body(); + log_report(&report, status, error.code()); + let body = Json(ErrorResponse::new(message, error.code())); + return (status, body).into_response(); } log_report(&report, StatusCode::INTERNAL_SERVER_ERROR, "internal_error"); @@ -70,6 +75,10 @@ impl Error { "The email address or password is incorrect.".to_string(), ), Self::Model(model_error) => model_error.response_body(), + Self::Payment(payment_error) => { + let (status, message) = payment_error.response_body(); + (status, message.to_string()) + } Self::ValidationError(val_error) => (StatusCode::BAD_REQUEST, val_error.clone()), Self::JsonRejection(json_rejection) => { (json_rejection.status(), json_rejection.body_text()) diff --git a/service/src/lib.rs b/service/src/lib.rs index a7f7ab4..05b4afc 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -8,6 +8,7 @@ pub mod error; pub mod mailer; pub mod middlewares; pub mod models; +pub mod payments; pub mod schemas; pub mod utils; pub mod views; diff --git a/service/src/middlewares/rbac.rs b/service/src/middlewares/rbac.rs index 1bbe77a..2f5bf5b 100644 --- a/service/src/middlewares/rbac.rs +++ b/service/src/middlewares/rbac.rs @@ -22,6 +22,7 @@ pub struct RbacLayer { required_permission: Option, route_permissions: Vec<(Method, Option<&'static str>, PermissionName)>, allow_customers: bool, + customers_only: bool, deny_customers: bool, } @@ -33,6 +34,7 @@ impl RbacLayer { required_permission: Some(required_permission), route_permissions: Vec::new(), allow_customers: false, + customers_only: false, deny_customers: false, } } @@ -54,6 +56,7 @@ impl RbacLayer { .map(|(method, permission)| (method, None, permission)) .collect(), allow_customers: false, + customers_only: false, deny_customers: false, } } @@ -72,6 +75,7 @@ impl RbacLayer { required_permission: None, route_permissions: route_permissions.into_iter().collect(), allow_customers: false, + customers_only: false, deny_customers: false, } } @@ -87,6 +91,19 @@ impl RbacLayer { self.deny_customers = true; self } + + /// Restricts a route to authenticated users assigned the customer role. + #[must_use] + pub const fn customers_only(state: Arc) -> Self { + Self { + state, + required_permission: None, + route_permissions: Vec::new(), + allow_customers: false, + customers_only: true, + deny_customers: false, + } + } } impl Layer for RbacLayer { @@ -99,6 +116,7 @@ impl Layer for RbacLayer { required_permission: self.required_permission, route_permissions: self.route_permissions.clone(), allow_customers: self.allow_customers, + customers_only: self.customers_only, deny_customers: self.deny_customers, } } @@ -111,6 +129,7 @@ pub struct RbacService { required_permission: Option, route_permissions: Vec<(Method, Option<&'static str>, PermissionName)>, allow_customers: bool, + customers_only: bool, deny_customers: bool, } @@ -127,6 +146,7 @@ impl RbacService { required_permission: Some(required_permission), route_permissions: Vec::new(), allow_customers: false, + customers_only: false, deny_customers: false, } } @@ -151,25 +171,13 @@ where let required_permission = self.required_permission; let route_permissions = self.route_permissions.clone(); let allow_customers = self.allow_customers; + let customers_only = self.customers_only; let deny_customers = self.deny_customers; let clone = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, clone); Box::pin(async move { - let required_permission = required_permission.or_else(|| { - route_permissions - .iter() - .find(|(method, path_suffix, _)| { - method == req.method() - && path_suffix.is_none_or(|suffix| req.uri().path().ends_with(suffix)) - }) - .map(|(_, _, permission)| *permission) - }); - let Some(required_permission) = required_permission else { - return Ok(Error::Forbidden.response()); - }; - let Some(claims) = req.extensions().get::() else { return Ok(Error::MissingCredentials.response()); }; @@ -178,10 +186,17 @@ where return Ok(Error::Forbidden.response()); }; - if crate::models::User::has_role(state.db(), user_pid, "customer") + let is_customer = crate::models::User::has_role(state.db(), user_pid, "customer") .await - .unwrap_or(false) - { + .unwrap_or(false); + if customers_only { + return if is_customer { + inner.call(req).await + } else { + Ok(Error::Forbidden.response()) + }; + } + if is_customer { if deny_customers { return Ok(Error::Forbidden.response()); } @@ -190,6 +205,19 @@ where } } + let required_permission = required_permission.or_else(|| { + route_permissions + .iter() + .find(|(method, path_suffix, _)| { + method == req.method() + && path_suffix.is_none_or(|suffix| req.uri().path().ends_with(suffix)) + }) + .map(|(_, _, permission)| *permission) + }); + let Some(required_permission) = required_permission else { + return Ok(Error::Forbidden.response()); + }; + let granted = Permission::is_granted_to_user_role( state.db(), user_pid, diff --git a/service/src/models/order_items.rs b/service/src/models/order_items.rs index 1f241c9..d38b43d 100644 --- a/service/src/models/order_items.rs +++ b/service/src/models/order_items.rs @@ -136,6 +136,21 @@ impl OrderItem { pub const fn quantity(&self) -> i32 { self.quantity } + + #[must_use] + pub fn product_name(&self) -> &str { + &self.product_name + } + + #[must_use] + pub const fn variant_pid(&self) -> Uuid { + self.variant_pid + } + + #[must_use] + pub const fn unit_price(&self) -> Decimal { + self.unit_price + } } pub(super) struct CheckoutItem { diff --git a/service/src/models/orders.rs b/service/src/models/orders.rs index 91a1521..02cad7e 100644 --- a/service/src/models/orders.rs +++ b/service/src/models/orders.rs @@ -7,7 +7,7 @@ use serde_json::Value as JsonValue; use sqlx::{FromRow, PgPool, types::Json}; use uuid::Uuid; -use crate::schemas::{NewOrder, OrderListQuery, UpdateOrder}; +use crate::schemas::{CheckoutOrder, NewOrder, NewOrderItem, OrderListQuery, UpdateOrder}; use super::{ ModelError, ModelResult, PaginatedModel, Pagination, Seedable, @@ -57,6 +57,39 @@ pub struct OrderWithItems { pub items: Vec, } +struct CheckoutOrderInput<'a> { + customer_pid: Uuid, + billing_address_pid: Option, + shipping_address_pid: Option, + items: &'a [NewOrderItem], + customer_note: Option<&'a str>, + staff_note: Option<&'a str>, +} + +impl<'a> CheckoutOrderInput<'a> { + fn from_new_order(params: &'a NewOrder) -> Self { + Self { + customer_pid: params.customer_pid(), + billing_address_pid: params.billing_address_pid(), + shipping_address_pid: params.shipping_address_pid(), + items: params.items(), + customer_note: params.customer_note(), + staff_note: params.staff_note(), + } + } + + fn from_checkout(customer_pid: Uuid, params: &'a CheckoutOrder) -> Self { + Self { + customer_pid, + billing_address_pid: params.billing_address_pid(), + shipping_address_pid: params.shipping_address_pid(), + items: params.items(), + customer_note: params.customer_note(), + staff_note: None, + } + } +} + impl Order { /// Checks out an order and reserves its inventory in one transaction. /// @@ -72,8 +105,39 @@ impl Order { /// items; or a database error when checkout cannot be committed. #[allow(clippy::too_many_lines)] pub async fn create(db: &PgPool, params: &NewOrder) -> ModelResult { - validate_checkout_items(params)?; let mut txn = db.begin().await?; + let input = CheckoutOrderInput::from_new_order(params); + let created = Self::insert_checkout_order(&mut txn, input).await?; + txn.commit().await?; + Ok(created.order) + } + + /// Creates a customer-owned order inside a caller-managed transaction. + /// + /// Customer identity is supplied separately from the public payload so it + /// can only come from authenticated claims. The caller can create a payment + /// attempt in the same transaction before contacting Stripe. + /// + /// # Errors + /// Returns an invalid-reference error for missing customers, addresses, or + /// variants; an invalid-input error for empty, duplicate, or unavailable + /// items; or a database error when checkout rows cannot be written. + #[allow(clippy::too_many_lines)] + pub async fn create_checkout_order( + txn: &mut sqlx::Transaction<'_, sqlx::Postgres>, + customer_pid: Uuid, + params: &CheckoutOrder, + ) -> ModelResult { + let input = CheckoutOrderInput::from_checkout(customer_pid, params); + Self::insert_checkout_order(txn, input).await + } + + #[allow(clippy::too_many_lines)] + async fn insert_checkout_order( + txn: &mut sqlx::Transaction<'_, sqlx::Postgres>, + input: CheckoutOrderInput<'_>, + ) -> ModelResult { + validate_checkout_items(input.items)?; let (customer_id, customer_name, customer_email) = sqlx::query_as::<_, (i32, String, String)>( r"SELECT id, name, email::text @@ -81,28 +145,28 @@ impl Order { WHERE pid = $1 AND deleted_at IS NULL", ) - .bind(params.customer_pid()) - .fetch_optional(&mut *txn) + .bind(input.customer_pid) + .fetch_optional(&mut **txn) .await? .ok_or(ModelError::CustomerNotFound)?; let billing = load_customer_address( - &mut txn, + txn, customer_id, - params.billing_address_pid(), + input.billing_address_pid, "billingAddressPid", ) .await?; let shipping = load_customer_address( - &mut txn, + txn, customer_id, - params.shipping_address_pid(), + input.shipping_address_pid, "shippingAddressPid", ) .await?; - let mut variants = load_checkout_variants(&mut txn, params).await?; - let subtotal = calculate_subtotal(&variants, params)?; + let mut variants = load_checkout_variants(txn, input.items).await?; + let subtotal = calculate_subtotal(&variants, input.items)?; let order = sqlx::query_as::<_, Self>( r"INSERT INTO orders ( customer_id, @@ -143,17 +207,18 @@ impl Order { .bind(Decimal::ZERO) .bind(Decimal::ZERO) .bind(subtotal) - .bind(params.customer_note().map(str::trim)) - .bind(params.staff_note().map(str::trim)) - .fetch_one(&mut *txn) + .bind(input.customer_note.map(str::trim)) + .bind(input.staff_note.map(str::trim)) + .fetch_one(&mut **txn) .await?; - for requested_item in params.items() { + let mut order_items = Vec::with_capacity(input.items.len()); + for requested_item in input.items { let variant = variants .remove(&requested_item.variant_pid()) .ok_or(ModelError::ProductVariantUnavailable)?; reserve_inventory( - &mut txn, + txn, variant.variant_id, variant.variant_pid, requested_item.quantity(), @@ -161,8 +226,8 @@ impl Order { ) .await?; let line_total = variant.price * Decimal::from(requested_item.quantity()); - OrderItem::insert( - &mut txn, + let item = OrderItem::insert( + txn, CheckoutItem { order_id: order.id, product_id: variant.product_id, @@ -178,10 +243,13 @@ impl Order { }, ) .await?; + order_items.push(item); } - txn.commit().await?; - Ok(order) + Ok(OrderWithItems { + order, + items: order_items, + }) } /// Finds an order by its public ID, including its persisted snapshots. @@ -366,6 +434,26 @@ impl Order { pub fn status(&self) -> &str { &self.status } + + #[must_use] + pub fn customer_email(&self) -> &str { + &self.customer_email + } + + #[must_use] + pub const fn grand_total(&self) -> Decimal { + self.grand_total + } + + #[must_use] + pub fn currency(&self) -> &str { + &self.currency + } + + #[must_use] + pub const fn row_id(&self) -> i32 { + self.id + } } impl Seedable for Order { @@ -485,13 +573,13 @@ impl Seedable for Order { } } -fn validate_checkout_items(params: &NewOrder) -> ModelResult<()> { - if params.items().is_empty() { +fn validate_checkout_items(items: &[NewOrderItem]) -> ModelResult<()> { + if items.is_empty() { return Err(ModelError::OrderHasNoItems); } - let mut variants = HashSet::with_capacity(params.items().len()); - for item in params.items() { + let mut variants = HashSet::with_capacity(items.len()); + for item in items { if item.quantity() < 1 { return Err(ModelError::InvalidOrderItemQuantity); } @@ -505,10 +593,9 @@ fn validate_checkout_items(params: &NewOrder) -> ModelResult<()> { async fn load_checkout_variants( txn: &mut sqlx::Transaction<'_, sqlx::Postgres>, - params: &NewOrder, + items: &[NewOrderItem], ) -> ModelResult> { - let variant_pids = params - .items() + let variant_pids = items .iter() .map(crate::schemas::NewOrderItem::variant_pid) .collect::>(); @@ -555,24 +642,21 @@ async fn load_checkout_variants( fn calculate_subtotal( variants: &HashMap, - params: &NewOrder, + items: &[NewOrderItem], ) -> ModelResult { - params - .items() - .iter() - .try_fold(Decimal::ZERO, |total, item| { - let variant = variants - .get(&item.variant_pid()) - .ok_or(ModelError::ProductVariantUnavailable)?; - if variant.stock_quantity < item.quantity() { - return Err(ModelError::InsufficientStock { - variant_pid: item.variant_pid(), - requested: item.quantity(), - available: variant.stock_quantity, - }); - } - Ok(total + variant.price * Decimal::from(item.quantity())) - }) + items.iter().try_fold(Decimal::ZERO, |total, item| { + let variant = variants + .get(&item.variant_pid()) + .ok_or(ModelError::ProductVariantUnavailable)?; + if variant.stock_quantity < item.quantity() { + return Err(ModelError::InsufficientStock { + variant_pid: item.variant_pid(), + requested: item.quantity(), + available: variant.stock_quantity, + }); + } + Ok(total + variant.price * Decimal::from(item.quantity())) + }) } async fn reserve_inventory( diff --git a/service/src/payments/mod.rs b/service/src/payments/mod.rs new file mode 100644 index 0000000..4b0a5d5 --- /dev/null +++ b/service/src/payments/mod.rs @@ -0,0 +1,157 @@ +use std::collections::HashMap; + +use chrono::{DateTime, FixedOffset, Utc}; +use rust_decimal::{Decimal, prelude::ToPrimitive}; +use serde::Serialize; +use stripe::{ + CheckoutSession, CheckoutSessionMode, CreateCheckoutSession, CreateCheckoutSessionLineItems, + CreateCheckoutSessionLineItemsPriceData, CreateCheckoutSessionLineItemsPriceDataProductData, + Currency, RequestStrategy, +}; + +use crate::{ + config::StripeConfig, + context::StripeContext, + error::PaymentError, + models::{OrderWithItems, PaymentAttempt}, +}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HostedCheckoutSession { + session_id: String, + checkout_url: String, + expires_at: DateTime, +} + +impl HostedCheckoutSession { + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } + + #[must_use] + pub fn checkout_url(&self) -> &str { + &self.checkout_url + } + + #[must_use] + pub const fn expires_at(&self) -> DateTime { + self.expires_at + } +} + +/// Converts a decimal amount into the integer minor unit expected by Stripe. +/// +/// Silk checkout currently supports USD, whose minor unit is one cent. Values +/// with fractions of a cent are rejected instead of silently rounded. +/// +/// # Errors +/// Returns an unsupported-currency or invalid-amount error when the value +/// cannot be represented exactly as a non-negative 64-bit minor-unit amount. +pub fn to_minor_units(amount: Decimal, currency: &str) -> Result { + if !currency.eq_ignore_ascii_case("USD") { + return Err(PaymentError::UnsupportedCurrency(currency.to_owned())); + } + if amount.is_sign_negative() { + return Err(PaymentError::InvalidAmount); + } + + let minor_units = amount * Decimal::ONE_HUNDRED; + if !minor_units.fract().is_zero() { + return Err(PaymentError::InvalidAmount); + } + + minor_units.to_i64().ok_or(PaymentError::InvalidAmount) +} + +/// Creates a Stripe-hosted Checkout Session for a persisted order attempt. +/// +/// Product names, quantities, and prices come exclusively from the order +/// snapshots. The payment attempt PID is used as Stripe's idempotency key so a +/// retry cannot create a second Session for the same attempt. +/// +/// # Errors +/// Returns a validation error for unsupported monetary values, a classified +/// Stripe communication error, or an invalid-response error when Stripe omits +/// the hosted checkout URL. +pub async fn create_hosted_checkout_session( + stripe: &StripeContext, + config: &StripeConfig, + checkout: &OrderWithItems, + attempt: &PaymentAttempt, +) -> Result { + let currency = stripe_currency(checkout.order.currency())?; + let mut line_items = Vec::with_capacity(checkout.items.len()); + for item in &checkout.items { + let quantity = u64::try_from(item.quantity()).map_err(|_| PaymentError::InvalidAmount)?; + line_items.push(CreateCheckoutSessionLineItems { + price_data: Some(CreateCheckoutSessionLineItemsPriceData { + currency, + product_data: Some(CreateCheckoutSessionLineItemsPriceDataProductData { + name: item.product_name().to_owned(), + metadata: Some(HashMap::from([ + ("variant_pid".to_string(), item.variant_pid().to_string()), + ("sku".to_string(), item.sku().to_owned()), + ])), + ..Default::default() + }), + unit_amount: Some(to_minor_units( + item.unit_price(), + checkout.order.currency(), + )?), + ..Default::default() + }), + quantity: Some(quantity), + ..Default::default() + }); + } + + let order_pid = checkout.order.pid().to_string(); + let attempt_pid = attempt.pid().to_string(); + let expires_at = Utc::now().timestamp() + config.checkout_ttl_seconds(); + let mut params = CreateCheckoutSession::new(); + params.cancel_url = Some(config.checkout_cancel_url()); + params.client_reference_id = Some(&order_pid); + params.customer_email = Some(checkout.order.customer_email()); + params.expires_at = Some(expires_at); + params.line_items = Some(line_items); + params.metadata = Some(HashMap::from([ + ("order_pid".to_string(), order_pid.clone()), + ("payment_attempt_pid".to_string(), attempt_pid.clone()), + ])); + params.mode = Some(CheckoutSessionMode::Payment); + params.success_url = Some(config.checkout_success_url()); + + let client = stripe + .client() + .clone() + .with_strategy(RequestStrategy::Idempotent(format!( + "silk-checkout-{attempt_pid}" + ))); + let session = CheckoutSession::create(&client, params) + .await + .map_err(PaymentError::from_stripe)?; + hosted_session(session) +} + +fn stripe_currency(currency: &str) -> Result { + if currency.eq_ignore_ascii_case("USD") { + Ok(Currency::USD) + } else { + Err(PaymentError::UnsupportedCurrency(currency.to_owned())) + } +} + +fn hosted_session(session: CheckoutSession) -> Result { + let checkout_url = session.url.ok_or(PaymentError::InvalidProviderResponse)?; + let expires_at = DateTime::from_timestamp(session.expires_at, 0) + .ok_or(PaymentError::InvalidProviderResponse)? + .fixed_offset(); + + Ok(HostedCheckoutSession { + session_id: session.id.to_string(), + checkout_url, + expires_at, + }) +} diff --git a/service/src/schemas/mod.rs b/service/src/schemas/mod.rs index 8fc63c7..3a768c2 100644 --- a/service/src/schemas/mod.rs +++ b/service/src/schemas/mod.rs @@ -10,7 +10,7 @@ pub mod validator; pub use self::{ auth::{ChangePassword, ForgotPassword, LoginUser, RegisterUser, ResetPassword, UpdateProfile}, categories::{CategoryAttributesInput, NewCategory, UpdateCategory}, - orders::{NewAddress, NewOrder, NewOrderItem, UpdateOrder}, + orders::{CheckoutOrder, NewAddress, NewOrder, NewOrderItem, UpdateOrder}, products::{ CreateProduct, CreateProductPicture, CreateProductTag, CreateProductVariant, CreateVariantOption, ProductListQuery, StockStatus, UpdateProduct, UpdateProductPicture, diff --git a/service/src/schemas/orders.rs b/service/src/schemas/orders.rs index 2320f51..1116f60 100644 --- a/service/src/schemas/orders.rs +++ b/service/src/schemas/orders.rs @@ -124,6 +124,44 @@ pub struct NewOrder { staff_note: Option, } +/// Customer-controlled input for starting a hosted Checkout flow. +/// +/// Customer identity is intentionally absent and must be derived from the +/// authenticated access token by the controller. Staff-only notes are also not +/// accepted on this public customer operation. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CheckoutOrder { + billing_address_pid: Option, + shipping_address_pid: Option, + #[validate(length(min = 1, max = 100), nested)] + items: Vec, + #[validate(length(max = 2_000))] + customer_note: Option, +} + +impl CheckoutOrder { + #[must_use] + pub const fn billing_address_pid(&self) -> Option { + self.billing_address_pid + } + + #[must_use] + pub const fn shipping_address_pid(&self) -> Option { + self.shipping_address_pid + } + + #[must_use] + pub fn items(&self) -> &[NewOrderItem] { + &self.items + } + + #[must_use] + pub fn customer_note(&self) -> Option<&str> { + self.customer_note.as_deref() + } +} + impl NewOrder { #[must_use] pub const fn customer_pid(&self) -> Uuid { diff --git a/service/tests/mod.rs b/service/tests/mod.rs index 2f54762..136f5fd 100644 --- a/service/tests/mod.rs +++ b/service/tests/mod.rs @@ -8,6 +8,7 @@ mod config; mod error; mod middlewares; mod models; +mod payments; mod requests; mod schemas; pub mod utils; diff --git a/service/tests/payments.rs b/service/tests/payments.rs new file mode 100644 index 0000000..c1fb9a8 --- /dev/null +++ b/service/tests/payments.rs @@ -0,0 +1,30 @@ +use insta::{Settings, assert_debug_snapshot}; +use rstest::rstest; +use rust_decimal::Decimal; +use service::payments::to_minor_units; + +macro_rules! configure_insta { + ($(expr:expr),*) => { + let mut settings = Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_path("snapshots/payments"); + settings.set_snapshot_suffix("payments"); + let _guard = settings.bind_to_scope(); + }; +} + +#[rstest] +#[case("converts_zero", Decimal::ZERO, "USD")] +#[case("converts_whole_dollars", Decimal::new(100, 2), "USD")] +#[case("converts_cents", Decimal::new(2499, 2), "usd")] +#[case("rejects_fractional_cents", Decimal::new(24991, 3), "USD")] +#[case("rejects_negative_amounts", Decimal::NEGATIVE_ONE, "USD")] +#[case("rejects_unsupported_currency", Decimal::ONE, "KES")] +fn converts_checkout_amounts( + #[case] test_name: &str, + #[case] amount: Decimal, + #[case] currency: &str, +) { + configure_insta!(); + assert_debug_snapshot!(test_name, to_minor_units(amount, currency)); +} diff --git a/service/tests/requests/orders.rs b/service/tests/requests/orders.rs index 6f66bfe..9c4a81c 100644 --- a/service/tests/requests/orders.rs +++ b/service/tests/requests/orders.rs @@ -78,6 +78,47 @@ async fn revoke_customer_role(db: &sqlx::PgPool, email: &str) { .expect("customer role should be revoked"); } +#[derive(Clone, Copy)] +enum CheckoutActor { + Customer, + Staff, + Anonymous, +} + +#[rstest] +#[case("checkout_requires_authentication", CheckoutActor::Anonymous)] +#[case("checkout_rejects_staff_accounts", CheckoutActor::Staff)] +#[case("checkout_reports_unconfigured_stripe", CheckoutActor::Customer)] +#[tokio::test] +#[serial] +async fn protects_customer_checkout(#[case] test_name: &str, #[case] actor: CheckoutActor) { + crate::request(|server, ctx| async move { + configure_insta!(); + seed_data(ctx.db()).await.expect("seed should complete"); + + let mut request = server.post("/orders/checkout").json(&serde_json::json!({ + "items": [{ + "variantPid": "db365773-2ac1-49aa-a4b9-03dcf8ac3401", + "quantity": 1 + }] + })); + match actor { + CheckoutActor::Customer => { + assign_customer_role(ctx.db(), "john.doe@silk.com").await; + request = with_auth(request, access_token(&server, "john.doe@silk.com").await); + } + CheckoutActor::Staff => { + request = with_auth(request, access_token(&server, "admin@silk.com").await); + } + CheckoutActor::Anonymous => {} + } + + let response = request.await; + assert_debug_snapshot!(test_name, (response.status_code(), response.text())); + }) + .await; +} + #[rstest] #[case("can_list_orders", "/orders")] #[case( diff --git a/service/tests/requests/snapshots/orders/checkout_rejects_staff_accounts@orders.snap b/service/tests/requests/snapshots/orders/checkout_rejects_staff_accounts@orders.snap new file mode 100644 index 0000000..790e5a8 --- /dev/null +++ b/service/tests/requests/snapshots/orders/checkout_rejects_staff_accounts@orders.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/orders.rs +expression: "(response.status_code(), response.text())" +--- +( + 403, + "{\"error\":\"You do not have permission to perform this action.\",\"code\":\"forbidden\"}", +) diff --git a/service/tests/requests/snapshots/orders/checkout_reports_unconfigured_stripe@orders.snap b/service/tests/requests/snapshots/orders/checkout_reports_unconfigured_stripe@orders.snap new file mode 100644 index 0000000..eca1019 --- /dev/null +++ b/service/tests/requests/snapshots/orders/checkout_reports_unconfigured_stripe@orders.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/orders.rs +expression: "(response.status_code(), response.text())" +--- +( + 503, + "{\"error\":\"Payments are temporarily unavailable. Please try again later.\",\"code\":\"stripe_not_configured\"}", +) diff --git a/service/tests/requests/snapshots/orders/checkout_requires_authentication@orders.snap b/service/tests/requests/snapshots/orders/checkout_requires_authentication@orders.snap new file mode 100644 index 0000000..50f7d92 --- /dev/null +++ b/service/tests/requests/snapshots/orders/checkout_requires_authentication@orders.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/orders.rs +expression: "(response.status_code(), response.text())" +--- +( + 401, + "{\"error\":\"Authentication is required. Please sign in and try again.\",\"code\":\"missing_credentials\"}", +) diff --git a/service/tests/snapshots/payments/converts_cents@payments.snap b/service/tests/snapshots/payments/converts_cents@payments.snap new file mode 100644 index 0000000..b05fd6d --- /dev/null +++ b/service/tests/snapshots/payments/converts_cents@payments.snap @@ -0,0 +1,7 @@ +--- +source: tests/payments.rs +expression: "to_minor_units(amount, currency)" +--- +Ok( + 2499, +) diff --git a/service/tests/snapshots/payments/converts_whole_dollars@payments.snap b/service/tests/snapshots/payments/converts_whole_dollars@payments.snap new file mode 100644 index 0000000..d9650e8 --- /dev/null +++ b/service/tests/snapshots/payments/converts_whole_dollars@payments.snap @@ -0,0 +1,7 @@ +--- +source: tests/payments.rs +expression: "to_minor_units(amount, currency)" +--- +Ok( + 100, +) diff --git a/service/tests/snapshots/payments/converts_zero@payments.snap b/service/tests/snapshots/payments/converts_zero@payments.snap new file mode 100644 index 0000000..90e1ab5 --- /dev/null +++ b/service/tests/snapshots/payments/converts_zero@payments.snap @@ -0,0 +1,7 @@ +--- +source: tests/payments.rs +expression: "to_minor_units(amount, currency)" +--- +Ok( + 0, +) diff --git a/service/tests/snapshots/payments/rejects_fractional_cents@payments.snap b/service/tests/snapshots/payments/rejects_fractional_cents@payments.snap new file mode 100644 index 0000000..6fd8f4c --- /dev/null +++ b/service/tests/snapshots/payments/rejects_fractional_cents@payments.snap @@ -0,0 +1,7 @@ +--- +source: tests/payments.rs +expression: "to_minor_units(amount, currency)" +--- +Err( + InvalidAmount, +) diff --git a/service/tests/snapshots/payments/rejects_negative_amounts@payments.snap b/service/tests/snapshots/payments/rejects_negative_amounts@payments.snap new file mode 100644 index 0000000..6fd8f4c --- /dev/null +++ b/service/tests/snapshots/payments/rejects_negative_amounts@payments.snap @@ -0,0 +1,7 @@ +--- +source: tests/payments.rs +expression: "to_minor_units(amount, currency)" +--- +Err( + InvalidAmount, +) diff --git a/service/tests/snapshots/payments/rejects_unsupported_currency@payments.snap b/service/tests/snapshots/payments/rejects_unsupported_currency@payments.snap new file mode 100644 index 0000000..130b365 --- /dev/null +++ b/service/tests/snapshots/payments/rejects_unsupported_currency@payments.snap @@ -0,0 +1,9 @@ +--- +source: tests/payments.rs +expression: "to_minor_units(amount, currency)" +--- +Err( + UnsupportedCurrency( + "KES", + ), +) From 1330103e7384ad0cf2bdacb12ec0e8126d3837df Mon Sep 17 00:00:00 2001 From: Kibet arap Sum Date: Tue, 14 Jul 2026 17:33:46 +0300 Subject: [PATCH 4/9] feat(payments): process verified Stripe webhooks Expose a public raw-body webhook endpoint that authenticates Stripe signatures without relying on Silk sessions. Deduplicate deliveries transactionally, recover interrupted Checkout Session persistence, and reconcile mode, metadata, order identity, amount, and currency before applying payment transitions. Handle immediate and delayed success, delayed failure, expiry, and unknown events while proving replay safety and single inventory restoration through reviewed snapshots. --- service/src/config/stripe.rs | 13 ++ service/src/controllers/mod.rs | 2 + service/src/controllers/payments.rs | 48 ++++ service/src/error/payment.rs | 28 +++ service/src/models/mod.rs | 2 +- service/src/models/payment_attempts.rs | 78 +++++++ service/src/payments/mod.rs | 184 ++++++++++++++- service/tests/payments.rs | 220 +++++++++++++++++- service/tests/requests/mod.rs | 1 + service/tests/requests/payments.rs | 24 ++ ...requires_a_signature@payment_requests.snap | 8 + .../acknowledges_unknown_event@payments.snap | 9 + .../completes_delayed_payment@payments.snap | 9 + .../completes_paid_checkout@payments.snap | 9 + .../expires_unpaid_checkout@payments.snap | 9 + ...ords_delayed_payment_failure@payments.snap | 9 + ...s_invalid_webhook_signatures@payments.snap | 7 + .../waits_for_delayed_payment@payments.snap | 9 + 18 files changed, 665 insertions(+), 4 deletions(-) create mode 100644 service/src/controllers/payments.rs create mode 100644 service/tests/requests/payments.rs create mode 100644 service/tests/requests/snapshots/payments/stripe_webhook_requires_a_signature@payment_requests.snap create mode 100644 service/tests/snapshots/payments/acknowledges_unknown_event@payments.snap create mode 100644 service/tests/snapshots/payments/completes_delayed_payment@payments.snap create mode 100644 service/tests/snapshots/payments/completes_paid_checkout@payments.snap create mode 100644 service/tests/snapshots/payments/expires_unpaid_checkout@payments.snap create mode 100644 service/tests/snapshots/payments/records_delayed_payment_failure@payments.snap create mode 100644 service/tests/snapshots/payments/rejects_invalid_webhook_signatures@payments.snap create mode 100644 service/tests/snapshots/payments/waits_for_delayed_payment@payments.snap diff --git a/service/src/config/stripe.rs b/service/src/config/stripe.rs index c2d1fbb..b2251eb 100644 --- a/service/src/config/stripe.rs +++ b/service/src/config/stripe.rs @@ -21,6 +21,8 @@ pub struct StripeConfig { checkout_cancel_url: String, #[serde(default = "default_checkout_ttl_seconds")] checkout_ttl_seconds: i64, + #[serde(default)] + live_mode: bool, } impl StripeConfig { @@ -42,6 +44,11 @@ impl StripeConfig { { self.checkout_ttl_seconds = value; } + if let Ok(value) = std::env::var("APP_STRIPE_LIVE_MODE") + && let Ok(value) = value.parse() + { + self.live_mode = value; + } } pub(super) fn is_configured(&self) -> bool { @@ -75,6 +82,11 @@ impl StripeConfig { pub const fn checkout_ttl_seconds(&self) -> i64 { self.checkout_ttl_seconds } + + #[must_use] + pub const fn live_mode(&self) -> bool { + self.live_mode + } } impl fmt::Debug for StripeConfig { @@ -86,6 +98,7 @@ impl fmt::Debug for StripeConfig { .field("checkout_success_url", &self.checkout_success_url) .field("checkout_cancel_url", &self.checkout_cancel_url) .field("checkout_ttl_seconds", &self.checkout_ttl_seconds) + .field("live_mode", &self.live_mode) .finish() } } diff --git a/service/src/controllers/mod.rs b/service/src/controllers/mod.rs index 72150cf..6b2d05f 100644 --- a/service/src/controllers/mod.rs +++ b/service/src/controllers/mod.rs @@ -11,6 +11,7 @@ mod auth; mod categories; mod media; mod orders; +mod payments; mod permissions; mod products; mod roles; @@ -52,6 +53,7 @@ pub fn router(ctx: &AppState) -> Router { .nest("/categories", categories::router(ctx)) .nest("/products", products::router(ctx)) .nest("/orders", orders::router(ctx)) + .nest("/payments", payments::router(ctx)) .nest("/media", media::router(ctx)) .fallback(not_found) } diff --git a/service/src/controllers/payments.rs b/service/src/controllers/payments.rs new file mode 100644 index 0000000..37eafd3 --- /dev/null +++ b/service/src/controllers/payments.rs @@ -0,0 +1,48 @@ +use axum::{ + Router, + body::Bytes, + debug_handler, + extract::State, + http::{HeaderMap, StatusCode}, + routing::post, +}; + +use crate::{ + AppState, + error::PaymentError, + payments::{process_stripe_event, verify_stripe_event}, +}; + +#[tracing::instrument(skip(ctx, headers, body))] +#[debug_handler] +async fn stripe_webhook( + State(ctx): State, + headers: HeaderMap, + body: Bytes, +) -> crate::Result { + let signature = headers + .get("stripe-signature") + .and_then(|value| value.to_str().ok()) + .ok_or(PaymentError::MissingWebhookSignature)?; + let payload = std::str::from_utf8(&body).map_err(|_| PaymentError::InvalidWebhookPayload)?; + let stripe = ctx.stripe().ok_or(PaymentError::NotConfigured)?; + let config = ctx.config().stripe().ok_or(PaymentError::NotConfigured)?; + let event = verify_stripe_event(payload, signature, stripe.webhook_secret())?; + let parsed_payload = + serde_json::from_str(payload).map_err(|_| PaymentError::InvalidWebhookPayload)?; + + Box::pin(process_stripe_event( + ctx.db(), + config, + event, + &parsed_payload, + )) + .await?; + Ok(StatusCode::OK) +} + +pub fn router(ctx: &AppState) -> Router { + Router::new() + .route("/stripe/webhook", post(stripe_webhook)) + .with_state(ctx.clone()) +} diff --git a/service/src/error/payment.rs b/service/src/error/payment.rs index a35c6e2..1814937 100644 --- a/service/src/error/payment.rs +++ b/service/src/error/payment.rs @@ -15,6 +15,16 @@ pub enum PaymentError { ProviderUnavailable(#[source] stripe::StripeError), #[error("Stripe returned a checkout session without a redirect URL")] InvalidProviderResponse, + #[error("the Stripe signature header is missing")] + MissingWebhookSignature, + #[error("the Stripe webhook signature is invalid")] + InvalidWebhookSignature, + #[error("the Stripe webhook payload is invalid")] + InvalidWebhookPayload, + #[error("Stripe webhook reconciliation failed: {0}")] + WebhookReconciliation(&'static str), + #[error("Stripe webhook processing failed: {0}")] + WebhookProcessing(String), } impl PaymentError { @@ -27,6 +37,12 @@ impl PaymentError { Self::ProviderRejected(_) => "checkout_session_rejected", Self::ProviderUnavailable(_) => "payment_provider_unavailable", Self::InvalidProviderResponse => "invalid_payment_provider_response", + Self::MissingWebhookSignature | Self::InvalidWebhookSignature => { + "invalid_stripe_signature" + } + Self::InvalidWebhookPayload => "invalid_stripe_payload", + Self::WebhookReconciliation(_) => "webhook_reconciliation_failed", + Self::WebhookProcessing(_) => "webhook_processing_failed", } } @@ -53,6 +69,18 @@ impl PaymentError { StatusCode::SERVICE_UNAVAILABLE, "The payment provider is temporarily unavailable. Please try again.", ), + Self::MissingWebhookSignature | Self::InvalidWebhookSignature => ( + StatusCode::BAD_REQUEST, + "The Stripe webhook signature is missing or invalid.", + ), + Self::InvalidWebhookPayload => ( + StatusCode::BAD_REQUEST, + "The Stripe webhook payload could not be verified.", + ), + Self::WebhookReconciliation(_) | Self::WebhookProcessing(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "The Stripe event could not be processed and will be retried.", + ), } } diff --git a/service/src/models/mod.rs b/service/src/models/mod.rs index a58d90c..a101c21 100644 --- a/service/src/models/mod.rs +++ b/service/src/models/mod.rs @@ -25,7 +25,7 @@ pub use self::{ media_assets::{FinalizeMediaAsset, MediaAsset}, order_items::OrderItem, orders::{Order, OrderWithItems}, - payment_attempts::{CheckoutSessionDetails, PaymentAttempt}, + payment_attempts::{CheckoutSessionDetails, PaymentAttempt, PaymentReconciliation}, permissions::Permission, products::{ Attribute, AttributeValue, NewPicture, NewProductOption, NewVariant, diff --git a/service/src/models/payment_attempts.rs b/service/src/models/payment_attempts.rs index 8ad094a..f9b070a 100644 --- a/service/src/models/payment_attempts.rs +++ b/service/src/models/payment_attempts.rs @@ -36,6 +36,14 @@ pub struct CheckoutSessionDetails<'a> { pub expires_at: DateTime, } +#[derive(Debug, Clone)] +pub struct PaymentReconciliation { + pub attempt_pid: Uuid, + pub order_pid: Uuid, + pub amount: Decimal, + pub currency: String, +} + impl PaymentAttempt { /// Creates the sole active payment attempt for an order. /// @@ -286,6 +294,76 @@ impl PaymentAttempt { .ok_or(ModelError::PaymentAttemptNotFound) } + /// Recovers the Session link when Stripe succeeded after the application + /// committed its attempt but before it stored Stripe's response. + /// + /// Replaying the same link is idempotent. A different Session or a terminal + /// attempt is rejected before any order state changes. + /// + /// # Errors + /// Returns an attempt-not-found or invalid-transition error, or a database + /// error when the attempt cannot be locked or updated. + pub async fn recover_checkout_session( + txn: &mut Transaction<'_, Postgres>, + attempt_pid: Uuid, + session_id: &str, + checkout_url: Option<&str>, + expires_at: DateTime, + ) -> ModelResult { + let attempt = Self::lock_by_pid(txn, attempt_pid).await?; + if attempt.stripe_checkout_session_id.as_deref() == Some(session_id) { + return Ok(attempt); + } + attempt.require_status(&["initiated"], "session_created")?; + + Ok(sqlx::query_as::<_, Self>( + r"UPDATE payment_attempts + SET status = 'session_created', + stripe_checkout_session_id = $2, + checkout_url = COALESCE($3, checkout_url), + expires_at = $4 + WHERE pid = $1 + RETURNING *", + ) + .bind(attempt_pid) + .bind(session_id) + .bind(checkout_url) + .bind(expires_at) + .fetch_one(&mut **txn) + .await?) + } + + /// Locks and returns the identifiers and monetary values needed to + /// reconcile a verified Stripe Session against Silk's persisted records. + /// + /// # Errors + /// Returns an attempt-not-found error when the Session is unknown, or a + /// database error when the linked rows cannot be locked. + pub async fn reconciliation_for_session( + txn: &mut Transaction<'_, Postgres>, + session_id: &str, + ) -> ModelResult { + sqlx::query_as::<_, (Uuid, Uuid, Decimal, String)>( + r"SELECT attempt.pid, orders.pid, attempt.amount, attempt.currency + FROM payment_attempts attempt + JOIN orders ON orders.id = attempt.order_id + WHERE attempt.stripe_checkout_session_id = $1 + FOR UPDATE OF attempt, orders", + ) + .bind(session_id) + .fetch_optional(&mut **txn) + .await? + .map( + |(attempt_pid, order_pid, amount, currency)| PaymentReconciliation { + attempt_pid, + order_pid, + amount, + currency, + }, + ) + .ok_or(ModelError::PaymentAttemptNotFound) + } + #[must_use] pub const fn pid(&self) -> Uuid { self.pid diff --git a/service/src/payments/mod.rs b/service/src/payments/mod.rs index 4b0a5d5..9b3ebc4 100644 --- a/service/src/payments/mod.rs +++ b/service/src/payments/mod.rs @@ -6,14 +6,14 @@ use serde::Serialize; use stripe::{ CheckoutSession, CheckoutSessionMode, CreateCheckoutSession, CreateCheckoutSessionLineItems, CreateCheckoutSessionLineItemsPriceData, CreateCheckoutSessionLineItemsPriceDataProductData, - Currency, RequestStrategy, + Currency, Event, EventObject, EventType, RequestStrategy, }; use crate::{ config::StripeConfig, context::StripeContext, error::PaymentError, - models::{OrderWithItems, PaymentAttempt}, + models::{OrderWithItems, PaymentAttempt, StripeWebhookEvent}, }; #[derive(Debug, Clone, Serialize)] @@ -155,3 +155,183 @@ fn hosted_session(session: CheckoutSession) -> Result Result<(), PaymentError> { + if event.livemode != config.live_mode() { + return Err(PaymentError::WebhookReconciliation("event mode mismatch")); + } + + let mut txn = db + .begin() + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string()))?; + let event_id = event.id.to_string(); + let event_type = event.type_.to_string(); + let is_new = StripeWebhookEvent::record_once( + &mut txn, + &event_id, + &event_type, + event.api_version.as_deref(), + payload, + ) + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string()))?; + if !is_new { + txn.commit() + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string()))?; + return Ok(()); + } + + match (event.type_, event.data.object) { + ( + EventType::CheckoutSessionCompleted | EventType::CheckoutSessionAsyncPaymentSucceeded, + EventObject::CheckoutSession(session), + ) => process_completed(&mut txn, config, &session).await?, + (EventType::CheckoutSessionExpired, EventObject::CheckoutSession(session)) => { + reconcile_session(&mut txn, config, &session).await?; + PaymentAttempt::expire_and_release_inventory(&mut txn, session.id.as_ref()) + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string()))?; + } + (EventType::CheckoutSessionAsyncPaymentFailed, EventObject::CheckoutSession(session)) => { + reconcile_session(&mut txn, config, &session).await?; + PaymentAttempt::mark_failed( + &mut txn, + session.id.as_ref(), + Some("async_payment_failed"), + None, + ) + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string()))?; + } + _ => {} + } + + txn.commit() + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string())) +} + +async fn process_completed( + txn: &mut sqlx::Transaction<'_, sqlx::Postgres>, + config: &StripeConfig, + session: &CheckoutSession, +) -> Result<(), PaymentError> { + reconcile_session(txn, config, session).await?; + let session_id = session.id.to_string(); + if session.payment_status == stripe::CheckoutSessionPaymentStatus::Paid { + let payment_intent_id = session + .payment_intent + .as_ref() + .map(stripe::Expandable::id) + .map(|id| id.to_string()); + PaymentAttempt::mark_succeeded(txn, &session_id, payment_intent_id.as_deref()) + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string()))?; + } else { + PaymentAttempt::mark_processing(txn, &session_id) + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string()))?; + } + Ok(()) +} + +async fn reconcile_session( + txn: &mut sqlx::Transaction<'_, sqlx::Postgres>, + config: &StripeConfig, + session: &CheckoutSession, +) -> Result<(), PaymentError> { + if session.livemode != config.live_mode() { + return Err(PaymentError::WebhookReconciliation("session mode mismatch")); + } + if session.mode != CheckoutSessionMode::Payment { + return Err(PaymentError::WebhookReconciliation( + "checkout mode mismatch", + )); + } + + let metadata = session + .metadata + .as_ref() + .ok_or(PaymentError::WebhookReconciliation("missing metadata"))?; + let attempt_pid = parse_metadata_pid(metadata, "payment_attempt_pid")?; + let order_pid = parse_metadata_pid(metadata, "order_pid")?; + let expires_at = DateTime::from_timestamp(session.expires_at, 0) + .ok_or(PaymentError::WebhookReconciliation( + "invalid session expiry", + ))? + .fixed_offset(); + let session_id = session.id.to_string(); + PaymentAttempt::recover_checkout_session( + txn, + attempt_pid, + &session_id, + session.url.as_deref(), + expires_at, + ) + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string()))?; + + let stored = PaymentAttempt::reconciliation_for_session(txn, &session_id) + .await + .map_err(|error| PaymentError::WebhookProcessing(error.to_string()))?; + if stored.attempt_pid != attempt_pid || stored.order_pid != order_pid { + return Err(PaymentError::WebhookReconciliation("metadata mismatch")); + } + let order_reference = order_pid.to_string(); + if session.client_reference_id.as_deref() != Some(order_reference.as_str()) { + return Err(PaymentError::WebhookReconciliation( + "client reference mismatch", + )); + } + if session.amount_total != Some(to_minor_units(stored.amount, &stored.currency)?) { + return Err(PaymentError::WebhookReconciliation("amount mismatch")); + } + let session_currency = session.currency.map(|currency| currency.to_string()); + if session_currency + .as_deref() + .map(str::to_uppercase) + .as_deref() + != Some(stored.currency.as_str()) + { + return Err(PaymentError::WebhookReconciliation("currency mismatch")); + } + Ok(()) +} + +fn parse_metadata_pid( + metadata: &HashMap, + key: &'static str, +) -> Result { + metadata + .get(key) + .and_then(|value| uuid::Uuid::parse_str(value).ok()) + .ok_or(PaymentError::WebhookReconciliation(key)) +} + +/// Verifies a Stripe signature against the exact raw request payload. +/// +/// # Errors +/// Returns a stable invalid-signature error for malformed, expired, or +/// mismatched signatures. +pub fn verify_stripe_event( + payload: &str, + signature: &str, + webhook_secret: &str, +) -> Result { + stripe::Webhook::construct_event(payload, signature, webhook_secret) + .map_err(|_| PaymentError::InvalidWebhookSignature) +} diff --git a/service/tests/payments.rs b/service/tests/payments.rs index c1fb9a8..e5397a1 100644 --- a/service/tests/payments.rs +++ b/service/tests/payments.rs @@ -1,7 +1,16 @@ use insta::{Settings, assert_debug_snapshot}; use rstest::rstest; use rust_decimal::Decimal; -use service::payments::to_minor_units; +use serial_test::serial; +use service::{ + config::StripeConfig, + models::{CheckoutSessionDetails, Order, PaymentAttempt}, + payments::{process_stripe_event, to_minor_units, verify_stripe_event}, +}; +use stripe::{ + CheckoutSession, CheckoutSessionMode, CheckoutSessionPaymentStatus, Currency, Event, + EventObject, EventType, NotificationEventData, +}; macro_rules! configure_insta { ($(expr:expr),*) => { @@ -28,3 +37,212 @@ fn converts_checkout_amounts( configure_insta!(); assert_debug_snapshot!(test_name, to_minor_units(amount, currency)); } + +#[test] +fn rejects_invalid_webhook_signatures() { + configure_insta!(); + let result = verify_stripe_event("{}", "t=1720950000,v1=invalid", "whsec_test"); + assert_debug_snapshot!("rejects_invalid_webhook_signatures", result); +} + +fn stripe_config() -> StripeConfig { + serde_json::from_value(serde_json::json!({ + "secret_key": "sk_test_placeholder", + "webhook_secret": "whsec_placeholder", + "checkout_success_url": "http://localhost:3000/checkout/success", + "checkout_cancel_url": "http://localhost:3000/checkout/cancel", + "checkout_ttl_seconds": 1800, + "live_mode": false + })) + .expect("test Stripe config should deserialize") +} + +fn checkout_event( + event_id: &str, + event_type: EventType, + session_id: &str, + attempt_pid: uuid::Uuid, + order_pid: uuid::Uuid, + amount: i64, + payment_status: CheckoutSessionPaymentStatus, +) -> Event { + let session = CheckoutSession { + id: session_id.parse().expect("session ID should parse"), + amount_total: Some(amount), + client_reference_id: Some(order_pid.to_string()), + currency: Some(Currency::USD), + expires_at: chrono::Utc::now().timestamp() + 1800, + livemode: false, + metadata: Some(std::collections::HashMap::from([ + ("order_pid".to_string(), order_pid.to_string()), + ("payment_attempt_pid".to_string(), attempt_pid.to_string()), + ])), + mode: CheckoutSessionMode::Payment, + payment_status, + ..Default::default() + }; + Event { + id: event_id.parse().expect("event ID should parse"), + api_version: Some("2025-02-24.acacia".to_string()), + data: NotificationEventData { + object: EventObject::CheckoutSession(session), + previous_attributes: None, + }, + livemode: false, + type_: event_type, + ..Default::default() + } +} + +#[rstest] +#[case( + "completes_paid_checkout", + EventType::CheckoutSessionCompleted, + CheckoutSessionPaymentStatus::Paid, + "succeeded", + "confirmed", + "paid" +)] +#[case( + "waits_for_delayed_payment", + EventType::CheckoutSessionCompleted, + CheckoutSessionPaymentStatus::Unpaid, + "processing", + "pending", + "pending" +)] +#[case( + "completes_delayed_payment", + EventType::CheckoutSessionAsyncPaymentSucceeded, + CheckoutSessionPaymentStatus::Paid, + "succeeded", + "confirmed", + "paid" +)] +#[case( + "records_delayed_payment_failure", + EventType::CheckoutSessionAsyncPaymentFailed, + CheckoutSessionPaymentStatus::Unpaid, + "failed", + "pending", + "failed" +)] +#[case( + "expires_unpaid_checkout", + EventType::CheckoutSessionExpired, + CheckoutSessionPaymentStatus::Unpaid, + "expired", + "cancelled", + "failed" +)] +#[case( + "acknowledges_unknown_event", + EventType::Unknown, + CheckoutSessionPaymentStatus::Unpaid, + "session_created", + "pending", + "pending" +)] +#[tokio::test] +#[serial] +async fn processes_checkout_webhooks_once( + #[case] test_name: &str, + #[case] event_type: EventType, + #[case] payment_status: CheckoutSessionPaymentStatus, + #[case] expected_attempt_status: &str, + #[case] expected_order_status: &str, + #[case] expected_payment_status: &str, +) { + configure_insta!(); + let ctx = crate::boot_test().await.expect("test context should boot"); + crate::seed_data(ctx.db()) + .await + .expect("seed should complete"); + let order_pid = uuid::Uuid::parse_str("26a12ef5-1bd0-4673-b772-96f4f4f94007") + .expect("order PID should parse"); + let order = Order::find_by_pid(ctx.db(), order_pid) + .await + .expect("order should exist"); + let mut txn = ctx.db().begin().await.expect("transaction should begin"); + let attempt = PaymentAttempt::create_for_order( + &mut txn, + order.row_id(), + order.grand_total(), + order.currency(), + ) + .await + .expect("attempt should be created"); + let session_id = format!("cs_test_{test_name}"); + PaymentAttempt::attach_checkout_session( + &mut txn, + attempt.pid(), + &CheckoutSessionDetails { + session_id: &session_id, + checkout_url: "https://checkout.stripe.test/session", + expires_at: chrono::Utc::now().fixed_offset(), + }, + ) + .await + .expect("session should attach"); + txn.commit().await.expect("transaction should commit"); + + let event = checkout_event( + &format!("evt_{test_name}"), + event_type, + &session_id, + attempt.pid(), + order_pid, + to_minor_units(order.grand_total(), order.currency()).expect("amount should convert"), + payment_status, + ); + let expires_inventory = matches!(event_type, EventType::CheckoutSessionExpired); + let payload = serde_json::to_value(&event).expect("event should serialize"); + let stock_before = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(stock_quantity), 0)::BIGINT FROM product_variants", + ) + .fetch_one(ctx.db()) + .await + .expect("stock total should load"); + process_stripe_event(ctx.db(), &stripe_config(), event.clone(), &payload) + .await + .expect("first delivery should process"); + let stock_after_first = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(stock_quantity), 0)::BIGINT FROM product_variants", + ) + .fetch_one(ctx.db()) + .await + .expect("stock total should load"); + process_stripe_event(ctx.db(), &stripe_config(), event, &payload) + .await + .expect("duplicate delivery should be acknowledged"); + let stock_after_duplicate = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(stock_quantity), 0)::BIGINT FROM product_variants", + ) + .fetch_one(ctx.db()) + .await + .expect("stock total should load"); + assert_eq!(stock_after_first, stock_after_duplicate); + if expires_inventory { + assert!(stock_after_first > stock_before); + } else { + assert_eq!(stock_after_first, stock_before); + } + + let stored_attempt = PaymentAttempt::find_by_pid(ctx.db(), attempt.pid()) + .await + .expect("attempt should exist"); + let lifecycle = sqlx::query_as::<_, (String, String)>( + "SELECT status, payment_status FROM orders WHERE pid = $1", + ) + .bind(order_pid) + .fetch_one(ctx.db()) + .await + .expect("order lifecycle should load"); + assert_eq!(stored_attempt.status(), expected_attempt_status); + assert_eq!(lifecycle.0, expected_order_status); + assert_eq!(lifecycle.1, expected_payment_status); + assert_debug_snapshot!( + test_name, + (stored_attempt.status(), lifecycle.0, lifecycle.1) + ); +} diff --git a/service/tests/requests/mod.rs b/service/tests/requests/mod.rs index 1e0bf0f..6dd2f79 100644 --- a/service/tests/requests/mod.rs +++ b/service/tests/requests/mod.rs @@ -2,6 +2,7 @@ mod auth; mod categories; mod media; mod orders; +mod payments; mod permissions; mod products; mod roles; diff --git a/service/tests/requests/payments.rs b/service/tests/requests/payments.rs new file mode 100644 index 0000000..f264f77 --- /dev/null +++ b/service/tests/requests/payments.rs @@ -0,0 +1,24 @@ +use insta::{Settings, assert_debug_snapshot}; + +macro_rules! configure_insta { + () => { + let mut settings = Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_path("snapshots/payments"); + settings.set_snapshot_suffix("payment_requests"); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +async fn stripe_webhook_requires_a_signature() { + configure_insta!(); + crate::request(|server, _ctx| async move { + let response = server.post("/payments/stripe/webhook").text("{}").await; + assert_debug_snapshot!( + "stripe_webhook_requires_a_signature", + (response.status_code(), response.text()) + ); + }) + .await; +} diff --git a/service/tests/requests/snapshots/payments/stripe_webhook_requires_a_signature@payment_requests.snap b/service/tests/requests/snapshots/payments/stripe_webhook_requires_a_signature@payment_requests.snap new file mode 100644 index 0000000..0870725 --- /dev/null +++ b/service/tests/requests/snapshots/payments/stripe_webhook_requires_a_signature@payment_requests.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/payments.rs +expression: "(response.status_code(), response.text())" +--- +( + 400, + "{\"error\":\"The Stripe webhook signature is missing or invalid.\",\"code\":\"invalid_stripe_signature\"}", +) diff --git a/service/tests/snapshots/payments/acknowledges_unknown_event@payments.snap b/service/tests/snapshots/payments/acknowledges_unknown_event@payments.snap new file mode 100644 index 0000000..097f90e --- /dev/null +++ b/service/tests/snapshots/payments/acknowledges_unknown_event@payments.snap @@ -0,0 +1,9 @@ +--- +source: tests/payments.rs +expression: "(stored_attempt.status(), lifecycle.0, lifecycle.1)" +--- +( + "session_created", + "pending", + "pending", +) diff --git a/service/tests/snapshots/payments/completes_delayed_payment@payments.snap b/service/tests/snapshots/payments/completes_delayed_payment@payments.snap new file mode 100644 index 0000000..c503b53 --- /dev/null +++ b/service/tests/snapshots/payments/completes_delayed_payment@payments.snap @@ -0,0 +1,9 @@ +--- +source: tests/payments.rs +expression: "(stored_attempt.status(), lifecycle.0, lifecycle.1)" +--- +( + "succeeded", + "confirmed", + "paid", +) diff --git a/service/tests/snapshots/payments/completes_paid_checkout@payments.snap b/service/tests/snapshots/payments/completes_paid_checkout@payments.snap new file mode 100644 index 0000000..c503b53 --- /dev/null +++ b/service/tests/snapshots/payments/completes_paid_checkout@payments.snap @@ -0,0 +1,9 @@ +--- +source: tests/payments.rs +expression: "(stored_attempt.status(), lifecycle.0, lifecycle.1)" +--- +( + "succeeded", + "confirmed", + "paid", +) diff --git a/service/tests/snapshots/payments/expires_unpaid_checkout@payments.snap b/service/tests/snapshots/payments/expires_unpaid_checkout@payments.snap new file mode 100644 index 0000000..c61eadb --- /dev/null +++ b/service/tests/snapshots/payments/expires_unpaid_checkout@payments.snap @@ -0,0 +1,9 @@ +--- +source: tests/payments.rs +expression: "(stored_attempt.status(), lifecycle.0, lifecycle.1)" +--- +( + "expired", + "cancelled", + "failed", +) diff --git a/service/tests/snapshots/payments/records_delayed_payment_failure@payments.snap b/service/tests/snapshots/payments/records_delayed_payment_failure@payments.snap new file mode 100644 index 0000000..b94d0b4 --- /dev/null +++ b/service/tests/snapshots/payments/records_delayed_payment_failure@payments.snap @@ -0,0 +1,9 @@ +--- +source: tests/payments.rs +expression: "(stored_attempt.status(), lifecycle.0, lifecycle.1)" +--- +( + "failed", + "pending", + "failed", +) diff --git a/service/tests/snapshots/payments/rejects_invalid_webhook_signatures@payments.snap b/service/tests/snapshots/payments/rejects_invalid_webhook_signatures@payments.snap new file mode 100644 index 0000000..c052bb8 --- /dev/null +++ b/service/tests/snapshots/payments/rejects_invalid_webhook_signatures@payments.snap @@ -0,0 +1,7 @@ +--- +source: tests/payments.rs +expression: result +--- +Err( + InvalidWebhookSignature, +) diff --git a/service/tests/snapshots/payments/waits_for_delayed_payment@payments.snap b/service/tests/snapshots/payments/waits_for_delayed_payment@payments.snap new file mode 100644 index 0000000..6b99993 --- /dev/null +++ b/service/tests/snapshots/payments/waits_for_delayed_payment@payments.snap @@ -0,0 +1,9 @@ +--- +source: tests/payments.rs +expression: "(stored_attempt.status(), lifecycle.0, lifecycle.1)" +--- +( + "processing", + "pending", + "pending", +) From 2b8d90fa9e66676181408e12e98241089aaf32d8 Mon Sep 17 00:00:00 2001 From: Kibet arap Sum Date: Tue, 14 Jul 2026 17:39:05 +0300 Subject: [PATCH 5/9] fix(payments): reserve payment state for verified events Remove paymentStatus from staff order update payloads and reject it as an unknown field at the service boundary. Keep payment state visible for operations while limiting the admin dialog to order, fulfillment, and internal-note updates. Add a request regression test proving even authorized staff cannot mark an order paid through the general update endpoint. --- admin/src/api/orders.ts | 1 - admin/src/components/orders/order-detail.tsx | 35 +++++-------------- service/src/models/orders.rs | 6 ++-- service/src/schemas/orders.rs | 11 +----- service/tests/requests/orders.rs | 22 ++++++++++++ ...ot_set_payment_status_directly@orders.snap | 8 +++++ 6 files changed, 41 insertions(+), 42 deletions(-) create mode 100644 service/tests/requests/snapshots/orders/staff_cannot_set_payment_status_directly@orders.snap diff --git a/admin/src/api/orders.ts b/admin/src/api/orders.ts index f1f5589..606e07a 100644 --- a/admin/src/api/orders.ts +++ b/admin/src/api/orders.ts @@ -68,7 +68,6 @@ export type OrderWithItems = { export type UpdateOrderInput = { status?: string; - paymentStatus?: string; fulfillmentStatus?: string; staffNote?: string; }; diff --git a/admin/src/components/orders/order-detail.tsx b/admin/src/components/orders/order-detail.tsx index c009ec1..7df671c 100644 --- a/admin/src/components/orders/order-detail.tsx +++ b/admin/src/components/orders/order-detail.tsx @@ -31,7 +31,6 @@ import { FULFILLMENT_STATUSES, ORDER_STATUSES, OrderStatusBadge, - PAYMENT_STATUSES, } from "#/components/orders/order-status"; import { EmptyState } from "#/components/empty-state"; import { ErrorState } from "#/components/error-state"; @@ -273,7 +272,6 @@ function UpdateOrderDialog({ order, pid }: { order: OrderDetail; pid: string }) const queryClient = useQueryClient(); const [isOpen, setIsOpen] = useState(false); const [status, setStatus] = useState(order.status); - const [paymentStatus, setPaymentStatus] = useState(order.paymentStatus); const [fulfillmentStatus, setFulfillmentStatus] = useState(order.fulfillmentStatus); const [staffNote, setStaffNote] = useState(order.staffNote ?? ""); const mutation = useMutation({ @@ -293,7 +291,6 @@ function UpdateOrderDialog({ order, pid }: { order: OrderDetail; pid: string }) function resetForm() { setStatus(order.status); - setPaymentStatus(order.paymentStatus); setFulfillmentStatus(order.fulfillmentStatus); setStaffNote(order.staffNote ?? ""); } @@ -307,7 +304,6 @@ function UpdateOrderDialog({ order, pid }: { order: OrderDetail; pid: string }) event.preventDefault(); mutation.mutate({ status, - paymentStatus, fulfillmentStatus, staffNote, }); @@ -344,16 +340,19 @@ function UpdateOrderDialog({ order, pid }: { order: OrderDetail; pid: string })
- +
-
- - -