Skip to main content

max / makenotwork

payments: migrate to async-stripe 1.0.0-rc.5 Webhook payloads under Stripe API version 2026-01-28.clover broke the 0.37 SDK because Subscription.current_period_* moved onto items.data[0] and Invoice.subscription moved into parent.subscription_details. Every checkout.session.completed event since 2026-05-12 failed to deserialize and left transactions stuck in 'pending'. - Outbound calls go through the rc.5 typed builder API (CreateAccount, CreateCheckoutSession, UpdateSubscription, etc.), per-request Stripe-Account header via .customize().account_id(...). Resume uses the new POST /subscriptions/{id}/resume endpoint instead of the legacy pause_collection="" trick. App-sync tier swaps now create a Product first (rc.5 requires an existing product id for price_data). - Inbound webhooks: keep our existing HMAC verify_signature; parse the envelope into a thin UntypedEvent { id, type_, data_object }. The dispatcher consumes data_object once into narrow view structs (CheckoutSessionView, SubscriptionView, InvoiceView, AccountView, ChargeView) that deserialize only the fields handlers read. This is resilient against new required fields Stripe adds — the original bug was caused by exactly that kind of over-strict typed parse. - InvoiceView.subscription_id() handles both the legacy top-level invoice.subscription and the new invoice.parent.subscription_details.subscription paths. - SubscriptionView.current_period() reads from items.data[0]. - checkout_metadata: refactored from_session(&CheckoutSession) into from_metadata(Option<&HashMap<String,String>>) so callers and tests don't depend on constructing a full Stripe struct. - Cargo.toml: enable per-resource features on each rc.5 sub-crate. - scheduler/webhooks: parses stored payloads through UntypedEvent and hands (type, id, data_object) to the dispatcher — no per-event clone. 1478 lib + 28 stripe integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-17 02:56 UTC
Commit: 69da61811c2135b203ac806b066dd5945421f619
Parent: d13df6b
13 files changed, +1007 insertions, -1180 deletions
M server/Cargo.lock +189 -106
@@ -195,7 +195,7 @@ dependencies = [
195 195 "rustc-hash 2.1.1",
196 196 "serde",
197 197 "serde_derive",
198 - "syn 2.0.117",
198 + "syn",
199 199 ]
200 200
201 201 [[package]]
@@ -250,7 +250,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
250 250 dependencies = [
251 251 "proc-macro2",
252 252 "quote",
253 - "syn 2.0.117",
253 + "syn",
254 254 "synstructure",
255 255 ]
256 256
@@ -262,7 +262,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
262 262 dependencies = [
263 263 "proc-macro2",
264 264 "quote",
265 - "syn 2.0.117",
265 + "syn",
266 266 "synstructure",
267 267 ]
268 268
@@ -274,7 +274,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
274 274 dependencies = [
275 275 "proc-macro2",
276 276 "quote",
277 - "syn 2.0.117",
277 + "syn",
278 278 ]
279 279
280 280 [[package]]
@@ -288,17 +288,6 @@ dependencies = [
288 288 ]
289 289
290 290 [[package]]
291 - name = "async-channel"
292 - version = "1.9.0"
293 - source = "registry+https://github.com/rust-lang/crates.io-index"
294 - checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
295 - dependencies = [
296 - "concurrent-queue",
297 - "event-listener 2.5.3",
298 - "futures-core",
299 - ]
300 -
301 - [[package]]
302 291 name = "async-stream"
303 292 version = "0.3.6"
304 293 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -317,32 +306,166 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
317 306 dependencies = [
318 307 "proc-macro2",
319 308 "quote",
320 - "syn 2.0.117",
309 + "syn",
321 310 ]
322 311
323 312 [[package]]
324 313 name = "async-stripe"
325 - version = "0.37.3"
314 + version = "1.0.0-rc.5"
326 315 source = "registry+https://github.com/rust-lang/crates.io-index"
327 - checksum = "e2f14b5943a52cf051bbbbb68538e93a69d1e291934174121e769f4b181113f5"
316 + checksum = "03ec8493f89fb9d9ac1848988955ec192df8e0ebf4d4bbd85fd93df9f681e890"
328 317 dependencies = [
329 - "chrono",
318 + "async-stripe-client-core",
319 + "async-stripe-shared",
320 + "bytes",
321 + "http-body-util",
322 + "hyper 1.8.1",
323 + "hyper-tls",
324 + "hyper-util",
325 + "miniserde",
326 + "thiserror 2.0.18",
327 + "tokio",
328 + "tracing",
329 + ]
330 +
331 + [[package]]
332 + name = "async-stripe-billing"
333 + version = "1.0.0-rc.5"
334 + source = "registry+https://github.com/rust-lang/crates.io-index"
335 + checksum = "e2340b289cd5e34bb9b8b3eebed4e7a9729831ebee01388965ad4d88fd7a5a3b"
336 + dependencies = [
337 + "async-stripe-client-core",
338 + "async-stripe-shared",
339 + "async-stripe-types",
340 + "miniserde",
341 + "serde",
342 + "serde_json",
343 + "smol_str",
344 + "tracing",
345 + ]
346 +
347 + [[package]]
348 + name = "async-stripe-checkout"
349 + version = "1.0.0-rc.5"
350 + source = "registry+https://github.com/rust-lang/crates.io-index"
351 + checksum = "d23c5f0fbbc4fef893e1c673438687e0b4e7970171fbf9fd011911643200e1b3"
352 + dependencies = [
353 + "async-stripe-client-core",
354 + "async-stripe-shared",
355 + "async-stripe-types",
356 + "miniserde",
357 + "serde",
358 + "serde_json",
359 + "smol_str",
360 + "tracing",
361 + ]
362 +
363 + [[package]]
364 + name = "async-stripe-client-core"
365 + version = "1.0.0-rc.5"
366 + source = "registry+https://github.com/rust-lang/crates.io-index"
367 + checksum = "10f095837711eb1c3ee02604b4e1d44b117014fb74da99ad4f2d70e907dbdc41"
368 + dependencies = [
369 + "async-stripe-shared",
370 + "async-stripe-types",
371 + "bytes",
330 372 "futures-util",
331 - "hex",
332 - "hmac 0.12.1",
333 - "http-types",
334 - "hyper 0.14.32",
335 - "hyper-tls 0.5.0",
373 + "miniserde",
374 + "serde",
375 + "serde_json",
376 + "serde_qs",
377 + "thiserror 2.0.18",
378 + "tracing",
379 + ]
380 +
381 + [[package]]
382 + name = "async-stripe-connect"
383 + version = "1.0.0-rc.5"
384 + source = "registry+https://github.com/rust-lang/crates.io-index"
385 + checksum = "12156e6b2118316feb9c51edef29a2705a5a653a60da03ff592936dd5dbbb0b2"
386 + dependencies = [
387 + "async-stripe-client-core",
388 + "async-stripe-shared",
389 + "async-stripe-types",
390 + "miniserde",
391 + "serde",
392 + "serde_json",
393 + "smol_str",
394 + "tracing",
395 + ]
396 +
397 + [[package]]
398 + name = "async-stripe-core"
399 + version = "1.0.0-rc.5"
400 + source = "registry+https://github.com/rust-lang/crates.io-index"
401 + checksum = "ee9710c3d64db48dda1ec0b4976d091b7f40cabc8ed7bcab9e93b608b70b405c"
402 + dependencies = [
403 + "async-stripe-client-core",
404 + "async-stripe-shared",
405 + "async-stripe-types",
406 + "miniserde",
407 + "serde",
408 + "serde_json",
409 + "smol_str",
410 + "tracing",
411 + ]
412 +
413 + [[package]]
414 + name = "async-stripe-payment"
415 + version = "1.0.0-rc.5"
416 + source = "registry+https://github.com/rust-lang/crates.io-index"
417 + checksum = "f57b37183dd9fcb700af54acc49e0dbd738b3f4596b0feb486c5d2dab0d22bac"
418 + dependencies = [
419 + "async-stripe-client-core",
420 + "async-stripe-shared",
421 + "async-stripe-types",
422 + "miniserde",
423 + "serde",
424 + "serde_json",
425 + "smol_str",
426 + "tracing",
427 + ]
428 +
429 + [[package]]
430 + name = "async-stripe-product"
431 + version = "1.0.0-rc.5"
432 + source = "registry+https://github.com/rust-lang/crates.io-index"
433 + checksum = "ec3ca828f2568de98ac380c851519e7f1c4ef41beee801d3e8f5744ee93c8c53"
434 + dependencies = [
435 + "async-stripe-client-core",
436 + "async-stripe-shared",
437 + "async-stripe-types",
438 + "miniserde",
439 + "serde",
440 + "serde_json",
441 + "smol_str",
442 + "tracing",
443 + ]
444 +
445 + [[package]]
446 + name = "async-stripe-shared"
447 + version = "1.0.0-rc.5"
448 + source = "registry+https://github.com/rust-lang/crates.io-index"
449 + checksum = "a352c5e36a92aa8bdd4326c8211b9a26e822e6cb4b7516a47282396a4938a231"
450 + dependencies = [
451 + "async-stripe-types",
452 + "miniserde",
453 + "serde",
454 + "serde_json",
455 + "smol_str",
456 + "tracing",
457 + ]
458 +
459 + [[package]]
460 + name = "async-stripe-types"
461 + version = "1.0.0-rc.5"
462 + source = "registry+https://github.com/rust-lang/crates.io-index"
463 + checksum = "8e1ec9960b89b1f556bf885403dd208d173a8c42d028fcd7baeca9ad2bfb13f6"
464 + dependencies = [
465 + "miniserde",
336 466 "serde",
337 467 "serde_json",
338 - "serde_path_to_error",
339 - "serde_qs 0.10.1",
340 - "sha2 0.10.9",
341 - "smart-default",
342 468 "smol_str",
343 - "thiserror 1.0.69",
344 - "tokio",
345 - "uuid 0.8.2",
346 469 ]
347 470
348 471 [[package]]
@@ -353,7 +476,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
353 476 dependencies = [
354 477 "proc-macro2",
355 478 "quote",
356 - "syn 2.0.117",
479 + "syn",
357 480 ]
358 481
359 482 [[package]]
@@ -396,7 +519,7 @@ dependencies = [
396 519 "aws-smithy-types",
397 520 "aws-types",
398 521 "bytes",
399 - "fastrand 2.3.0",
522 + "fastrand",
400 523 "hex",
401 524 "http 1.4.0",
402 525 "sha1 0.10.6",
@@ -458,7 +581,7 @@ dependencies = [
458 581 "aws-types",
459 582 "bytes",
460 583 "bytes-utils",
461 - "fastrand 2.3.0",
584 + "fastrand",
462 585 "http 0.2.12",
463 586 "http 1.4.0",
464 587 "http-body 0.4.6",
@@ -466,7 +589,7 @@ dependencies = [
466 589 "percent-encoding",
467 590 "pin-project-lite",
468 591 "tracing",
469 - "uuid 1.22.0",
592 + "uuid",
470 593 ]
471 594
472 595 [[package]]
@@ -490,7 +613,7 @@ dependencies = [
490 613 "aws-smithy-xml",
491 614 "aws-types",
492 615 "bytes",
493 - "fastrand 2.3.0",
616 + "fastrand",
494 617 "hex",
495 618 "hmac 0.13.0",
496 619 "http 0.2.12",
@@ -521,7 +644,7 @@ dependencies = [
521 644 "aws-smithy-types",
522 645 "aws-types",
523 646 "bytes",
524 - "fastrand 2.3.0",
647 + "fastrand",
525 648 "http 0.2.12",
526 649 "http 1.4.0",
527 650 "regex-lite",
@@ -545,7 +668,7 @@ dependencies = [
545 668 "aws-smithy-types",
546 669 "aws-types",
547 670 "bytes",
548 - "fastrand 2.3.0",
671 + "fastrand",
549 672 "http 0.2.12",
550 673 "http 1.4.0",
551 674 "regex-lite",
@@ -570,7 +693,7 @@ dependencies = [
570 693 "aws-smithy-types",
571 694 "aws-smithy-xml",
572 695 "aws-types",
573 - "fastrand 2.3.0",
696 + "fastrand",
574 697 "http 0.2.12",
575 698 "http 1.4.0",
576 699 "regex-lite",
@@ -741,7 +864,7 @@ dependencies = [
741 864 "aws-smithy-runtime-api",
742 865 "aws-smithy-types",
743 866 "bytes",
744 - "fastrand 2.3.0",
867 + "fastrand",
745 868 "http 0.2.12",
746 869 "http 1.4.0",
747 870 "http-body 0.4.6",
@@ -779,7 +902,7 @@ checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7"
779 902 dependencies = [
780 903 "proc-macro2",
781 904 "quote",
782 - "syn 2.0.117",
905 + "syn",
783 906 ]
784 907
785 908 [[package]]
@@ -919,7 +1042,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
919 1042 dependencies = [
920 1043 "proc-macro2",
921 1044 "quote",
922 - "syn 2.0.117",
1045 + "syn",
923 1046 ]
924 1047
925 1048 [[package]]
@@ -942,12 +1065,6 @@ checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
942 1065
943 1066 [[package]]
944 1067 name = "base64"
945 - version = "0.13.1"
946 - source = "registry+https://github.com/rust-lang/crates.io-index"
947 - checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
948 -
949 - [[package]]
950 - name = "base64"
951 1068 version = "0.21.7"
952 1069 source = "registry+https://github.com/rust-lang/crates.io-index"
953 1070 checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
@@ -1099,6 +1216,16 @@ dependencies = [
1099 1216 ]
1100 1217
1101 1218 [[package]]
1219 + name = "borsh"
1220 + version = "1.6.1"
1221 + source = "registry+https://github.com/rust-lang/crates.io-index"
1222 + checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
1223 + dependencies = [
1224 + "bytes",
1225 + "cfg_aliases",
1226 + ]
1227 +
1228 + [[package]]
1102 1229 name = "bstr"
1103 1230 version = "1.12.1"
1104 1231 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1181,7 +1308,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
1181 1308 dependencies = [
1182 1309 "byteorder",
1183 1310 "fnv",
1184 - "uuid 1.22.0",
1311 + "uuid",
1185 1312 ]
1186 1313
1187 1314 [[package]]
@@ -1191,6 +1318,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1191 1318 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
1192 1319
1193 1320 [[package]]
1321 + name = "cfg_aliases"
1322 + version = "0.2.1"
1323 + source = "registry+https://github.com/rust-lang/crates.io-index"
1324 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
1325 +
1326 + [[package]]
1194 1327 name = "chrono"
1195 1328 version = "0.4.44"
1196 1329 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1245,7 +1378,7 @@ dependencies = [
1245 1378 "heck",
1246 1379 "proc-macro2",
1247 1380 "quote",
1248 - "syn 2.0.117",
1381 + "syn",
1249 1382 ]
1250 1383
1251 1384 [[package]]
@@ -1677,7 +1810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1677 1810 checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
1678 1811 dependencies = [
1679 1812 "quote",
1680 - "syn 2.0.117",
1813 + "syn",
1681 1814 ]
1682 1815
1683 1816 [[package]]
@@ -1737,7 +1870,7 @@ dependencies = [
1737 1870 "proc-macro2",
1738 1871 "quote",
1739 1872 "strsim",
1740 - "syn 2.0.117",
1873 + "syn",
1741 1874 ]
1742 1875
1743 1876 [[package]]
@@ -1748,7 +1881,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
1748 1881 dependencies = [
1749 1882 "darling_core",
1750 1883 "quote",
1751 - "syn 2.0.117",
1884 + "syn",
1752 1885 ]
1753 1886
1754 1887 [[package]]
@@ -1886,7 +2019,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
1886 2019 dependencies = [
1887 2020 "proc-macro2",
1888 2021 "quote",
1889 - "syn 2.0.117",
2022 + "syn",
1890 2023 ]
1891 2024
1892 2025 [[package]]
@@ -2089,12 +2222,6 @@ dependencies = [
2089 2222
2090 2223 [[package]]
2091 2224 name = "event-listener"
2092 - version = "2.5.3"
2093 - source = "registry+https://github.com/rust-lang/crates.io-index"
2094 - checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
2095 -
2096 - [[package]]
2097 - name = "event-listener"
2098 2225 version = "5.4.1"
2099 2226 source = "registry+https://github.com/rust-lang/crates.io-index"
2100 2227 checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
@@ -2123,15 +2250,6 @@ dependencies = [
2123 2250
2124 2251 [[package]]
2125 2252 name = "fastrand"
2126 - version = "1.9.0"
2127 - source = "registry+https://github.com/rust-lang/crates.io-index"
2128 - checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
2129 - dependencies = [
2130 - "instant",
2131 - ]
2132 -
2133 - [[package]]
2134 - name = "fastrand"
2135 2253 version = "2.3.0"
2136 2254 source = "registry+https://github.com/rust-lang/crates.io-index"
2137 2255 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
@@ -2327,21 +2445,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2327 2445 checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
2328 2446
2329 2447 [[package]]
2330 - name = "futures-lite"
2331 - version = "1.13.0"
2332 - source = "registry+https://github.com/rust-lang/crates.io-index"
2333 - checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
2334 - dependencies = [
2335 - "fastrand 1.9.0",
2336 - "futures-core",
2337 - "futures-io",
2338 - "memchr",
2339 - "parking",
2340 - "pin-project-lite",
2341 - "waker-fn",
2342 - ]
2343 -
2344 - [[package]]
2345 2448 name = "futures-macro"
2346 2449 version = "0.3.32"
2347 2450 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2349,7 +2452,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
2349 2452 dependencies = [
2350 2453 "proc-macro2",
2351 2454 "quote",
2352 - "syn 2.0.117",
2455 + "syn",
2353 2456 ]
2354 2457
2355 2458 [[package]]
@@ -2409,17 +2512,6 @@ dependencies = [
2409 2512
2410 2513 [[package]]
2411 2514 name = "getrandom"
2412 - version = "0.1.16"
2413 - source = "registry+https://github.com/rust-lang/crates.io-index"
2414 - checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
2415 - dependencies = [
2416 - "cfg-if",
2417 - "libc",
2418 - "wasi 0.9.0+wasi-snapshot-preview1",
2419 - ]
2420 -
2421 - [[package]]
2422 - name = "getrandom"
2423 2515 version = "0.2.17"
2424 2516 source = "registry+https://github.com/rust-lang/crates.io-index"
2425 2517 checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
@@ -2427,7 +2519,7 @@ dependencies = [
2427 2519 "cfg-if",
2428 2520 "js-sys",
2429 2521 "libc",
2430 - "wasi 0.11.1+wasi-snapshot-preview1",
2522 + "wasi",
2431 2523 "wasm-bindgen",
2432 2524 ]
2433 2525
@@ -2823,27 +2915,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2823 2915 checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
2824 2916
2825 2917 [[package]]
2826 - name = "http-types"
2827 - version = "2.12.0"
2828 - source = "registry+https://github.com/rust-lang/crates.io-index"
2829 - checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad"
2830 - dependencies = [
2831 - "anyhow",
2832 - "async-channel",
2833 - "base64 0.13.1",
2834 - "futures-lite",
Lines truncated
@@ -123,12 +123,12 @@ s3-storage = { path = "../shared/s3-storage" }
123 123 # rc.5; see `payments::webhooks::verify_signature` for our HMAC check).
124 124 async-stripe = { version = "1.0.0-rc.5", features = ["default-tls"] }
125 125 async-stripe-shared = { version = "1.0.0-rc.5", features = ["deserialize"] }
126 - async-stripe-billing = { version = "1.0.0-rc.5", features = ["deserialize"] }
127 - async-stripe-checkout = { version = "1.0.0-rc.5", features = ["deserialize"] }
128 - async-stripe-connect = { version = "1.0.0-rc.5", features = ["deserialize"] }
129 - async-stripe-core = { version = "1.0.0-rc.5", features = ["deserialize"] }
126 + async-stripe-billing = { version = "1.0.0-rc.5", features = ["deserialize", "subscription", "billing_portal_session"] }
127 + async-stripe-checkout = { version = "1.0.0-rc.5", features = ["deserialize", "checkout_session"] }
128 + async-stripe-connect = { version = "1.0.0-rc.5", features = ["deserialize", "account", "account_link"] }
129 + async-stripe-core = { version = "1.0.0-rc.5", features = ["deserialize", "balance", "refund"] }
130 130 async-stripe-payment = { version = "1.0.0-rc.5", features = ["deserialize"] }
131 - async-stripe-product = { version = "1.0.0-rc.5", features = ["deserialize"] }
131 + async-stripe-product = { version = "1.0.0-rc.5", features = ["deserialize", "product", "price"] }
132 132 async-stripe-types = { version = "1.0.0-rc.5", features = ["deserialize"] }
133 133 reqwest = { version = "0.12", features = ["json", "cookies"] }
134 134 urlencoding = "2.1.3"
@@ -1,11 +1,20 @@
1 - //! Checkout session creation and metadata extraction.
2 -
3 - use stripe::{
4 - CheckoutSession, CheckoutSessionMode,
5 - CreateCheckoutSession,
6 - CreateCheckoutSessionLineItems, CreateCheckoutSessionLineItemsPriceData,
7 - CreateCheckoutSessionLineItemsPriceDataProductData, Currency,
1 + //! Checkout session creation.
2 + //!
3 + //! Direct Charges pattern: payment goes directly to the connected account.
4 + //! No `application_fee_amount` is set — the 0% platform fee promise.
5 +
6 + use std::collections::HashMap;
7 +
8 + use stripe::StripeRequest;
9 + use stripe_checkout::checkout_session::{
10 + CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems,
11 + CreateCheckoutSessionLineItemsPriceData, CreateCheckoutSessionLineItemsPriceDataRecurring,
12 + CreateCheckoutSessionLineItemsPriceDataRecurringInterval,
13 + CreateCheckoutSessionSubscriptionData, ProductData,
8 14 };
15 + use stripe_shared::CheckoutSessionMode;
16 + use stripe_types::Currency;
17 +
9 18 use crate::constants;
10 19 use crate::db::{Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId};
11 20 use crate::error::{AppError, Result};
@@ -83,119 +92,130 @@ pub struct GuestCheckoutParams<'a> {
83 92 pub enable_stripe_tax: bool,
84 93 }
85 94
95 + /// Parameters for an app sync subscription Checkout Session (inline pricing).
96 + pub struct AppSyncCheckoutParams<'a> {
97 + pub product_name: &'a str,
98 + pub price_cents: i64,
99 + /// "month" or "year"
100 + pub interval: &'a str,
101 + pub user_id: UserId,
102 + pub app_id: SyncAppId,
103 + pub app_name: &'a str,
104 + pub tier: &'a str,
105 + pub success_url: &'a str,
106 + pub cancel_url: &'a str,
107 + }
108 +
109 + fn check_min_charge(amount_cents: i64) -> Result<()> {
110 + if amount_cents > 0 && amount_cents < constants::STRIPE_MINIMUM_CHARGE_CENTS {
111 + return Err(AppError::BadRequest(format!(
112 + "Minimum purchase amount is ${:.2}",
113 + constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0
114 + )));
115 + }
116 + Ok(())
117 + }
118 +
119 + fn build_inline_line_item(title: &str, amount_cents: i64) -> CreateCheckoutSessionLineItems {
120 + CreateCheckoutSessionLineItems {
121 + price_data: Some(CreateCheckoutSessionLineItemsPriceData {
122 + currency: Currency::USD,
123 + product_data: Some(ProductData::new(title.to_string())),
124 + unit_amount: Some(amount_cents),
125 + ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD)
126 + }),
127 + quantity: Some(1),
128 + ..CreateCheckoutSessionLineItems::new()
129 + }
130 + }
131 +
132 + fn build_price_line_item(price_id: &str) -> CreateCheckoutSessionLineItems {
133 + CreateCheckoutSessionLineItems {
134 + price: Some(price_id.to_string()),
135 + quantity: Some(1),
136 + ..CreateCheckoutSessionLineItems::new()
137 + }
138 + }
139 +
140 + fn automatic_tax(enable: bool) -> Option<CreateCheckoutSessionAutomaticTax> {
141 + if enable {
142 + Some(CreateCheckoutSessionAutomaticTax::new(true))
143 + } else {
144 + None
145 + }
146 + }
147 +
86 148 impl StripeClient {
87 - /// Create a guest Checkout Session (no MNW account required).
88 - ///
89 - /// Uses Direct Charges pattern. Stripe collects the buyer's email.
90 - /// The `checkout_type: "guest"` metadata tells the webhook handler
91 - /// to use the guest completion flow.
149 + async fn send_on_connected_account(
150 + &self,
151 + builder: CreateCheckoutSession,
152 + connected_account_id: &str,
153 + log_label: &str,
154 + ) -> Result<stripe_shared::CheckoutSession> {
155 + let account_id = Self::parse_account_id(connected_account_id)?;
156 + builder
157 + .customize()
158 + .account_id(account_id)
159 + .send(&self.client)
160 + .await
161 + .map_err(|e| {
162 + tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
163 + AppError::BadRequest("Failed to create checkout session".to_string())
164 + })
165 + }
166 +
167 + async fn send_on_platform(
168 + &self,
169 + builder: CreateCheckoutSession,
170 + log_label: &str,
171 + ) -> Result<stripe_shared::CheckoutSession> {
172 + builder
173 + .send(&self.client)
174 + .await
175 + .map_err(|e| {
176 + tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
177 + AppError::BadRequest("Failed to create checkout session".to_string())
178 + })
179 + }
180 +
181 + /// Build a one-time payment checkout session for a guest purchase.
92 182 #[tracing::instrument(skip_all, name = "payments::create_guest_checkout_session")]
93 183 pub async fn create_guest_checkout_session(
94 184 &self,
95 185 checkout: &GuestCheckoutParams<'_>,
96 - ) -> Result<CheckoutSession> {
97 - if checkout.amount_cents.as_i64() > 0 && checkout.amount_cents.as_i64() < constants::STRIPE_MINIMUM_CHARGE_CENTS {
98 - return Err(AppError::BadRequest(format!(
99 - "Minimum purchase amount is ${:.2}",
100 - constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0
101 - )));
102 - }
103 -
104 - let mut params = CreateCheckoutSession::new();
105 - params.mode = Some(CheckoutSessionMode::Payment);
106 - params.success_url = Some(checkout.success_url);
107 - params.cancel_url = Some(checkout.cancel_url);
186 + ) -> Result<stripe_shared::CheckoutSession> {
187 + check_min_charge(checkout.amount_cents.as_i64())?;
108 188
109 - // Line items
110 - let line_item = CreateCheckoutSessionLineItems {
111 - price_data: Some(CreateCheckoutSessionLineItemsPriceData {
112 - currency: Currency::USD,
113 - product_data: Some(CreateCheckoutSessionLineItemsPriceDataProductData {
114 - name: checkout.item_title.to_string(),
115 - ..Default::default()
116 - }),
117 - unit_amount: Some(checkout.amount_cents.as_i64()),
118 - ..Default::default()
119 - }),
120 - quantity: Some(1),
121 - ..Default::default()
122 - };
123 - params.line_items = Some(vec![line_item]);
124 -
125 - // Metadata for webhook processing
126 - let mut metadata = std::collections::HashMap::new();
189 + let mut metadata = HashMap::new();
127 190 metadata.insert("checkout_type".to_string(), CheckoutType::Guest.to_string());
128 191 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
129 192 metadata.insert("item_id".to_string(), checkout.item_id.to_string());
130 193 if let Some(pc_id) = checkout.promo_code_id {
131 194 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
132 195 }
133 - params.metadata = Some(metadata);
134 196
135 - if checkout.enable_stripe_tax {
136 - params.automatic_tax = Some(stripe::CreateCheckoutSessionAutomaticTax {
137 - enabled: true,
138 - liability: None,
139 - });
197 + let mut builder = CreateCheckoutSession::new()
198 + .mode(CheckoutSessionMode::Payment)
199 + .success_url(checkout.success_url.to_string())
200 + .cancel_url(checkout.cancel_url.to_string())
201 + .line_items(vec![build_inline_line_item(checkout.item_title, checkout.amount_cents.as_i64())])
202 + .metadata(metadata);
203 + if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
204 + builder = builder.automatic_tax(tax);
140 205 }
141 206
142 - // Direct Charges on the connected account (0% platform fee)
143 - let session = CheckoutSession::create(
144 - &self.client.clone().with_stripe_account(
145 - checkout.connected_account_id.parse().map_err(|_| {
146 - AppError::BadRequest("Invalid Stripe account ID format".to_string())
147 - })?,
148 - ),
149 - params,
150 - )
151 - .await
152 - .map_err(|e| {
153 - tracing::error!(error = ?e, "failed to create guest checkout session");
154 - AppError::BadRequest("Failed to create checkout session".to_string())
155 - })?;
156 -
157 - Ok(session)
207 + self.send_on_connected_account(builder, checkout.connected_account_id, "guest_checkout").await
158 208 }
159 209
160 - /// Create a Checkout Session for purchasing an item
161 - ///
162 - /// Uses Direct Charges pattern: the payment goes directly to the creator's
163 - /// connected account. No application_fee_amount means 0% platform fee.
210 + /// Build a one-time payment checkout session for a purchase by a logged-in user.
164 211 #[tracing::instrument(skip_all, name = "payments::create_checkout_session")]
165 212 pub async fn create_checkout_session(
166 213 &self,
167 214 checkout: &CheckoutParams<'_>,
168 - ) -> Result<CheckoutSession> {
169 - if checkout.amount_cents.as_i64() > 0 && checkout.amount_cents.as_i64() < constants::STRIPE_MINIMUM_CHARGE_CENTS {
170 - return Err(AppError::BadRequest(format!(
171 - "Minimum purchase amount is ${:.2}",
172 - constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0
173 - )));
174 - }
175 -
176 - let mut params = CreateCheckoutSession::new();
177 - params.mode = Some(CheckoutSessionMode::Payment);
178 - params.success_url = Some(checkout.success_url);
179 - params.cancel_url = Some(checkout.cancel_url);
180 -
181 - // Line items
182 - let line_item = CreateCheckoutSessionLineItems {
183 - price_data: Some(CreateCheckoutSessionLineItemsPriceData {
184 - currency: Currency::USD,
185 - product_data: Some(CreateCheckoutSessionLineItemsPriceDataProductData {
186 - name: checkout.item_title.to_string(),
187 - ..Default::default()
188 - }),
189 - unit_amount: Some(checkout.amount_cents.as_i64()),
190 - ..Default::default()
191 - }),
192 - quantity: Some(1),
193 - ..Default::default()
194 - };
195 - params.line_items = Some(vec![line_item]);
215 + ) -> Result<stripe_shared::CheckoutSession> {
216 + check_min_charge(checkout.amount_cents.as_i64())?;
196 217
197 - // Store metadata for webhook processing
198 - let mut metadata = std::collections::HashMap::new();
218 + let mut metadata = HashMap::new();
199 219 metadata.insert("buyer_id".to_string(), checkout.buyer_id.to_string());
200 220 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
201 221 if let Some(item_id) = checkout.item_id {
@@ -204,137 +224,60 @@ impl StripeClient {
204 224 if let Some(pc_id) = checkout.promo_code_id {
205 225 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
206 226 }
207 - params.metadata = Some(metadata);
208 227
209 - if checkout.enable_stripe_tax {
210 - params.automatic_tax = Some(stripe::CreateCheckoutSessionAutomaticTax {
211 - enabled: true,
212 - liability: None,
213 - });
228 + let mut builder = CreateCheckoutSession::new()
229 + .mode(CheckoutSessionMode::Payment)
230 + .success_url(checkout.success_url.to_string())
231 + .cancel_url(checkout.cancel_url.to_string())
232 + .line_items(vec![build_inline_line_item(checkout.item_title, checkout.amount_cents.as_i64())])
233 + .metadata(metadata);
234 + if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
235 + builder = builder.automatic_tax(tax);
214 236 }
215 237
216 - // Direct Charges: the checkout session is created on the connected
217 - // account (the creator's Stripe), so all funds go directly to them.
218 - // We intentionally omit `application_fee_amount` — this is the core
219 - // 0% platform fee promise. Stripe's own processing fee (~3%) is the
220 - // only deduction the creator sees.
221 - let session = CheckoutSession::create(
222 - &self.client.clone().with_stripe_account(
223 - checkout.connected_account_id.parse().map_err(|_| {
224 - AppError::BadRequest("Invalid Stripe account ID format".to_string())
225 - })?,
226 - ),
227 - params,
228 - )
229 - .await
230 - .map_err(|e| {
231 - tracing::error!(error = ?e, "failed to create checkout session");
232 - AppError::BadRequest("Failed to create checkout session".to_string())
233 - })?;
234 -
235 - Ok(session)
238 + self.send_on_connected_account(builder, checkout.connected_account_id, "checkout").await
236 239 }
237 240
238 - /// Create a multi-line-item Checkout Session for a cart purchase.
239 - ///
240 - /// Groups multiple items from the same seller into one session, saving
241 - /// the $0.30 flat Stripe fee per additional item. Uses Direct Charges.
241 + /// Build a multi-line-item Checkout Session for a cart purchase.
242 242 #[tracing::instrument(skip_all, name = "payments::create_cart_checkout_session")]
243 243 pub async fn create_cart_checkout_session(
244 244 &self,
245 245 cart: &CartCheckoutParams<'_>,
246 - ) -> Result<CheckoutSession> {
246 + ) -> Result<stripe_shared::CheckoutSession> {
247 247 let total_cents: i64 = cart.line_items.iter().map(|li| li.amount_cents).sum();
248 - if total_cents > 0 && total_cents < constants::STRIPE_MINIMUM_CHARGE_CENTS {
249 - return Err(AppError::BadRequest(format!(
250 - "Minimum purchase amount is ${:.2}",
251 - constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0
252 - )));
253 - }
254 -
255 - let mut params = CreateCheckoutSession::new();
256 - params.mode = Some(CheckoutSessionMode::Payment);
257 - params.success_url = Some(cart.success_url);
258 - params.cancel_url = Some(cart.cancel_url);
248 + check_min_charge(total_cents)?;
259 249
260 250 let line_items: Vec<CreateCheckoutSessionLineItems> = cart
261 251 .line_items
262 252 .iter()
263 - .map(|li| CreateCheckoutSessionLineItems {
264 - price_data: Some(CreateCheckoutSessionLineItemsPriceData {
265 - currency: Currency::USD,
266 - product_data: Some(CreateCheckoutSessionLineItemsPriceDataProductData {
267 - name: li.title.to_string(),
268 - ..Default::default()
269 - }),
270 - unit_amount: Some(li.amount_cents),
271 - ..Default::default()
272 - }),
273 - quantity: Some(1),
274 - ..Default::default()
275 - })
253 + .map(|li| build_inline_line_item(li.title, li.amount_cents))
276 254 .collect();
277 - params.line_items = Some(line_items);
278 255
279 - let mut metadata = std::collections::HashMap::new();
256 + let mut metadata = HashMap::new();
280 257 metadata.insert("checkout_type".to_string(), CheckoutType::Cart.to_string());
281 258 metadata.insert("buyer_id".to_string(), cart.buyer_id.to_string());
282 259 metadata.insert("seller_id".to_string(), cart.seller_id.to_string());
283 - params.metadata = Some(metadata);
284 260
285 - if cart.enable_stripe_tax {
286 - params.automatic_tax = Some(stripe::CreateCheckoutSessionAutomaticTax {
287 - enabled: true,
288 - liability: None,
289 - });
261 + let mut builder = CreateCheckoutSession::new()
262 + .mode(CheckoutSessionMode::Payment)
263 + .success_url(cart.success_url.to_string())
264 + .cancel_url(cart.cancel_url.to_string())
265 + .line_items(line_items)
266 + .metadata(metadata);
267 + if let Some(tax) = automatic_tax(cart.enable_stripe_tax) {
268 + builder = builder.automatic_tax(tax);
290 269 }
291 270
292 - let session = CheckoutSession::create(
293 - &self.client.clone().with_stripe_account(
294 - cart.connected_account_id.parse().map_err(|_| {
295 - AppError::BadRequest("Invalid Stripe account ID format".to_string())
296 - })?,
297 - ),
298 - params,
299 - )
300 - .await
301 - .map_err(|e| {
302 - tracing::error!(error = ?e, "failed to create cart checkout session");
303 - AppError::BadRequest("Failed to create checkout session".to_string())
304 - })?;
305 -
306 - Ok(session)
271 + self.send_on_connected_account(builder, cart.connected_account_id, "cart_checkout").await
307 272 }
308 273
309 - /// Create a Checkout Session in subscription mode on a connected account.
310 - ///
311 - /// Uses Direct Charges pattern consistent with one-time purchases.
274 + /// Build a subscription Checkout Session on a connected account.
312 275 #[tracing::instrument(skip_all, name = "payments::create_subscription_checkout_session")]
313 276 pub async fn create_subscription_checkout_session(
314 277 &self,
315 278 sub: &SubscriptionCheckoutParams<'_>,
316 - ) -> Result<CheckoutSession> {
317 - let connected_client = self.client.clone().with_stripe_account(
318 - sub.connected_account_id.parse().map_err(|_| {
319 - AppError::BadRequest("Invalid Stripe account ID format".to_string())
320 - })?,
321 - );
322 -
323 - let mut params = CreateCheckoutSession::new();
324 - params.mode = Some(CheckoutSessionMode::Subscription);
325 - params.success_url = Some(sub.success_url);
326 - params.cancel_url = Some(sub.cancel_url);
327 -
328 - // Reference the existing Price on the connected account
329 - let line_item = CreateCheckoutSessionLineItems {
330 - price: Some(sub.stripe_price_id.to_string()),
331 - quantity: Some(1),
332 - ..Default::default()
333 - };
334 - params.line_items = Some(vec![line_item]);
335 -
336 - // Store metadata for webhook processing
337 - let mut metadata = std::collections::HashMap::new();
279 + ) -> Result<stripe_shared::CheckoutSession> {
280 + let mut metadata = HashMap::new();
338 281 metadata.insert("subscriber_id".to_string(), sub.subscriber_id.to_string());
339 282 metadata.insert("project_id".to_string(), sub.project_id.to_string());
340 283 metadata.insert("tier_id".to_string(), sub.tier_id.to_string());
@@ -342,67 +285,39 @@ impl StripeClient {
342 285 if let Some(pc_id) = sub.promo_code_id {
343 286 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
344 287 }
345 - params.metadata = Some(metadata);
346 288
347 - if sub.enable_stripe_tax {
348 - params.automatic_tax = Some(stripe::CreateCheckoutSessionAutomaticTax {
349 - enabled: true,
350 - liability: None,
351 - });
289 + let mut builder = CreateCheckoutSession::new()
290 + .mode(CheckoutSessionMode::Subscription)
291 + .success_url(sub.success_url.to_string())
292 + .cancel_url(sub.cancel_url.to_string())
293 + .line_items(vec![build_price_line_item(sub.stripe_price_id)])
294 + .metadata(metadata);
295 + if let Some(tax) = automatic_tax(sub.enable_stripe_tax) {
296 + builder = builder.automatic_tax(tax);
352 297 }
353 298
354 - // Apply free trial period if specified
355 299 if let Some(days) = sub.trial_days {
356 300 let trial_days: u32 = days.try_into().map_err(|_| {
357 301 AppError::BadRequest("Invalid trial period".to_string())
358 302 })?;
359 - params.subscription_data = Some(stripe::CreateCheckoutSessionSubscriptionData {
303 + builder = builder.subscription_data(CreateCheckoutSessionSubscriptionData {
360 304 trial_period_days: Some(trial_days),
361 - ..Default::default()
305 + ..CreateCheckoutSessionSubscriptionData::new()
362 306 });
363 307 }
364 308
365 - let session = CheckoutSession::create(&connected_client, params)
366 - .await
367 - .map_err(|e| {
368 - tracing::error!(error = ?e, "failed to create subscription checkout session");
369 - AppError::BadRequest("Failed to create subscription checkout".to_string())
370 - })?;
371 -
372 - Ok(session)
309 + self.send_on_connected_account(builder, sub.connected_account_id, "subscription_checkout").await
373 310 }
374 311
375 - /// Create a Checkout Session for a tip to a creator.
376 - ///
377 - /// Uses Direct Charges pattern: the payment goes directly to the creator's
378 - /// connected account. No application_fee_amount means 0% platform fee.
312 + /// Build a Checkout Session for a tip to a creator.
379 313 #[tracing::instrument(skip_all, name = "payments::create_tip_checkout_session")]
380 314 pub async fn create_tip_checkout_session(
381 315 &self,
382 316 tip: &TipCheckoutParams<'_>,
383 - ) -> Result<CheckoutSession> {
384 - let mut params = CreateCheckoutSession::new();
385 - params.mode = Some(CheckoutSessionMode::Payment);
386 - params.success_url = Some(tip.success_url);
387 - params.cancel_url = Some(tip.cancel_url);
388 -
317 + ) -> Result<stripe_shared::CheckoutSession> {
389 318 let product_name = format!("Tip for {}", tip.recipient_display_name);
390 - let line_item = CreateCheckoutSessionLineItems {
391 - price_data: Some(CreateCheckoutSessionLineItemsPriceData {
392 - currency: Currency::USD,
393 - product_data: Some(CreateCheckoutSessionLineItemsPriceDataProductData {
394 - name: product_name,
395 - ..Default::default()
396 - }),
397 - unit_amount: Some(tip.amount_cents.as_i64()),
398 - ..Default::default()
399 - }),
400 - quantity: Some(1),
401 - ..Default::default()
402 - };
403 - params.line_items = Some(vec![line_item]);
404 319
405 - let mut metadata = std::collections::HashMap::new();
320 + let mut metadata = HashMap::new();
406 321 metadata.insert("checkout_type".to_string(), CheckoutType::Tip.to_string());
407 322 metadata.insert("tipper_id".to_string(), tip.tipper_id.to_string());
408 323 metadata.insert("recipient_id".to_string(), tip.recipient_id.to_string());
@@ -410,32 +325,20 @@ impl StripeClient {
410 325 metadata.insert("project_id".to_string(), project_id.to_string());
411 326 }
412 327 if let Some(msg) = tip.message {
413 - // Stripe metadata values are limited to 500 chars
414 328 metadata.insert("message".to_string(), msg.chars().take(500).collect());
415 329 }
416 - params.metadata = Some(metadata);
417 -
Lines truncated
@@ -2,61 +2,50 @@
2 2 //!
3 3 //! Each variant (`CheckoutMetadata`, `SubscriptionCheckoutMetadata`, etc.) corresponds
4 4 //! to one of the `CheckoutType` flavors set in the session's `metadata.checkout_type`
5 - //! field at creation time. The `from_session` constructors parse the relevant fields
5 + //! field at creation time. The `from_metadata` constructors parse the relevant fields
6 6 //! back out at webhook time.
7 7
8 - use stripe::CheckoutSession;
8 + use std::collections::HashMap;
9 9
10 10 use crate::db::{CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId};
11 11 use crate::error::{AppError, Result};
12 12
13 - /// Parsed metadata from a checkout session
13 + /// Convenience alias — the metadata pulled off a Stripe `CheckoutSession`.
14 + pub type CheckoutMetaMap = HashMap<String, String>;
15 +
16 + fn require<'a>(meta: Option<&'a CheckoutMetaMap>, key: &str) -> Result<&'a String> {
17 + meta.and_then(|m| m.get(key))
18 + .ok_or_else(|| AppError::BadRequest(format!("Missing {key} in metadata")))
19 + }
20 +
21 + fn parse_uuid_to<T: From<uuid::Uuid>>(value: &str, field: &'static str) -> Result<T> {
22 + value.parse::<uuid::Uuid>()
23 + .map(T::from)
24 + .map_err(|_| AppError::BadRequest(format!("Invalid {field} format")))
25 + }
26 +
27 + /// Parsed metadata from a one-time purchase checkout session.
14 28 #[derive(Debug)]
15 29 pub struct CheckoutMetadata {
16 - /// UUID of the user making the purchase.
17 30 pub buyer_id: UserId,
18 - /// UUID of the creator receiving payment.
19 31 pub seller_id: UserId,
20 - /// UUID of the item being purchased (`None` for project-level purchases).
21 32 pub item_id: Option<ItemId>,
22 - /// UUID of the promo code used, if any.
23 33 pub promo_code_id: Option<PromoCodeId>,
24 34 }
25 35
26 36 impl CheckoutMetadata {
27 - /// Extract metadata from a checkout session
28 - pub fn from_session(session: &CheckoutSession) -> Result<Self> {
29 - let metadata = session.metadata.as_ref()
30 - .ok_or_else(|| AppError::BadRequest("Missing session metadata".to_string()))?;
31 -
32 - let buyer_id: UserId = metadata.get("buyer_id")
33 - .ok_or_else(|| AppError::BadRequest("Missing buyer_id in metadata".to_string()))?
34 - .parse::<uuid::Uuid>()
35 - .map(UserId::from)
36 - .map_err(|_| AppError::BadRequest("Invalid buyer_id format".to_string()))?;
37 -
38 - let seller_id: UserId = metadata.get("seller_id")
39 - .ok_or_else(|| AppError::BadRequest("Missing seller_id in metadata".to_string()))?
40 - .parse::<uuid::Uuid>()
41 - .map(UserId::from)
42 - .map_err(|_| AppError::BadRequest("Invalid seller_id format".to_string()))?;
43 -
44 - let item_id: Option<ItemId> = metadata.get("item_id")
37 + pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
38 + let buyer_id: UserId = parse_uuid_to(require(meta, "buyer_id")?, "buyer_id")?;
39 + let seller_id: UserId = parse_uuid_to(require(meta, "seller_id")?, "seller_id")?;
40 + let item_id = meta.and_then(|m| m.get("item_id"))
45 41 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(ItemId::from));
46 -
47 - let promo_code_id: Option<PromoCodeId> = metadata.get("promo_code_id")
42 + let promo_code_id = meta.and_then(|m| m.get("promo_code_id"))
48 43 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(PromoCodeId::from));
49 -
50 - Ok(CheckoutMetadata {
51 - buyer_id,
52 - seller_id,
53 - item_id,
54 - promo_code_id,
55 - })
44 + Ok(CheckoutMetadata { buyer_id, seller_id, item_id, promo_code_id })
56 45 }
57 46 }
58 47
59 - /// Parsed metadata from a subscription checkout session
48 + /// Parsed metadata from a subscription checkout session.
60 49 #[derive(Debug)]
61 50 pub struct SubscriptionCheckoutMetadata {
62 51 pub subscriber_id: UserId,
@@ -66,38 +55,13 @@ pub struct SubscriptionCheckoutMetadata {
66 55 }
67 56
68 57 impl SubscriptionCheckoutMetadata {
69 - /// Extract subscription metadata from a checkout session
70 - pub fn from_session(session: &CheckoutSession) -> Result<Self> {
71 - let metadata = session.metadata.as_ref()
72 - .ok_or_else(|| AppError::BadRequest("Missing session metadata".to_string()))?;
73 -
74 - let subscriber_id: UserId = metadata.get("subscriber_id")
75 - .ok_or_else(|| AppError::BadRequest("Missing subscriber_id in metadata".to_string()))?
76 - .parse::<uuid::Uuid>()
77 - .map(UserId::from)
78 - .map_err(|_| AppError::BadRequest("Invalid subscriber_id format".to_string()))?;
79 -
80 - let project_id: ProjectId = metadata.get("project_id")
81 - .ok_or_else(|| AppError::BadRequest("Missing project_id in metadata".to_string()))?
82 - .parse::<uuid::Uuid>()
83 - .map(ProjectId::from)
84 - .map_err(|_| AppError::BadRequest("Invalid project_id format".to_string()))?;
85 -
86 - let tier_id: SubscriptionTierId = metadata.get("tier_id")
87 - .ok_or_else(|| AppError::BadRequest("Missing tier_id in metadata".to_string()))?
88 - .parse::<uuid::Uuid>()
89 - .map(SubscriptionTierId::from)
90 - .map_err(|_| AppError::BadRequest("Invalid tier_id format".to_string()))?;
91 -
92 - let promo_code_id: Option<PromoCodeId> = metadata.get("promo_code_id")
58 + pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
59 + let subscriber_id: UserId = parse_uuid_to(require(meta, "subscriber_id")?, "subscriber_id")?;
60 + let project_id: ProjectId = parse_uuid_to(require(meta, "project_id")?, "project_id")?;
61 + let tier_id: SubscriptionTierId = parse_uuid_to(require(meta, "tier_id")?, "tier_id")?;
62 + let promo_code_id = meta.and_then(|m| m.get("promo_code_id"))
93 63 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(PromoCodeId::from));
94 -
95 - Ok(SubscriptionCheckoutMetadata {
96 - subscriber_id,
97 - project_id,
98 - tier_id,
99 - promo_code_id,
100 - })
64 + Ok(SubscriptionCheckoutMetadata { subscriber_id, project_id, tier_id, promo_code_id })
101 65 }
102 66 }
103 67
@@ -108,17 +72,8 @@ pub struct FanPlusCheckoutMetadata {
108 72 }
109 73
110 74 impl FanPlusCheckoutMetadata {
111 - /// Extract Fan+ metadata from a checkout session.
112 - pub fn from_session(session: &CheckoutSession) -> Result<Self> {
113 - let metadata = session.metadata.as_ref()
114 - .ok_or_else(|| AppError::BadRequest("Missing session metadata".to_string()))?;
115 -
116 - let user_id: UserId = metadata.get("user_id")
117 - .ok_or_else(|| AppError::BadRequest("Missing user_id in metadata".to_string()))?
118 - .parse::<uuid::Uuid>()
119 - .map(UserId::from)
120 - .map_err(|_| AppError::BadRequest("Invalid user_id format".to_string()))?;
121 -
75 + pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
76 + let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?;
122 77 Ok(FanPlusCheckoutMetadata { user_id })
123 78 }
124 79 }
@@ -131,21 +86,9 @@ pub struct CreatorTierCheckoutMetadata {
131 86 }
132 87
133 88 impl CreatorTierCheckoutMetadata {
134 - /// Extract creator tier metadata from a checkout session.
135 - pub fn from_session(session: &CheckoutSession) -> Result<Self> {
136 - let metadata = session.metadata.as_ref()
137 - .ok_or_else(|| AppError::BadRequest("Missing session metadata".to_string()))?;
138 -
139 - let user_id: UserId = metadata.get("user_id")
140 - .ok_or_else(|| AppError::BadRequest("Missing user_id in metadata".to_string()))?
141 - .parse::<uuid::Uuid>()
142 - .map(UserId::from)
143 - .map_err(|_| AppError::BadRequest("Invalid user_id format".to_string()))?;
144 -
145 - let tier = metadata.get("tier")
146 - .ok_or_else(|| AppError::BadRequest("Missing tier in metadata".to_string()))?
147 - .clone();
148 -
89 + pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
90 + let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?;
91 + let tier = require(meta, "tier")?.clone();
149 92 Ok(CreatorTierCheckoutMetadata { user_id, tier })
150 93 }
151 94 }
@@ -160,34 +103,13 @@ pub struct TipCheckoutMetadata {
160 103 }
161 104
162 105 impl TipCheckoutMetadata {
163 - /// Extract tip metadata from a checkout session.
164 - pub fn from_session(session: &CheckoutSession) -> Result<Self> {
165 - let metadata = session.metadata.as_ref()
166 - .ok_or_else(|| AppError::BadRequest("Missing session metadata".to_string()))?;
167 -
168 - let tipper_id: UserId = metadata.get("tipper_id")
169 - .ok_or_else(|| AppError::BadRequest("Missing tipper_id in metadata".to_string()))?
170 - .parse::<uuid::Uuid>()
171 - .map(UserId::from)
172 - .map_err(|_| AppError::BadRequest("Invalid tipper_id format".to_string()))?;
173 -
174 - let recipient_id: UserId = metadata.get("recipient_id")
175 - .ok_or_else(|| AppError::BadRequest("Missing recipient_id in metadata".to_string()))?
176 - .parse::<uuid::Uuid>()
177 - .map(UserId::from)
178 - .map_err(|_| AppError::BadRequest("Invalid recipient_id format".to_string()))?;
179 -
180 - let project_id: Option<ProjectId> = metadata.get("project_id")
106 + pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
107 + let tipper_id: UserId = parse_uuid_to(require(meta, "tipper_id")?, "tipper_id")?;
108 + let recipient_id: UserId = parse_uuid_to(require(meta, "recipient_id")?, "recipient_id")?;
109 + let project_id = meta.and_then(|m| m.get("project_id"))
181 110 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(ProjectId::from));
182 -
183 - let message = metadata.get("message").cloned();
184 -
185 - Ok(TipCheckoutMetadata {
186 - tipper_id,
187 - recipient_id,
188 - project_id,
189 - message,
190 - })
111 + let message = meta.and_then(|m| m.get("message")).cloned();
112 + Ok(TipCheckoutMetadata { tipper_id, recipient_id, project_id, message })
191 113 }
192 114 }
193 115
@@ -201,78 +123,16 @@ pub struct AppSyncCheckoutMetadata {
201 123 }
202 124
203 125 impl AppSyncCheckoutMetadata {
204 - /// Extract app sync metadata from a checkout session.
205 - pub fn from_session(session: &CheckoutSession) -> Result<Self> {
206 - let metadata = session.metadata.as_ref()
207 - .ok_or_else(|| AppError::BadRequest("Missing session metadata".to_string()))?;
208 -
209 - let user_id: UserId = metadata.get("user_id")
210 - .ok_or_else(|| AppError::BadRequest("Missing user_id in metadata".to_string()))?
211 - .parse::<uuid::Uuid>()
212 - .map(UserId::from)
213 - .map_err(|_| AppError::BadRequest("Invalid user_id format".to_string()))?;
214 -
215 - let app_id: SyncAppId = metadata.get("app_id")
216 - .ok_or_else(|| AppError::BadRequest("Missing app_id in metadata".to_string()))?
217 - .parse::<uuid::Uuid>()
218 - .map(SyncAppId::from)
219 - .map_err(|_| AppError::BadRequest("Invalid app_id format".to_string()))?;
220 -
221 - let tier = metadata.get("tier")
222 - .ok_or_else(|| AppError::BadRequest("Missing tier in metadata".to_string()))?
223 - .clone();
224 -
225 - let app_name = metadata.get("app_name")
226 - .ok_or_else(|| AppError::BadRequest("Missing app_name in metadata".to_string()))?
227 - .clone();
228 -
126 + pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
127 + let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?;
128 + let app_id: SyncAppId = parse_uuid_to(require(meta, "app_id")?, "app_id")?;
129 + let tier = require(meta, "tier")?.clone();
130 + let app_name = require(meta, "app_name")?.clone();
229 131 Ok(AppSyncCheckoutMetadata { user_id, app_id, tier, app_name })
230 132 }
231 133 }
232 134
233 - /// Extract the checkout type from a Stripe session's metadata.
234 - pub fn get_checkout_type(session: &CheckoutSession) -> Option<CheckoutType> {
235 - session.metadata.as_ref()
236 - .and_then(|m| m.get("checkout_type"))
237 - .and_then(|t| t.parse().ok())
238 - }
239 -
240 - /// Check if a checkout session is for a tip.
241 - pub fn is_tip_checkout(session: &CheckoutSession) -> bool {
242 - get_checkout_type(session) == Some(CheckoutType::Tip)
243 - }
244 -
245 - /// Check if a checkout session is for a Fan+ subscription.
246 - pub fn is_fan_plus_checkout(session: &CheckoutSession) -> bool {
247 - get_checkout_type(session) == Some(CheckoutType::FanPlus)
248 - }
249 -
250 - /// Check if a checkout session is for a creator tier subscription.
251 - pub fn is_creator_tier_checkout(session: &CheckoutSession) -> bool {
252 - get_checkout_type(session) == Some(CheckoutType::CreatorTier)
253 - }
254 -
255 - /// Check if a checkout session is for a subscription (vs one-time purchase)
256 - pub fn is_subscription_checkout(session: &CheckoutSession) -> bool {
257 - get_checkout_type(session) == Some(CheckoutType::Subscription)
258 - }
259 -
260 - /// Check if a checkout session is a guest checkout (no MNW account).
261 - pub fn is_guest_checkout(session: &CheckoutSession) -> bool {
262 - get_checkout_type(session) == Some(CheckoutType::Guest)
263 - }
264 -
265 - /// Check if a checkout session is for an app sync subscription.
266 - pub fn is_app_sync_checkout(session: &CheckoutSession) -> bool {
267 - get_checkout_type(session) == Some(CheckoutType::AppSync)
268 - }
269 -
270 - /// Check if a checkout session is a cart (multi-item) checkout.
271 - pub fn is_cart_checkout(session: &CheckoutSession) -> bool {
272 - get_checkout_type(session) == Some(CheckoutType::Cart)
273 - }
274 -
275 - /// Parsed metadata from a cart checkout session.
135 + /// Parsed metadata from a cart (multi-item) checkout session.
276 136 #[derive(Debug)]
277 137 pub struct CartCheckoutMetadata {
278 138 pub buyer_id: UserId,
@@ -280,24 +140,10 @@ pub struct CartCheckoutMetadata {
280 140 }
281 141
282 142 impl CartCheckoutMetadata {
283 - pub fn from_session(session: &CheckoutSession) -> Result<Self> {
284 - let meta = session.metadata.as_ref().ok_or(AppError::BadRequest(
285 - "Missing checkout metadata".to_string(),
286 - ))?;
287 -
288 - let buyer_id: UserId = meta
289 - .get("buyer_id")
290 - .ok_or_else(|| AppError::BadRequest("Missing buyer_id in metadata".to_string()))?
291 - .parse()
292 - .map_err(|_| AppError::BadRequest("Invalid buyer_id".to_string()))?;
293 -
294 - let seller_id: UserId = meta
295 - .get("seller_id")
296 - .ok_or_else(|| AppError::BadRequest("Missing seller_id in metadata".to_string()))?
297 - .parse()
298 - .map_err(|_| AppError::BadRequest("Invalid seller_id".to_string()))?;
299 -
300 - Ok(Self { buyer_id, seller_id })
143 + pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
144 + let buyer_id: UserId = parse_uuid_to(require(meta, "buyer_id")?, "buyer_id")?;
145 + let seller_id: UserId = parse_uuid_to(require(meta, "seller_id")?, "seller_id")?;
146 + Ok(CartCheckoutMetadata { buyer_id, seller_id })
301 147 }
302 148 }
303 149
@@ -310,606 +156,420 @@ pub struct GuestCheckoutMetadata {
310 156 }
311 157
312 158 impl GuestCheckoutMetadata {
313 - pub fn from_session(session: &CheckoutSession) -> Result<Self> {
314 - let metadata = session.metadata.as_ref()
315 - .ok_or_else(|| AppError::BadRequest("Missing session metadata".to_string()))?;
316 -
317 - let seller_id: UserId = metadata.get("seller_id")
318 - .ok_or_else(|| AppError::BadRequest("Missing seller_id in metadata".to_string()))?
319 - .parse::<uuid::Uuid>()
320 - .map(UserId::from)
321 - .map_err(|_| AppError::BadRequest("Invalid seller_id format".to_string()))?;
322 -
323 - let item_id: ItemId = metadata.get("item_id")
324 - .ok_or_else(|| AppError::BadRequest("Missing item_id in metadata".to_string()))?
325 - .parse::<uuid::Uuid>()
326 - .map(ItemId::from)
327 - .map_err(|_| AppError::BadRequest("Invalid item_id format".to_string()))?;
328 -
329 - let promo_code_id: Option<PromoCodeId> = metadata.get("promo_code_id")
159 + pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> {
160 + let seller_id: UserId = parse_uuid_to(require(meta, "seller_id")?, "seller_id")?;
161 + let item_id: ItemId = parse_uuid_to(require(meta, "item_id")?, "item_id")?;
162 + let promo_code_id = meta.and_then(|m| m.get("promo_code_id"))
330 163 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(PromoCodeId::from));
331 -
332 164 Ok(GuestCheckoutMetadata { seller_id, item_id, promo_code_id })
333 165 }
334 166 }
335 167
168 + /// Extract the checkout type from a Stripe session's metadata.
169 + pub fn get_checkout_type(meta: Option<&CheckoutMetaMap>) -> Option<CheckoutType> {
170 + meta.and_then(|m| m.get("checkout_type"))
171 + .and_then(|t| t.parse().ok())
172 + }
173 +
174 + pub fn is_tip_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
175 + get_checkout_type(meta) == Some(CheckoutType::Tip)
176 + }
177 +
178 + pub fn is_fan_plus_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
179 + get_checkout_type(meta) == Some(CheckoutType::FanPlus)
180 + }
181 +
182 + pub fn is_creator_tier_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
183 + get_checkout_type(meta) == Some(CheckoutType::CreatorTier)
184 + }
185 +
186 + pub fn is_subscription_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
187 + get_checkout_type(meta) == Some(CheckoutType::Subscription)
188 + }
189 +
190 + pub fn is_guest_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
191 + get_checkout_type(meta) == Some(CheckoutType::Guest)
192 + }
193 +
194 + pub fn is_app_sync_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
195 + get_checkout_type(meta) == Some(CheckoutType::AppSync)
196 + }
197 +
198 + pub fn is_cart_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
199 + get_checkout_type(meta) == Some(CheckoutType::Cart)
200 + }
201 +
336 202 #[cfg(test)]
337 203 mod tests {
338 204 use super::*;
339 - use std::collections::HashMap;
340 205
341 - #[allow(clippy::field_reassign_with_default)]
342 - fn session_with_metadata(metadata: Option<HashMap<String, String>>) -> CheckoutSession {
343 - let mut session = CheckoutSession::default();
344 - session.metadata = metadata;
345 - session
206 + fn meta_of(entries: &[(&str, &str)]) -> CheckoutMetaMap {
207 + entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
346 208 }
347 209
348 - // --- CheckoutMetadata::from_session ---
210 + // --- CheckoutMetadata ---
349 211
350 212 #[test]
351 - fn from_session_valid_metadata() {
213 + fn from_metadata_valid() {
352 214 let buyer = UserId::new();
353 215 let seller = UserId::new();
354 216 let item = ItemId::new();
355 - let mut meta = HashMap::new();
356 - meta.insert("buyer_id".to_string(), buyer.to_string());
357 - meta.insert("seller_id".to_string(), seller.to_string());
358 - meta.insert("item_id".to_string(), item.to_string());
359 -
360 - let session = session_with_metadata(Some(meta));
361 - let result = CheckoutMetadata::from_session(&session).unwrap();
362 - assert_eq!(result.buyer_id, buyer);
363 - assert_eq!(result.seller_id, seller);
364 - assert_eq!(result.item_id, Some(item));
217 + let m = meta_of(&[
218 + ("buyer_id", &buyer.to_string()),
219 + ("seller_id", &seller.to_string()),
220 + ("item_id", &item.to_string()),
221 + ]);
222 + let r = CheckoutMetadata::from_metadata(Some(&m)).unwrap();
223 + assert_eq!(r.buyer_id, buyer);
224 + assert_eq!(r.seller_id, seller);
225 + assert_eq!(r.item_id, Some(item));
365 226 }
366 227
367 228 #[test]
368 - fn from_session_missing_metadata() {
369 - let session = session_with_metadata(None);
370 - assert!(CheckoutMetadata::from_session(&session).is_err());
229 + fn from_metadata_missing_metadata() {
230 + assert!(CheckoutMetadata::from_metadata(None).is_err());
371 231 }
372 232
373 233 #[test]
374 - fn from_session_missing_buyer_id() {
375 - let mut meta = HashMap::new();
376 - meta.insert("seller_id".to_string(), UserId::new().to_string());
377 - meta.insert("item_id".to_string(), ItemId::new().to_string());
378 - let session = session_with_metadata(Some(meta));
379 - assert!(CheckoutMetadata::from_session(&session).is_err());
234 + fn from_metadata_missing_buyer_id() {
235 + let m = meta_of(&[("seller_id", &UserId::new().to_string()), ("item_id", &ItemId::new().to_string())]);
236 + assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err());
380 237 }
381 238
382 239 #[test]
383 - fn from_session_missing_seller_id() {
384 - let mut meta = HashMap::new();
385 - meta.insert("buyer_id".to_string(), UserId::new().to_string());
386 - meta.insert("item_id".to_string(), ItemId::new().to_string());
387 - let session = session_with_metadata(Some(meta));
388 - assert!(CheckoutMetadata::from_session(&session).is_err());
240 + fn from_metadata_missing_seller_id() {
241 + let m = meta_of(&[("buyer_id", &UserId::new().to_string()), ("item_id", &ItemId::new().to_string())]);
242 + assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err());
389 243 }
390 244
391 245 #[test]
392 - fn from_session_invalid_uuid_format() {
393 - let mut meta = HashMap::new();
394 - meta.insert("buyer_id".to_string(), "not-a-uuid".to_string());
395 - meta.insert("seller_id".to_string(), UserId::new().to_string());
396 - meta.insert("item_id".to_string(), ItemId::new().to_string());
397 - let session = session_with_metadata(Some(meta));
398 - assert!(CheckoutMetadata::from_session(&session).is_err());
246 + fn from_metadata_invalid_uuid_format() {
247 + let m = meta_of(&[
248 + ("buyer_id", "not-a-uuid"),
249 + ("seller_id", &UserId::new().to_string()),
250 + ("item_id", &ItemId::new().to_string()),
251 + ]);
252 + assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err());
399 253 }
400 254
401 255 #[test]
402 - fn from_session_empty_metadata() {
403 - let session = session_with_metadata(Some(HashMap::new()));
404 - assert!(CheckoutMetadata::from_session(&session).is_err());
256 + fn from_metadata_empty() {
257 + let m = HashMap::new();
258 + assert!(CheckoutMetadata::from_metadata(Some(&m)).is_err());
405 259 }
406 260
407 261 // --- SubscriptionCheckoutMetadata ---
408 262
409 263 #[test]
410 264 fn sub_metadata_valid() {
411 - let sub_id = UserId::new();
412 - let proj_id = ProjectId::new();
413 - let tier_id = SubscriptionTierId::new();
414 - let mut meta = HashMap::new();
415 - meta.insert("subscriber_id".to_string(), sub_id.to_string());
416 - meta.insert("project_id".to_string(), proj_id.to_string());
Lines truncated
@@ -1,42 +1,55 @@
1 - //! Connected account operations — onboarding, balance, product/price creation.
2 -
3 - use stripe::{
4 - Account, AccountLink, AccountLinkType, AccountType,
5 - Balance,
6 - CreateAccount, CreateAccountLink,
7 - CreatePrice, CreatePriceRecurring, CreatePriceRecurringInterval,
8 - CreateProduct, Currency, IdOrCreate, Price, Product,
9 - Subscription, UpdateSubscription, UpdateSubscriptionPauseCollection,
10 - UpdateSubscriptionPauseCollectionBehavior,
1 + //! Connected account operations — onboarding, balance, product/price creation,
2 + //! subscription lifecycle, refunds, and billing portal.
3 +
4 + use stripe::StripeRequest;
5 + use stripe_billing::subscription::{
6 + CancelSubscription, ResumeSubscription, RetrieveSubscription,
7 + UpdateSubscription, UpdateSubscriptionItems, UpdateSubscriptionItemsPriceData,
8 + UpdateSubscriptionItemsPriceDataRecurring, UpdateSubscriptionItemsPriceDataRecurringInterval,
9 + UpdateSubscriptionPauseCollection, UpdateSubscriptionPauseCollectionBehavior,
10 + UpdateSubscriptionProrationBehavior,
11 11 };
12 + use stripe_billing::billing_portal_session::CreateBillingPortalSession;
13 + use stripe_connect::account::{CreateAccount, CreateAccountType, RetrieveAccount};
14 + use stripe_connect::account_link::{CreateAccountLink, CreateAccountLinkType};
15 + use stripe_core::balance::RetrieveForMyAccountBalance;
16 + use stripe_core::refund::CreateRefund;
17 + use stripe_product::product::CreateProduct;
18 + use stripe_product::price::{CreatePrice, CreatePriceRecurring, CreatePriceRecurringInterval};
19 + use stripe_types::Currency;
20 +
12 21 use crate::error::{AppError, Result};
13 22 use super::StripeClient;
14 23
24 + fn parse_subscription_id(stripe_sub_id: &str) -> Result<stripe_shared::SubscriptionId> {
25 + stripe_sub_id.parse().map_err(|e| {
26 + AppError::Internal(anyhow::anyhow!("Invalid Stripe subscription ID '{}': {}", stripe_sub_id, e))
27 + })
28 + }
29 +
30 + fn parse_account_id_internal(account_id: &str) -> Result<stripe_shared::AccountId> {
31 + account_id.parse().map_err(|_| {
32 + AppError::Internal(anyhow::anyhow!("Invalid Stripe account ID"))
33 + })
34 + }
35 +
15 36 impl StripeClient {
16 37 /// Create a Stripe Standard connected account for a creator.
17 - ///
18 - /// Returns the `acct_...` account ID string.
19 38 #[tracing::instrument(skip_all, name = "payments::create_connect_account")]
20 39 pub async fn create_connect_account(&self, email: &str) -> Result<String> {
21 - let mut params = CreateAccount::new();
22 - params.type_ = Some(AccountType::Standard);
23 - params.email = Some(email);
24 -
25 - let account = Account::create(&self.client, params)
40 + let account = CreateAccount::new()
41 + .type_(CreateAccountType::Standard)
42 + .email(email.to_string())
43 + .send(&self.client)
26 44 .await
27 45 .map_err(|e| {
28 46 tracing::error!(error = ?e, "failed to create Stripe connected account");
29 47 AppError::BadRequest("Failed to create Stripe account".to_string())
30 48 })?;
31 -
32 49 Ok(account.id.to_string())
33 50 }
34 51
35 52 /// Create an Account Link for Stripe Connect onboarding.
36 - ///
37 - /// Returns the URL to redirect the creator to. The link is single-use
38 - /// and expires, so `refresh_url` should point to a handler that creates
39 - /// a fresh link.
40 53 #[tracing::instrument(skip_all, name = "payments::create_account_link")]
41 54 pub async fn create_account_link(
42 55 &self,
@@ -44,32 +57,25 @@ impl StripeClient {
44 57 return_url: &str,
45 58 refresh_url: &str,
46 59 ) -> Result<String> {
47 - let account_id = account_id.parse().map_err(|_| {
48 - AppError::BadRequest("Invalid Stripe account ID format".to_string())
49 - })?;
50 -
51 - let mut params = CreateAccountLink::new(account_id, AccountLinkType::AccountOnboarding);
52 - params.return_url = Some(return_url);
53 - params.refresh_url = Some(refresh_url);
54 -
55 - let link = AccountLink::create(&self.client, params)
60 + // CreateAccountLink takes the account id as a plain String, not AccountId.
61 + let link = CreateAccountLink::new(account_id.to_string(), CreateAccountLinkType::AccountOnboarding)
62 + .return_url(return_url.to_string())
63 + .refresh_url(refresh_url.to_string())
64 + .send(&self.client)
56 65 .await
57 66 .map_err(|e| {
58 67 tracing::error!(error = ?e, "failed to create Stripe account link");
59 68 AppError::BadRequest("Failed to create Stripe onboarding link".to_string())
60 69 })?;
61 -
62 70 Ok(link.url)
63 71 }
64 72
65 - /// Fetch a Stripe Connect account by ID and return an `AccountUpdate`.
73 + /// Fetch a Stripe Connect account by ID.
66 74 #[tracing::instrument(skip_all, name = "payments::fetch_account")]
67 75 pub async fn fetch_account(&self, account_id: &str) -> Result<super::AccountUpdate> {
68 - let account_id = account_id.parse().map_err(|_| {
69 - AppError::BadRequest("Invalid Stripe account ID format".to_string())
70 - })?;
71 -
72 - let account = Account::retrieve(&self.client, &account_id, &[])
76 + let account_id = Self::parse_account_id(account_id)?;
77 + let account = RetrieveAccount::new(account_id)
78 + .send(&self.client)
73 79 .await
74 80 .map_err(|e| {
75 81 tracing::error!(error = ?e, "failed to fetch Stripe account");
@@ -84,9 +90,7 @@ impl StripeClient {
84 90 })
85 91 }
86 92
87 - /// Create a Stripe Product and monthly recurring Price on a connected account.
88 - ///
89 - /// Called when a creator creates a subscription tier. Returns `(product_id, price_id)`.
93 + /// Create a Product + monthly recurring Price on a connected account.
90 94 #[tracing::instrument(skip_all, name = "payments::create_subscription_product_and_price")]
91 95 pub async fn create_subscription_product_and_price(
92 96 &self,
@@ -99,35 +103,29 @@ impl StripeClient {
99 103 return Err(AppError::BadRequest("Price must be positive".to_string()));
100 104 }
101 105
102 - let connected_client = self.client.clone().with_stripe_account(
103 - connected_account_id.parse().map_err(|_| {
104 - AppError::BadRequest("Invalid Stripe account ID format".to_string())
105 - })?,
106 - );
106 + let acct = Self::parse_account_id(connected_account_id)?;
107 107
108 - // Create the Product
109 - let mut product_params = CreateProduct::new(tier_name);
108 + let mut product_req = CreateProduct::new(tier_name.to_string());
110 109 if let Some(desc) = tier_description {
111 - product_params.description = Some(desc);
110 + product_req = product_req.description(desc.to_string());
112 111 }
113 -
114 - let product = Product::create(&connected_client, product_params)
112 + let product = product_req
113 + .customize()
114 + .account_id(acct.clone())
115 + .send(&self.client)
115 116 .await
116 117 .map_err(|e| {
117 118 tracing::error!(error = ?e, "failed to create Stripe product");
118 119 AppError::BadRequest("Failed to create subscription product".to_string())
119 120 })?;
120 121
121 - // Create a monthly recurring Price
122 - let mut price_params = CreatePrice::new(Currency::USD);
123 - price_params.product = Some(IdOrCreate::Id(&product.id));
124 - price_params.unit_amount = Some(price_cents);
125 - price_params.recurring = Some(CreatePriceRecurring {
126 - interval: CreatePriceRecurringInterval::Month,
127 - ..Default::default()
128 - });
129 -
130 - let price = Price::create(&connected_client, price_params)
122 + let price = CreatePrice::new(Currency::USD)
123 + .product(product.id.to_string())
124 + .unit_amount(price_cents)
125 + .recurring(CreatePriceRecurring::new(CreatePriceRecurringInterval::Month))
126 + .customize()
127 + .account_id(acct)
128 + .send(&self.client)
131 129 .await
132 130 .map_err(|e| {
133 131 tracing::error!(error = ?e, "failed to create Stripe price");
@@ -138,16 +136,13 @@ impl StripeClient {
138 136 }
139 137
140 138 /// Retrieve the balance for a connected account.
141 - ///
142 - /// Returns the Stripe Balance object which contains `available` and
143 - /// `pending` amounts broken down by currency.
144 139 #[tracing::instrument(skip_all, name = "payments::get_connected_account_balance")]
145 - pub async fn get_connected_account_balance(&self, account_id: &str) -> Result<Balance> {
146 - let account_id = account_id.parse().map_err(|_| {
147 - AppError::BadRequest("Invalid Stripe account ID format".to_string())
148 - })?;
149 -
150 - Balance::retrieve(&self.client, Some(account_id))
140 + pub async fn get_connected_account_balance(&self, account_id: &str) -> Result<stripe_core::Balance> {
141 + let acct = Self::parse_account_id(account_id)?;
142 + RetrieveForMyAccountBalance::new()
143 + .customize()
144 + .account_id(acct)
145 + .send(&self.client)
151 146 .await
152 147 .map_err(|e| {
153 148 tracing::error!(error = ?e, "failed to fetch Stripe balance");
@@ -155,32 +150,23 @@ impl StripeClient {
155 150 })
156 151 }
157 152
158 - /// Pause a subscription on a connected account (void invoices while paused).
159 - ///
160 - /// Used when a creator is suspended to stop charging their fans.
153 + /// Pause subscription collection (void invoices) on a connected account.
161 154 #[tracing::instrument(skip_all, name = "payments::pause_subscription")]
162 155 pub async fn pause_subscription(
163 156 &self,
164 157 stripe_sub_id: &str,
165 158 connected_account_id: &str,
166 159 ) -> Result<()> {
167 - let connected_client = self.client.clone().with_stripe_account(
168 - connected_account_id.parse().map_err(|_| {
169 - AppError::Internal(anyhow::anyhow!("Invalid Stripe account ID"))
170 - })?,
171 - );
172 -
173 - let sub_id = stripe_sub_id.parse::<stripe::SubscriptionId>().map_err(|e| {
174 - AppError::Internal(anyhow::anyhow!("Invalid Stripe subscription ID '{}': {}", stripe_sub_id, e))
175 - })?;
176 -
177 - let mut params = UpdateSubscription::new();
178 - params.pause_collection = Some(UpdateSubscriptionPauseCollection {
179 - behavior: UpdateSubscriptionPauseCollectionBehavior::Void,
180 - resumes_at: None,
181 - });
182 -
183 - Subscription::update(&connected_client, &sub_id, params)
160 + let acct = parse_account_id_internal(connected_account_id)?;
161 + let sub_id = parse_subscription_id(stripe_sub_id)?;
162 +
163 + UpdateSubscription::new(sub_id)
164 + .pause_collection(UpdateSubscriptionPauseCollection::new(
165 + UpdateSubscriptionPauseCollectionBehavior::Void,
166 + ))
167 + .customize()
168 + .account_id(acct)
169 + .send(&self.client)
184 170 .await
185 171 .map_err(|e| {
186 172 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to pause Stripe subscription");
@@ -192,58 +178,44 @@ impl StripeClient {
192 178
193 179 /// Resume a paused subscription on a connected account.
194 180 ///
195 - /// Used when a creator is unsuspended to resume charging their fans.
181 + /// rc.5 exposes `POST /subscriptions/{id}/resume` as the proper way to lift
182 + /// a pause; the legacy "clear `pause_collection`" trick is no longer needed.
196 183 #[tracing::instrument(skip_all, name = "payments::resume_subscription")]
197 184 pub async fn resume_subscription(
198 185 &self,
199 186 stripe_sub_id: &str,
200 187 connected_account_id: &str,
201 188 ) -> Result<()> {
202 - // The stripe crate can't express "clear pause_collection" (needs empty
203 - // string, not null). Use the raw Stripe API instead.
204 - let url = format!("https://api.stripe.com/v1/subscriptions/{}", stripe_sub_id);
205 - let resp = reqwest::Client::new()
206 - .post(&url)
207 - .header("Authorization", format!("Bearer {}", self.config.secret_key))
208 - .header("Stripe-Account", connected_account_id)
209 - .form(&[("pause_collection", "")])
210 - .timeout(std::time::Duration::from_secs(30))
211 - .send()
189 + let acct = parse_account_id_internal(connected_account_id)?;
190 + let sub_id = parse_subscription_id(stripe_sub_id)?;
191 +
192 + ResumeSubscription::new(sub_id)
193 + .customize()
194 + .account_id(acct)
195 + .send(&self.client)
212 196 .await
213 197 .map_err(|e| {
214 198 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to resume Stripe subscription");
215 199 AppError::Internal(anyhow::anyhow!("Failed to resume subscription"))
216 200 })?;
217 201
218 - if !resp.status().is_success() {
219 - let body = resp.text().await.unwrap_or_default();
220 - tracing::error!(stripe_sub_id = %stripe_sub_id, body = %body, "Stripe resume subscription returned error");
221 - return Err(AppError::Internal(anyhow::anyhow!("Failed to resume subscription")));
222 - }
223 -
224 202 Ok(())
225 203 }
226 204
227 - /// Cancel a subscription on a connected account (permanent, not pausable).
228 - ///
229 - /// Used when a creator's account is terminated.
205 + /// Cancel a subscription on a connected account (permanent).
230 206 #[tracing::instrument(skip_all, name = "payments::cancel_subscription")]
231 207 pub async fn cancel_subscription(
232 208 &self,
233 209 stripe_sub_id: &str,
234 210 connected_account_id: &str,
235 211 ) -> Result<()> {
236 - let connected_client = self.client.clone().with_stripe_account(
237 - connected_account_id.parse().map_err(|_| {
238 - AppError::Internal(anyhow::anyhow!("Invalid Stripe account ID"))
239 - })?,
240 - );
241 -
242 - let sub_id = stripe_sub_id.parse::<stripe::SubscriptionId>().map_err(|e| {
243 - AppError::Internal(anyhow::anyhow!("Invalid Stripe subscription ID '{}': {}", stripe_sub_id, e))
244 - })?;
212 + let acct = parse_account_id_internal(connected_account_id)?;
213 + let sub_id = parse_subscription_id(stripe_sub_id)?;
245 214
246 - stripe::Subscription::cancel(&connected_client, &sub_id, stripe::CancelSubscription::default())
215 + CancelSubscription::new(sub_id)
216 + .customize()
217 + .account_id(acct)
218 + .send(&self.client)
247 219 .await
248 220 .map_err(|e| {
249 221 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel Stripe subscription");
@@ -255,9 +227,9 @@ impl StripeClient {
255 227
256 228 /// Change the tier of a platform-level app sync subscription.
257 229 ///
258 - /// Retrieves the subscription to find the existing item, then updates it
259 - /// with new inline price_data for the target tier. Stripe prorates automatically.
260 - /// Returns the updated subscription's metadata for webhook reconciliation.
230 + /// rc.5's `UpdateSubscriptionItemsPriceData` requires an existing `product`
231 + /// id (no inline `product_data`), so we create a Product first and then
232 + /// swap the subscription item's price to a new inline Price referencing it.
261 233 #[tracing::instrument(skip_all, name = "payments::update_app_sync_subscription_tier")]
262 234 pub async fn update_app_sync_subscription_tier(
263 235 &self,
@@ -266,166 +238,96 @@ impl StripeClient {
266 238 price_cents: i64,
267 239 interval: &str,
268 240 ) -> Result<()> {
269 - let http = reqwest::Client::new();
270 - let auth = format!("Bearer {}", self.config.secret_key);
271 -
272 - // 1. Retrieve subscription to get the current item ID
273 - let resp = http
274 - .get(&format!("https://api.stripe.com/v1/subscriptions/{stripe_sub_id}"))
275 - .header("Authorization", &auth)
276 - .timeout(std::time::Duration::from_secs(30))
277 - .send()
241 + let sub_id = parse_subscription_id(stripe_sub_id)?;
242 +
243 + // 1. Retrieve subscription to find the existing item ID.
244 + let sub = RetrieveSubscription::new(sub_id.clone())
245 + .send(&self.client)
278 246 .await
279 247 .map_err(|e| {
280 248 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to retrieve subscription");
281 249 AppError::Internal(anyhow::anyhow!("Failed to retrieve subscription"))
282 250 })?;
283 251
284 - if !resp.status().is_success() {
285 - let body = resp.text().await.unwrap_or_default();
286 - tracing::error!(stripe_sub_id = %stripe_sub_id, body = %body, "Stripe retrieve subscription returned error");
287 - return Err(AppError::Internal(anyhow::anyhow!("Failed to retrieve subscription")));
288 - }
289 -
290 - let sub_json: serde_json::Value = resp.json().await.map_err(|e| {
291 - tracing::error!(error = ?e, "failed to parse subscription JSON");
292 - AppError::Internal(anyhow::anyhow!("Failed to parse subscription"))
252 + let item_id = sub.items.data.first().map(|it| it.id.to_string()).ok_or_else(|| {
253 + tracing::error!(stripe_sub_id = %stripe_sub_id, "subscription has no items");
254 + AppError::Internal(anyhow::anyhow!("Subscription has no items"))
293 255 })?;
294 256
295 - let item_id = sub_json["items"]["data"][0]["id"]
296 - .as_str()
297 - .ok_or_else(|| {
298 - tracing::error!(stripe_sub_id = %stripe_sub_id, "subscription has no items");
299 - AppError::Internal(anyhow::anyhow!("Subscription has no items"))
257 + // 2. Create a Product (no `Stripe-Account` — platform-mode subscription).
258 + let product = CreateProduct::new(product_name.to_string())
259 + .send(&self.client)
260 + .await
261 + .map_err(|e| {
262 + tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to create product for tier change");
263 + AppError::Internal(anyhow::anyhow!("Failed to create product"))
300 264 })?;
301 265
302 - // 2. Update subscription: replace the item's price with new inline price_data
303 - let resp = http
304 - .post(&format!("https://api.stripe.com/v1/subscriptions/{stripe_sub_id}"))
305 - .header("Authorization", &auth)
306 - .form(&[
307 - ("items[0][id]", item_id),
308 - ("items[0][price_data][currency]", "usd"),
309 - ("items[0][price_data][product_data][name]", product_name),
310 - ("items[0][price_data][unit_amount]", &price_cents.to_string()),
311 - ("items[0][price_data][recurring][interval]", interval),
312 - ("proration_behavior", "create_prorations"),
313 - ])
314 - .timeout(std::time::Duration::from_secs(30))
315 - .send()
266 + // 3. Update the subscription item with inline price_data on the new product.
267 + let recurring_interval = match interval {
268 + "year" => UpdateSubscriptionItemsPriceDataRecurringInterval::Year,
269 + _ => UpdateSubscriptionItemsPriceDataRecurringInterval::Month,
270 + };
271 + let mut price_data = UpdateSubscriptionItemsPriceData::new(
272 + Currency::USD,
273 + product.id.to_string(),
274 + UpdateSubscriptionItemsPriceDataRecurring::new(recurring_interval),
275 + );
276 + price_data.unit_amount = Some(price_cents);
277 +
278 + let item = UpdateSubscriptionItems {
279 + id: Some(item_id),
280 + price_data: Some(price_data),
281 + ..UpdateSubscriptionItems::new()
282 + };
283 +
284 + UpdateSubscription::new(sub_id)
285 + .items(vec![item])
286 + .proration_behavior(UpdateSubscriptionProrationBehavior::CreateProrations)
287 + .send(&self.client)
316 288 .await
317 289 .map_err(|e| {
318 290 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to update subscription tier");
319 291 AppError::Internal(anyhow::anyhow!("Failed to update subscription tier"))
320 292 })?;
321 293
322 - if !resp.status().is_success() {
323 - let body = resp.text().await.unwrap_or_default();
324 - tracing::error!(stripe_sub_id = %stripe_sub_id, body = %body, "Stripe update subscription returned error");
325 - return Err(AppError::Internal(anyhow::anyhow!("Failed to update subscription tier")));
326 - }
327 -
328 294 Ok(())
329 295 }
330 296
331 - /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account.
297 + /// Cancel a platform-level subscription (creator tier, Fan+).
332 298 #[tracing::instrument(skip_all, name = "payments::cancel_platform_subscription")]
333 - pub async fn cancel_platform_subscription(
334 - &self,
335 - stripe_sub_id: &str,
336 - ) -> Result<()> {
337 - let sub_id = stripe_sub_id.parse::<stripe::SubscriptionId>().map_err(|e| {
338 - AppError::Internal(anyhow::anyhow!("Invalid Stripe subscription ID '{}': {}", stripe_sub_id, e))
339 - })?;
340 -
341 - stripe::Subscription::cancel(&self.client, &sub_id, stripe::CancelSubscription::default())
299 + pub async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> Result<()> {
300 + let sub_id = parse_subscription_id(stripe_sub_id)?;
301 + CancelSubscription::new(sub_id)
302 + .send(&self.client)
342 303 .await
343 304 .map_err(|e| {
344 305 tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel platform subscription");
345 306 AppError::Internal(anyhow::anyhow!("Failed to cancel platform subscription"))
346 307 })?;
347 -
348 308 Ok(())
349 309 }
350 310
351 - /// Set or clear `cancel_at_period_end` on a platform-level subscription
352 - /// (Fan+, creator tier). Used by Fan+ self-service cancel/resume on the
353 - /// dashboard. No `Stripe-Account` header — the subscription belongs to
354 - /// the platform.
311 + /// Set or clear `cancel_at_period_end` on a platform-level subscription.
355 312 #[tracing::instrument(skip_all, name = "payments::set_platform_cancel_at_period_end")]
356 313 pub async fn set_platform_cancel_at_period_end(
357 314 &self,
358 315 stripe_sub_id: &str,
359 316 cancel: bool,
360 317 ) -> Result<()> {
361 - let url = format!("https://api.stripe.com/v1/subscriptions/{}", stripe_sub_id);
362 - let resp = reqwest::Client::new()
363 - .post(&url)
364 - .header("Authorization", format!("Bearer {}", self.config.secret_key))
365 - .form(&[("cancel_at_period_end", if cancel { "true" } else { "false" })])
366 - .timeout(std::time::Duration::from_secs(30))
367 - .send()
318 + let sub_id = parse_subscription_id(stripe_sub_id)?;
319 + UpdateSubscription::new(sub_id)
320 + .cancel_at_period_end(cancel)
321 + .send(&self.client)
368 322 .await
369 323 .map_err(|e| {
370 324 tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set platform cancel_at_period_end");
371 325 AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation"))
372 326 })?;
373 -
374 - if !resp.status().is_success() {
375 - let body = resp.text().await.unwrap_or_default();
376 - tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, body = %body, "Stripe set platform cancel_at_period_end returned error");
377 - return Err(AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation")));
378 - }
379 -
380 327 Ok(())
381 328 }
382 329
383 - /// Create a Stripe Billing Portal session for a customer. The returned URL
384 - /// is a Stripe-hosted page where the customer can update payment methods,
385 - /// view invoices, and (if portal config permits) cancel subscriptions.
386 - ///
387 - /// Requires Customer Portal to be configured in the Stripe dashboard.
388 - #[tracing::instrument(skip_all, name = "payments::create_billing_portal_session")]
389 - pub async fn create_billing_portal_session(
390 - &self,
Lines truncated
@@ -42,8 +42,17 @@ impl StripeClient {
42 42 config: config.clone(),
43 43 }
44 44 }
45 +
46 + /// Parse a connected account ID string into an `AccountId`.
47 + pub(crate) fn parse_account_id(account_id: &str) -> Result<stripe_shared::AccountId> {
48 + account_id.parse().map_err(|_| {
49 + AppError::BadRequest("Invalid Stripe account ID format".to_string())
50 + })
51 + }
45 52 }
46 53
54 + use crate::error::{AppError, Result};
55 +
47 56 /// Simplified checkout result — what handlers actually need from Stripe sessions.
48 57 pub struct CheckoutResult {
49 58 pub id: String,
@@ -95,7 +104,7 @@ pub trait PaymentProvider: Send + Sync {
95 104 async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()>;
96 105
97 106 // Webhooks
98 - fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<stripe::Event>;
107 + fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent>;
99 108 fn verify_webhook_v2(&self, payload: &str, signature: &str) -> crate::error::Result<serde_json::Value>;
100 109 }
101 110
@@ -162,13 +171,13 @@ impl PaymentProvider for StripeClient {
162 171 let available_cents: i64 = balance
163 172 .available
164 173 .iter()
165 - .filter(|b| b.currency == stripe::Currency::USD)
174 + .filter(|b| b.currency == stripe_types::Currency::USD)
166 175 .map(|b| b.amount)
167 176 .sum();
168 177 let pending_cents: i64 = balance
169 178 .pending
170 179 .iter()
171 - .filter(|b| b.currency == stripe::Currency::USD)
180 + .filter(|b| b.currency == stripe_types::Currency::USD)
172 181 .map(|b| b.amount)
173 182 .sum();
174 183 Ok(BalanceSummary { available_cents, pending_cents })
@@ -210,7 +219,7 @@ impl PaymentProvider for StripeClient {
210 219 StripeClient::create_refund(self, payment_intent_id, connected_account_id).await
211 220 }
212 221
213 - fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<stripe::Event> {
222 + fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent> {
214 223 StripeClient::verify_webhook(self, payload, signature)
215 224 }
216 225
@@ -1,31 +1,69 @@
1 - //! Webhook verification and event extraction.
1 + //! Webhook signature verification and event extraction.
2 + //!
3 + //! rc.5 ships no webhook helper, so we keep the local HMAC `verify_signature`
4 + //! and a thin `UntypedEvent` envelope. The webhook dispatcher matches on
5 + //! `type_` and consumes `data_object` (no per-extractor clones).
2 6
3 7 use hmac::{Hmac, Mac};
4 8 use sha2::Sha256;
5 - use stripe::{
6 - CheckoutSession, Event, EventObject, EventType, Webhook,
7 - };
9 +
8 10 use crate::db::Cents;
9 11 use crate::error::{AppError, Result};
10 12 use super::StripeClient;
11 13
12 14 type HmacSha256 = Hmac<Sha256>;
13 15
16 + /// A Stripe webhook envelope after signature verification and JSON parsing.
17 + ///
18 + /// `data_object` is the raw `data.object` JSON value, ready to be consumed
19 + /// by `serde_json::from_value` into a typed rc.5 struct.
20 + #[derive(Debug, Clone)]
21 + pub struct UntypedEvent {
22 + pub id: String,
23 + pub type_: String,
24 + pub data_object: serde_json::Value,
25 + }
26 +
27 + impl UntypedEvent {
28 + /// Parse a JSON webhook payload. Caller must verify the signature first.
29 + pub fn from_payload(payload: &str) -> Result<Self> {
30 + let mut v: serde_json::Value = serde_json::from_str(payload).map_err(|e| {
31 + tracing::warn!(error = ?e, "webhook envelope JSON parse failed");
32 + AppError::BadRequest("Invalid webhook payload".to_string())
33 + })?;
34 +
35 + let id = take_string(&mut v, "id")
36 + .ok_or_else(|| AppError::BadRequest("Webhook missing id".to_string()))?;
37 + let type_ = take_string(&mut v, "type")
38 + .ok_or_else(|| AppError::BadRequest("Webhook missing type".to_string()))?;
39 + let data_object = v.get_mut("data")
40 + .and_then(|d| d.get_mut("object"))
41 + .map(std::mem::take)
42 + .ok_or_else(|| AppError::BadRequest("Webhook missing data.object".to_string()))?;
43 +
44 + Ok(UntypedEvent { id, type_, data_object })
45 + }
46 + }
47 +
48 + fn take_string(v: &mut serde_json::Value, key: &str) -> Option<String> {
49 + v.get_mut(key).and_then(|s| match std::mem::take(s) {
50 + serde_json::Value::String(s) => Some(s),
51 + _ => None,
52 + })
53 + }
54 +
14 55 impl StripeClient {
15 - /// Verify and parse a webhook event
56 + /// Verify the webhook signature and return the parsed envelope.
16 57 #[tracing::instrument(skip_all, name = "payments::verify_webhook")]
17 - pub fn verify_webhook(&self, payload: &str, signature: &str) -> Result<Event> {
18 - Webhook::construct_event(payload, signature, &self.config.webhook_secret)
19 - .map_err(|e| {
20 - tracing::warn!(error = ?e, "webhook signature verification failed");
21 - AppError::BadRequest("Invalid webhook signature".to_string())
22 - })
58 + pub fn verify_webhook(&self, payload: &str, signature: &str) -> Result<UntypedEvent> {
59 + verify_signature(payload, signature, &self.config.webhook_secret).map_err(|e| {
60 + tracing::warn!(error = %e, "webhook signature verification failed");
61 + AppError::BadRequest("Invalid webhook signature".to_string())
62 + })?;
63 + UntypedEvent::from_payload(payload)
23 64 }
24 65
25 66 /// Verify a v2 thin event webhook and return the parsed JSON body.
26 - ///
27 - /// Returns `Err(503)` if the v2 secret is not configured, `Err(400)` on
28 - /// invalid signature.
29 67 #[tracing::instrument(skip_all, name = "payments::verify_webhook_v2")]
30 68 pub fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> {
31 69 let secret = self.config.webhook_secret_v2.as_deref().ok_or_else(|| {
@@ -44,117 +82,206 @@ impl StripeClient {
44 82 }
45 83 }
46 84
47 - /// Account update event data
48 - #[derive(Debug)]
49 - pub struct AccountUpdate {
50 - pub account_id: String,
51 - pub charges_enabled: bool,
52 - pub payouts_enabled: bool,
53 - pub details_submitted: bool,
85 + /// Narrow view of a CheckoutSession — only the fields any handler reads.
86 + ///
87 + /// Built ad-hoc rather than via `stripe_shared::CheckoutSession` to stay
88 + /// resilient against new required fields Stripe adds. The original migration
89 + /// bug was caused by an over-strict typed struct.
90 + #[derive(Debug, Default, serde::Deserialize)]
91 + pub struct CheckoutSessionView {
92 + pub id: String,
93 + #[serde(default)]
94 + pub metadata: Option<std::collections::HashMap<String, String>>,
95 + #[serde(default, deserialize_with = "deserialize_expandable_id")]
96 + pub payment_intent: Option<String>,
97 + #[serde(default, deserialize_with = "deserialize_expandable_id")]
98 + pub subscription: Option<String>,
99 + #[serde(default, deserialize_with = "deserialize_expandable_id")]
100 + pub customer: Option<String>,
101 + #[serde(default)]
102 + pub customer_details: Option<CheckoutCustomerDetailsView>,
54 103 }
55 104
56 - /// Extract relevant data from webhook events
57 - pub fn extract_checkout_completed(event: &Event) -> Option<&CheckoutSession> {
58 - if event.type_ == EventType::CheckoutSessionCompleted
59 - && let EventObject::CheckoutSession(session) = &event.data.object
60 - {
61 - return Some(session);
62 - }
63 - None
105 + #[derive(Debug, Default, serde::Deserialize)]
106 + pub struct CheckoutCustomerDetailsView {
107 + pub email: Option<String>,
64 108 }
65 109
66 - /// Extract a Subscription object from a customer.subscription.updated event
67 - pub fn extract_subscription_updated(event: &Event) -> Option<&stripe::Subscription> {
68 - if event.type_ == EventType::CustomerSubscriptionUpdated
69 - && let EventObject::Subscription(sub) = &event.data.object
70 - {
71 - return Some(sub);
72 - }
73 - None
110 + /// Narrow view of a Subscription — id, status, cancellation flag, and the
111 + /// item-level period fields rc.5 promoted from the top level.
112 + #[derive(Debug, serde::Deserialize)]
113 + pub struct SubscriptionView {
114 + pub id: String,
115 + pub status: String,
116 + #[serde(default)]
117 + pub cancel_at_period_end: bool,
118 + #[serde(default)]
119 + pub items: SubscriptionItemList,
74 120 }
75 121
76 - /// Extract a Subscription object from a customer.subscription.deleted event
77 - pub fn extract_subscription_deleted(event: &Event) -> Option<&stripe::Subscription> {
78 - if event.type_ == EventType::CustomerSubscriptionDeleted
79 - && let EventObject::Subscription(sub) = &event.data.object
80 - {
81 - return Some(sub);
122 + impl SubscriptionView {
123 + /// Period from `items.data[0]` (rc.5 moved these off the top-level Subscription).
124 + pub fn current_period(&self) -> Option<(i64, i64)> {
125 + self.items.data.first().map(|it| (it.current_period_start, it.current_period_end))
82 126 }
83 - None
84 127 }
85 128
86 - /// Extract an Invoice object from an invoice.payment_succeeded event
87 - pub fn extract_invoice_payment_succeeded(event: &Event) -> Option<&stripe::Invoice> {
88 - if event.type_ == EventType::InvoicePaymentSucceeded
89 - && let EventObject::Invoice(invoice) = &event.data.object
90 - {
91 - return Some(invoice);
129 + #[derive(Debug, Default, serde::Deserialize)]
130 + pub struct SubscriptionItemList {
131 + #[serde(default)]
132 + pub data: Vec<SubscriptionItemView>,
133 + }
134 +
135 + #[derive(Debug, serde::Deserialize)]
136 + pub struct SubscriptionItemView {
137 + #[serde(default)]
138 + pub current_period_start: i64,
139 + #[serde(default)]
140 + pub current_period_end: i64,
141 + }
142 +
143 + /// Narrow view of an Invoice — subscription id (via legacy `subscription` or
144 + /// the rc.5 `parent.subscription_details.subscription` path), period bounds,
145 + /// and billing reason.
146 + #[derive(Debug, serde::Deserialize)]
147 + pub struct InvoiceView {
148 + #[serde(default)]
149 + pub period_start: i64,
150 + #[serde(default)]
151 + pub period_end: i64,
152 + #[serde(default)]
153 + pub billing_reason: Option<String>,
154 + #[serde(default, deserialize_with = "deserialize_expandable_id")]
155 + pub subscription: Option<String>,
156 + #[serde(default)]
157 + pub parent: Option<InvoiceParentView>,
158 + }
159 +
160 + impl InvoiceView {
161 + /// Pull the subscription id from either the legacy or new field path.
162 + pub fn subscription_id(&self) -> Option<&str> {
163 + if let Some(s) = &self.subscription {
164 + return Some(s.as_str());
165 + }
166 + self.parent.as_ref()?
167 + .subscription_details.as_ref()?
168 + .subscription.as_deref()
92 169 }
93 - None
170 +
171 + pub fn is_renewal(&self) -> bool {
172 + self.billing_reason.as_deref() == Some("subscription_cycle")
173 + }
174 + }
175 +
176 + #[derive(Debug, serde::Deserialize)]
177 + pub struct InvoiceParentView {
178 + #[serde(default)]
179 + pub subscription_details: Option<InvoiceSubscriptionDetailsView>,
180 + }
181 +
182 + #[derive(Debug, serde::Deserialize)]
183 + pub struct InvoiceSubscriptionDetailsView {
184 + #[serde(default, deserialize_with = "deserialize_expandable_id")]
185 + pub subscription: Option<String>,
94 186 }
95 187
96 - /// Extract an Invoice object from an invoice.payment_failed event
97 - pub fn extract_invoice_payment_failed(event: &Event) -> Option<&stripe::Invoice> {
98 - if event.type_ == EventType::InvoicePaymentFailed
99 - && let EventObject::Invoice(invoice) = &event.data.object
100 - {
101 - return Some(invoice);
188 + /// Stripe expandable fields are either a bare id string or a full object with
189 + /// an `id` field. Pluck the id either way.
190 + fn deserialize_expandable_id<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
191 + where D: serde::Deserializer<'de> {
192 + use serde::Deserialize;
193 + let v = serde_json::Value::deserialize(deserializer)?;
194 + Ok(match v {
195 + serde_json::Value::Null => None,
196 + serde_json::Value::String(s) => Some(s),
197 + serde_json::Value::Object(mut map) => match map.remove("id") {
198 + Some(serde_json::Value::String(s)) => Some(s),
199 + _ => None,
200 + },
201 + _ => None,
202 + })
203 + }
204 +
205 + /// Account update fields the dispatcher hands to the handler.
206 + #[derive(Debug)]
207 + pub struct AccountUpdate {
208 + pub account_id: String,
209 + pub charges_enabled: bool,
210 + pub payouts_enabled: bool,
211 + pub details_submitted: bool,
212 + }
213 +
214 + impl From<stripe_shared::Account> for AccountUpdate {
215 + fn from(a: stripe_shared::Account) -> Self {
216 + AccountUpdate {
217 + account_id: a.id.to_string(),
218 + charges_enabled: a.charges_enabled.unwrap_or(false),
219 + payouts_enabled: a.payouts_enabled.unwrap_or(false),
220 + details_submitted: a.details_submitted.unwrap_or(false),
221 + }
102 222 }
103 - None
104 223 }
105 224
106 - /// Extract account update data from webhook event
107 - pub fn extract_account_updated(event: &Event) -> Option<AccountUpdate> {
108 - if event.type_ == EventType::AccountUpdated
109 - && let EventObject::Account(account) = &event.data.object
110 - {
111 - return Some(AccountUpdate {
112 - account_id: account.id.to_string(),
113 - charges_enabled: account.charges_enabled.unwrap_or(false),
114 - payouts_enabled: account.payouts_enabled.unwrap_or(false),
115 - details_submitted: account.details_submitted.unwrap_or(false),
116 - });
225 + /// Narrow view of an Account — only the fields we react to.
226 + #[derive(Debug, serde::Deserialize)]
227 + pub struct AccountView {
228 + pub id: String,
229 + #[serde(default)]
230 + pub charges_enabled: bool,
231 + #[serde(default)]
232 + pub payouts_enabled: bool,
233 + #[serde(default)]
234 + pub details_submitted: bool,
235 + }
236 +
237 + impl From<AccountView> for AccountUpdate {
238 + fn from(a: AccountView) -> Self {
239 + AccountUpdate {
240 + account_id: a.id,
241 + charges_enabled: a.charges_enabled,
242 + payouts_enabled: a.payouts_enabled,
243 + details_submitted: a.details_submitted,
244 + }
117 245 }
118 - None
246 + }
247 +
248 + /// Narrow view of a Charge for refund processing.
249 + #[derive(Debug, serde::Deserialize)]
250 + pub struct ChargeView {
251 + #[serde(default)]
252 + pub amount: i64,
253 + #[serde(default)]
254 + pub amount_refunded: i64,
255 + #[serde(default, deserialize_with = "deserialize_expandable_id")]
256 + pub payment_intent: Option<String>,
119 257 }
120 258
121 259 /// Data extracted from a charge.refunded webhook event.
122 260 #[derive(Debug)]
123 261 pub struct ChargeRefundData {
124 262 pub payment_intent_id: String,
125 - /// Total amount of the charge in cents.
126 263 pub amount: Cents,
127 - /// Amount refunded so far (cumulative) in cents.
128 264 pub amount_refunded: Cents,
129 265 }
130 266
131 267 impl ChargeRefundData {
132 - /// Returns true when the full charge amount has been refunded.
133 268 pub fn is_full_refund(&self) -> bool {
134 269 self.amount_refunded >= self.amount
135 270 }
136 - }
137 271
138 - /// Extract refund data from a charge.refunded webhook event.
139 - pub fn extract_charge_refunded(event: &Event) -> Option<ChargeRefundData> {
140 - if event.type_ == EventType::ChargeRefunded
141 - && let EventObject::Charge(charge) = &event.data.object
142 - {
143 - let payment_intent_id = charge
144 - .payment_intent
145 - .as_ref()
146 - .map(|pi| pi.id().to_string())?;
147 - return Some(ChargeRefundData {
148 - payment_intent_id,
272 + /// Build from a parsed charge view. Returns None when there is no
273 + /// payment_intent — these events are out of scope here.
274 + pub fn from_view(charge: ChargeView) -> Option<Self> {
275 + Some(ChargeRefundData {
276 + payment_intent_id: charge.payment_intent?,
149 277 amount: Cents::new(charge.amount),
150 278 amount_refunded: Cents::new(charge.amount_refunded),
151 - });
279 + })
152 280 }
153 - None
154 281 }
155 282
156 283 // ---------------------------------------------------------------------------
157 - // v2 thin event types + signature verification
284 + // v2 thin event types
158 285 // ---------------------------------------------------------------------------
159 286
160 287 /// A Stripe v2 "thin" event — contains only the event type and a reference to
@@ -175,16 +302,12 @@ pub struct RelatedObject {
175 302 pub object_type: String,
176 303 }
177 304
178 - /// Verify a Stripe webhook signature (v1 scheme, shared by both v1 and v2
179 - /// webhook endpoints).
305 + /// Verify a Stripe webhook signature (v1 scheme, shared by v1 and v2 endpoints).
180 306 ///
181 - /// Parses the `Stripe-Signature` header (`t={ts},v1={hex}`), computes
182 - /// HMAC-SHA256 over `{ts}.{payload}`, and compares in constant time.
183 - /// Rejects signatures with timestamps older than
184 - /// [`WEBHOOK_TIMESTAMP_TOLERANCE_SECS`](crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS)
185 - /// to prevent replay attacks.
307 + /// Parses `t={ts},v1={hex}`, computes HMAC-SHA256 over `{ts}.{payload}`, and
308 + /// compares in constant time. Rejects timestamps outside the configured
309 + /// tolerance to prevent replay attacks.
186 310 pub fn verify_signature(payload: &str, header: &str, secret: &str) -> std::result::Result<(), String> {
187 - // Parse header: "t=1234,v1=abcdef..."
188 311 let mut timestamp = None;
189 312 let mut signature = None;
190 313 for part in header.split(',') {
@@ -198,7 +321,6 @@ pub fn verify_signature(payload: &str, header: &str, secret: &str) -> std::resul
198 321 let timestamp = timestamp.ok_or("missing timestamp in signature header")?;
199 322 let expected_sig = signature.ok_or("missing v1 signature in header")?;
200 323
201 - // Reject stale timestamps to prevent replay attacks
202 324 let ts_secs: u64 = timestamp.parse().map_err(|_| "invalid timestamp")?;
203 325 let now_secs = std::time::SystemTime::now()
204 326 .duration_since(std::time::UNIX_EPOCH)
@@ -208,7 +330,6 @@ pub fn verify_signature(payload: &str, header: &str, secret: &str) -> std::resul
208 330 if now_secs > ts_secs && now_secs - ts_secs > tolerance {
209 331 return Err("timestamp too old".to_string());
210 332 }
211 - // Also reject timestamps too far in the future (clock skew protection)
212 333 if ts_secs > now_secs && ts_secs - now_secs > tolerance {
213 334 return Err("timestamp too far in the future".to_string());
214 335 }
@@ -222,8 +343,6 @@ pub fn verify_signature(payload: &str, header: &str, secret: &str) -> std::resul
222 343 .map_err(|_| "invalid HMAC key")?;
223 344 mac.update(signed_payload.as_bytes());
224 345
225 - // Constant-time comparison: verify_slice compares the raw HMAC bytes
226 - // using the `subtle` crate's ConstantTimeEq under the hood.
227 346 mac.verify_slice(&expected_bytes)
228 347 .map_err(|_| "signature mismatch".to_string())?;
229 348
@@ -235,155 +354,101 @@ mod tests {
235 354 use super::*;
236 355 use serde_json::json;
237 356
238 - /// Build a minimal Event with the given type and inner object.
239 - #[allow(clippy::field_reassign_with_default)]
240 - fn make_event(event_type: EventType, object: EventObject) -> Event {
241 - let mut event = Event::default();
242 - event.type_ = event_type;
243 - event.data.object = object;
244 - event
245 - }
246 -
247 - // --- extract_checkout_completed ---
248 -
249 - #[test]
250 - fn extract_checkout_completed_valid() {
251 - let event = make_event(
252 - EventType::CheckoutSessionCompleted,
253 - EventObject::CheckoutSession(CheckoutSession::default()),
254 - );
255 - assert!(extract_checkout_completed(&event).is_some());
256 - }
257 -
258 357 #[test]
259 - fn extract_checkout_completed_wrong_type() {
260 - let event = make_event(
261 - EventType::AccountUpdated,
262 - EventObject::CheckoutSession(CheckoutSession::default()),
263 - );
264 - assert!(extract_checkout_completed(&event).is_none());
358 + fn parse_envelope_extracts_id_type_and_object() {
359 + let payload = r#"{"id":"evt_1","type":"checkout.session.completed","data":{"object":{"id":"cs_1"}}}"#;
360 + let evt = UntypedEvent::from_payload(payload).unwrap();
361 + assert_eq!(evt.id, "evt_1");
362 + assert_eq!(evt.type_, "checkout.session.completed");
363 + assert_eq!(evt.data_object["id"], "cs_1");
265 364 }
266 365
267 366 #[test]
268 - fn extract_checkout_completed_wrong_object() {
269 - let event = make_event(
270 - EventType::CheckoutSessionCompleted,
271 - EventObject::Account(stripe::Account::default()),
272 - );
273 - assert!(extract_checkout_completed(&event).is_none());
274 - }
275 -
276 - // --- extract_account_updated ---
277 -
278 - #[test]
279 - fn extract_account_updated_valid() {
280 - let account: stripe::Account = serde_json::from_value(json!({
281 - "id": "acct_test123",
282 - "charges_enabled": true,
283 - "payouts_enabled": true,
284 - "details_submitted": true
285 - }))
286 - .unwrap();
287 - let event = make_event(EventType::AccountUpdated, EventObject::Account(account));
288 - let update = extract_account_updated(&event).unwrap();
289 - assert_eq!(update.account_id, "acct_test123");
290 - assert!(update.charges_enabled);
291 - assert!(update.payouts_enabled);
292 - assert!(update.details_submitted);
367 + fn parse_envelope_missing_data_object_errors() {
368 + assert!(UntypedEvent::from_payload(r#"{"id":"x","type":"y"}"#).is_err());
293 369 }
294 370
371 + // CheckoutSession parses from a real captured webhook fixture.
295 372 #[test]
296 - fn extract_account_updated_defaults() {
297 - let account: stripe::Account =
298 - serde_json::from_value(json!({"id": "acct_defaults"})).unwrap();
299 - let event = make_event(EventType::AccountUpdated, EventObject::Account(account));
300 - let update = extract_account_updated(&event).unwrap();
301 - assert_eq!(update.account_id, "acct_defaults");
302 - assert!(!update.charges_enabled);
303 - assert!(!update.payouts_enabled);
304 - assert!(!update.details_submitted);
373 + fn checkout_session_parses_from_fixture() {
374 + let raw = include_str!("../../tests/fixtures/webhooks/checkout.session.completed.connect.json");
375 + let evt = UntypedEvent::from_payload(raw).unwrap();
376 + let session: stripe_shared::CheckoutSession =
377 + serde_json::from_value(evt.data_object).unwrap();
378 + assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment);
305 379 }
306 380
381 + // Subscription parses with current_period_* on items.data[0].
307 382 #[test]
308 - fn extract_account_updated_wrong_type() {
309 - let event = make_event(
310 - EventType::CheckoutSessionCompleted,
311 - EventObject::Account(stripe::Account::default()),
312 - );
313 - assert!(extract_account_updated(&event).is_none());
Lines truncated
@@ -10,36 +10,28 @@ use crate::{
10 10 /// Handle invoice.payment_succeeded — update period, send renewal email (not first invoice)
11 11 pub(super) async fn handle_invoice_payment_succeeded(
12 12 state: &AppState,
13 - invoice: &stripe::Invoice,
13 + invoice: &crate::payments::InvoiceView,
14 14 event_id: &str,
15 15 ) -> Result<()> {
16 - let stripe_sub_id = match &invoice.subscription {
17 - Some(sub) => sub.id().to_string(),
16 + let stripe_sub_id = match invoice.subscription_id() {
17 + Some(s) => s.to_string(),
18 18 None => return Ok(()), // Not a subscription invoice
19 19 };
20 20
21 21 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing invoice payment succeeded");
22 22
23 - let is_renewal = invoice.billing_reason
24 - .as_ref()
25 - .is_some_and(|r| r.as_str() == "subscription_cycle");
23 + let is_renewal = invoice.is_renewal();
26 24
27 25 // Check if this is a Fan+ subscription
28 26 if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan+ by stripe id")? {
29 27 // Update period
30 - if let (Some(start), Some(end)) = (invoice.period_start, invoice.period_end) {
31 - let period_start = stripe_timestamp(start);
32 - let period_end = stripe_timestamp(end);
33 - db::fan_plus::update_fan_plus_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update fan+ period")?;
34 - }
28 + let period_start = stripe_timestamp(invoice.period_start);
29 + let period_end = stripe_timestamp(invoice.period_end);
30 + db::fan_plus::update_fan_plus_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update fan+ period")?;
35 31
36 32 // On renewal, generate a $5 platform-wide promo code and email it
37 33 if is_renewal {
38 - let period_end = if let (Some(_), Some(end)) = (invoice.period_start, invoice.period_end) {
39 - chrono::DateTime::from_timestamp(end, 0)
40 - } else {
41 - None
42 - };
34 + let period_end = chrono::DateTime::from_timestamp(invoice.period_end, 0);
43 35
44 36 let code = helpers::generate_key_code();
45 37 match db::promo_codes::create_platform_promo_code(
@@ -105,11 +97,9 @@ pub(super) async fn handle_invoice_payment_succeeded(
105 97
106 98 // Check if this is a creator tier subscription
107 99 if let Some(_ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch creator sub by stripe id")? {
108 - if let (Some(start), Some(end)) = (invoice.period_start, invoice.period_end) {
109 - let period_start = stripe_timestamp(start);
110 - let period_end = stripe_timestamp(end);
111 - db::creator_tiers::update_creator_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update creator sub period")?;
112 - }
100 + let period_start = stripe_timestamp(invoice.period_start);
101 + let period_end = stripe_timestamp(invoice.period_end);
102 + db::creator_tiers::update_creator_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update creator sub period")?;
113 103
114 104 if let Err(e) = db::subscriptions::log_subscription_event(
115 105 &state.db, None, event_id, "invoice.payment_succeeded.creator_tier",
@@ -122,11 +112,9 @@ pub(super) async fn handle_invoice_payment_succeeded(
122 112
123 113 // Check if this is an app sync subscription
124 114 if let Some(_app_sub) = db::app_sync::get_app_sync_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch app sync sub by stripe id")? {
125 - if let (Some(start), Some(end)) = (invoice.period_start, invoice.period_end) {
126 - let period_start = stripe_timestamp(start);
127 - let period_end = stripe_timestamp(end);
128 - db::app_sync::update_app_sync_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update app sync sub period")?;
129 - }
115 + let period_start = stripe_timestamp(invoice.period_start);
116 + let period_end = stripe_timestamp(invoice.period_end);
117 + db::app_sync::update_app_sync_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update app sync sub period")?;
130 118
131 119 if let Err(e) = db::subscriptions::log_subscription_event(
132 120 &state.db, None, event_id, "invoice.payment_succeeded.app_sync",
@@ -138,11 +126,9 @@ pub(super) async fn handle_invoice_payment_succeeded(
138 126 }
139 127
140 128 // Update period for creator subscriptions
141 - if let (Some(start), Some(end)) = (invoice.period_start, invoice.period_end) {
142 - let period_start = stripe_timestamp(start);
143 - let period_end = stripe_timestamp(end);
144 - db::subscriptions::update_subscription_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update subscription period")?;
145 - }
129 + let period_start = stripe_timestamp(invoice.period_start);
130 + let period_end = stripe_timestamp(invoice.period_end);
131 + db::subscriptions::update_subscription_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update subscription period")?;
146 132
147 133 // Send renewal email only for renewals (not the first invoice)
148 134 let db_sub = db::subscriptions::get_subscription_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch subscription by stripe id")?;
@@ -183,11 +169,11 @@ pub(super) async fn handle_invoice_payment_succeeded(
183 169 /// Handle invoice.payment_failed — set status to past_due
184 170 pub(super) async fn handle_invoice_payment_failed(
185 171 state: &AppState,
186 - invoice: &stripe::Invoice,
172 + invoice: &crate::payments::InvoiceView,
187 173 event_id: &str,
188 174 ) -> Result<()> {
189 - let stripe_sub_id = match &invoice.subscription {
190 - Some(sub) => sub.id().to_string(),
175 + let stripe_sub_id = match invoice.subscription_id() {
176 + Some(s) => s.to_string(),
191 177 None => return Ok(()), // Not a subscription invoice
192 178 };
193 179
@@ -18,15 +18,15 @@ use super::checkout_helpers::{
18 18 #[tracing::instrument(skip_all, name = "stripe::handle_purchase_checkout")]
19 19 pub(super) async fn handle_purchase_checkout_completed(
20 20 state: &AppState,
21 - session: &stripe::CheckoutSession,
21 + session: &crate::payments::CheckoutSessionView,
22 22 event_id: &str,
23 23 ) -> Result<()> {
24 - let session_id = session.id.to_string();
24 + let session_id = session.id.clone();
25 25
26 26 tracing::info!(session_id = %session_id, "processing completed purchase checkout");
27 27
28 28 // Extract metadata (already typed IDs from CheckoutMetadata)
29 - let raw_metadata = CheckoutMetadata::from_session(session)?;
29 + let raw_metadata = CheckoutMetadata::from_metadata(session.metadata.as_ref())?;
30 30 let buyer_id = raw_metadata.buyer_id;
31 31 let seller_id = raw_metadata.seller_id;
32 32 let item_id = raw_metadata.item_id;
@@ -35,10 +35,7 @@ pub(super) async fn handle_purchase_checkout_completed(
35 35 let item_id_display = item_id.map(|id| id.to_string()).unwrap_or_else(|| "project".to_string());
36 36
37 37 // Get the payment intent ID
38 - let payment_intent_id = session.payment_intent
39 - .as_ref()
40 - .map(|pi| pi.id().to_string())
41 - .unwrap_or_else(|| "unknown".to_string());
38 + let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
42 39
43 40 // Complete the transaction (idempotent - returns None if already completed).
44 41 // Steps 1-3 (complete_transaction, increment_sales_count, discount code increment)
@@ -117,20 +114,17 @@ pub(super) async fn handle_purchase_checkout_completed(
117 114 #[tracing::instrument(skip_all, name = "stripe::handle_cart_checkout")]
118 115 pub(super) async fn handle_cart_checkout_completed(
119 116 state: &AppState,
120 - session: &stripe::CheckoutSession,
117 + session: &crate::payments::CheckoutSessionView,
121 118 event_id: &str,
122 119 ) -> Result<()> {
123 - let session_id = session.id.to_string();
120 + let session_id = session.id.clone();
124 121 tracing::info!(session_id = %session_id, "processing completed cart checkout");
125 122
126 - let meta = crate::payments::CartCheckoutMetadata::from_session(session)?;
123 + let meta = crate::payments::CartCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
127 124 let buyer_id = meta.buyer_id;
128 125 let seller_id = meta.seller_id;
129 126
130 - let payment_intent_id = session.payment_intent
131 - .as_ref()
132 - .map(|pi| pi.id().to_string())
133 - .unwrap_or_else(|| "unknown".to_string());
127 + let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
134 128
135 129 // Complete ALL pending transactions for this session in a single DB transaction
136 130 let mut db_tx = state.db.begin().await.context("begin cart webhook transaction")?;
@@ -223,31 +217,27 @@ pub(super) async fn handle_cart_checkout_completed(
223 217 #[tracing::instrument(skip_all, name = "stripe::handle_subscription_checkout")]
224 218 pub(super) async fn handle_subscription_checkout_completed(
225 219 state: &AppState,
226 - session: &stripe::CheckoutSession,
220 + session: &crate::payments::CheckoutSessionView,
227 221 event_id: &str,
228 222 ) -> Result<()> {
229 - let session_id = session.id.to_string();
223 + let session_id = session.id.clone();
230 224 tracing::info!(session_id = %session_id, "processing completed subscription checkout");
231 225
232 226 // Extract subscription-specific metadata (already typed IDs)
233 - let raw_metadata = SubscriptionCheckoutMetadata::from_session(session)?;
227 + let raw_metadata = SubscriptionCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
234 228 let subscriber_id = raw_metadata.subscriber_id;
235 229 let project_id = raw_metadata.project_id;
236 230 let tier_id = raw_metadata.tier_id;
237 231
238 232 // Get the Stripe subscription ID from the session
239 - let stripe_subscription_id = session.subscription
240 - .as_ref()
241 - .map(|s| s.id().to_string())
233 + let stripe_subscription_id = session.subscription.clone()
242 234 .ok_or_else(|| {
243 235 tracing::error!("Subscription checkout completed but no subscription ID on session");
244 236 AppError::BadRequest("Missing subscription ID on session".to_string())
245 237 })?;
246 238
247 239 // Get the Stripe customer ID from the session
248 - let stripe_customer_id = session.customer
249 - .as_ref()
250 - .map(|c| c.id().to_string())
240 + let stripe_customer_id = session.customer.clone()
251 241 .ok_or_else(|| {
252 242 tracing::error!("Subscription checkout completed but no customer ID on session");
253 243 AppError::BadRequest("Missing customer ID on session".to_string())
@@ -328,28 +318,24 @@ pub(super) async fn handle_subscription_checkout_completed(
328 318 #[tracing::instrument(skip_all, name = "stripe::handle_fan_plus_checkout")]
329 319 pub(super) async fn handle_fan_plus_checkout_completed(
330 320 state: &AppState,
331 - session: &stripe::CheckoutSession,
321 + session: &crate::payments::CheckoutSessionView,
332 322 event_id: &str,
333 323 ) -> Result<()> {
334 - let session_id = session.id.to_string();
324 + let session_id = session.id.clone();
335 325 tracing::info!(session_id = %session_id, "processing completed Fan+ checkout");
336 326
337 - let metadata = FanPlusCheckoutMetadata::from_session(session)?;
327 + let metadata = FanPlusCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
338 328 let user_id = metadata.user_id;
339 329
340 330 // Get the Stripe subscription ID from the session
341 - let stripe_subscription_id = session.subscription
342 - .as_ref()
343 - .map(|s| s.id().to_string())
331 + let stripe_subscription_id = session.subscription.clone()
344 332 .ok_or_else(|| {
345 333 tracing::error!("Fan+ checkout completed but no subscription ID on session");
346 334 AppError::BadRequest("Missing subscription ID on session".to_string())
347 335 })?;
348 336
349 337 // Get the Stripe customer ID from the session
350 - let stripe_customer_id = session.customer
351 - .as_ref()
352 - .map(|c| c.id().to_string())
338 + let stripe_customer_id = session.customer.clone()
353 339 .ok_or_else(|| {
354 340 tracing::error!("Fan+ checkout completed but no customer ID on session");
355 341 AppError::BadRequest("Missing customer ID on session".to_string())
@@ -396,30 +382,26 @@ pub(super) async fn handle_fan_plus_checkout_completed(
396 382 #[tracing::instrument(skip_all, name = "stripe::handle_creator_tier_checkout")]
397 383 pub(super) async fn handle_creator_tier_checkout_completed(
398 384 state: &AppState,
399 - session: &stripe::CheckoutSession,
385 + session: &crate::payments::CheckoutSessionView,
400 386 event_id: &str,
401 387 ) -> Result<()> {
402 - let session_id = session.id.to_string();
388 + let session_id = session.id.clone();
403 389 tracing::info!(session_id = %session_id, "processing completed creator tier checkout");
404 390
405 - let metadata = CreatorTierCheckoutMetadata::from_session(session)?;
391 + let metadata = CreatorTierCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
406 392 let user_id = metadata.user_id;
407 393 let tier: db::CreatorTier = metadata.tier.parse()
408 394 .map_err(|_| AppError::BadRequest(format!("Invalid tier: {}", metadata.tier)))?;
409 395
410 396 // Get the Stripe subscription ID from the session
411 - let stripe_subscription_id = session.subscription
412 - .as_ref()
413 - .map(|s| s.id().to_string())
397 + let stripe_subscription_id = session.subscription.clone()
414 398 .ok_or_else(|| {
415 399 tracing::error!("Creator tier checkout completed but no subscription ID on session");
416 400 AppError::BadRequest("Missing subscription ID on session".to_string())
417 401 })?;
418 402
419 403 // Get the Stripe customer ID from the session
420 - let stripe_customer_id = session.customer
421 - .as_ref()
422 - .map(|c| c.id().to_string())
404 + let stripe_customer_id = session.customer.clone()
423 405 .ok_or_else(|| {
424 406 tracing::error!("Creator tier checkout completed but no customer ID on session");
425 407 AppError::BadRequest("Missing customer ID on session".to_string())
@@ -509,29 +491,25 @@ pub(super) async fn handle_creator_tier_checkout_completed(
509 491 #[tracing::instrument(skip_all, name = "stripe::handle_app_sync_checkout")]
510 492 pub(super) async fn handle_app_sync_checkout_completed(
511 493 state: &AppState,
512 - session: &stripe::CheckoutSession,
494 + session: &crate::payments::CheckoutSessionView,
513 495 event_id: &str,
514 496 ) -> Result<()> {
515 - let session_id = session.id.to_string();
497 + let session_id = session.id.clone();
516 498 tracing::info!(session_id = %session_id, "processing completed app sync checkout");
517 499
518 - let metadata = crate::payments::AppSyncCheckoutMetadata::from_session(session)?;
500 + let metadata = crate::payments::AppSyncCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
519 501 let user_id = metadata.user_id;
520 502 let app_id = metadata.app_id;
521 503 let tier: db::AppSyncTier = metadata.tier.parse()
522 504 .map_err(|_| AppError::BadRequest(format!("Invalid tier: {}", metadata.tier)))?;
523 505
524 - let stripe_subscription_id = session.subscription
525 - .as_ref()
526 - .map(|s| s.id().to_string())
506 + let stripe_subscription_id = session.subscription.clone()
527 507 .ok_or_else(|| {
528 508 tracing::error!("App sync checkout completed but no subscription ID on session");
529 509 AppError::BadRequest("Missing subscription ID on session".to_string())
530 510 })?;
531 511
532 - let stripe_customer_id = session.customer
533 - .as_ref()
534 - .map(|c| c.id().to_string())
512 + let stripe_customer_id = session.customer.clone()
535 513 .ok_or_else(|| {
536 514 tracing::error!("App sync checkout completed but no customer ID on session");
537 515 AppError::BadRequest("Missing customer ID on session".to_string())
@@ -577,20 +555,17 @@ pub(super) async fn handle_app_sync_checkout_completed(
577 555 #[tracing::instrument(skip_all, name = "stripe::handle_tip_checkout")]
578 556 pub(super) async fn handle_tip_checkout_completed(
579 557 state: &AppState,
580 - session: &stripe::CheckoutSession,
558 + session: &crate::payments::CheckoutSessionView,
581 559 _event_id: &str,
582 560 ) -> Result<()> {
583 - let session_id = session.id.to_string();
561 + let session_id = session.id.clone();
584 562 tracing::info!(session_id = %session_id, "processing completed tip checkout");
585 563
586 - let metadata = TipCheckoutMetadata::from_session(session)?;
564 + let metadata = TipCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
587 565 let tipper_id = metadata.tipper_id;
588 566 let recipient_id = metadata.recipient_id;
589 567
590 - let payment_intent_id = session.payment_intent
591 - .as_ref()
592 - .map(|pi| pi.id().to_string())
593 - .unwrap_or_else(|| "unknown".to_string());
568 + let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
594 569
595 570 // Complete the tip (idempotent)
596 571 match db::tips::complete_tip(&state.db, &session_id, &payment_intent_id)
@@ -625,15 +600,15 @@ pub(super) async fn handle_tip_checkout_completed(
625 600 #[tracing::instrument(skip_all, name = "stripe::handle_guest_checkout")]
626 601 pub(super) async fn handle_guest_checkout_completed(
627 602 state: &AppState,
628 - session: &stripe::CheckoutSession,
603 + session: &crate::payments::CheckoutSessionView,
629 604 _event_id: &str,
630 605 ) -> Result<()> {
631 606 use crate::payments::GuestCheckoutMetadata;
632 607
633 - let session_id = session.id.to_string();
608 + let session_id = session.id.clone();
634 609 tracing::info!(session_id = %session_id, "processing completed guest checkout");
635 610
636 - let meta = GuestCheckoutMetadata::from_session(session)?;
611 + let meta = GuestCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
637 612
638 613 // Extract buyer email from Stripe customer_details
639 614 let guest_email = session.customer_details.as_ref()
@@ -641,10 +616,7 @@ pub(super) async fn handle_guest_checkout_completed(
641 616 .unwrap_or("unknown@guest")
642 617 .to_string();
643 618
644 - let payment_intent_id = session.payment_intent
645 - .as_ref()
646 - .map(|pi| pi.id().to_string())
647 - .unwrap_or_else(|| "unknown".to_string());
619 + let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
648 620
649 621 // Complete the guest transaction and increment sales count in a single DB transaction
650 622 // (matching the non-guest path pattern to prevent counter drift on partial failure)
@@ -15,7 +15,10 @@ use axum::{
15 15 use crate::{
16 16 db,
17 17 error::{AppError, Result, ResultExt},
18 - payments::{self, AccountUpdate},
18 + payments::{
19 + self, AccountUpdate, AccountView, ChargeRefundData, ChargeView,
20 + CheckoutSessionView, InvoiceView, SubscriptionView, UntypedEvent,
21 + },
19 22 AppState,
20 23 };
21 24
@@ -40,36 +43,33 @@ pub(in crate::routes::stripe) async fn webhook(
40 43 .map_err(|_| AppError::BadRequest("Invalid payload encoding".to_string()))?;
41 44
42 45 let event = stripe.verify_webhook(payload, signature)?;
43 -
44 - let event_id = event.id.to_string();
45 - let event_type_str = format!("{:?}", event.type_);
46 - tracing::info!(event_type = %event_type_str, event_id = %event_id, "received webhook event");
46 + tracing::info!(event_type = %event.type_, event_id = %event.id, "received webhook event");
47 47
48 48 // Deduplicate: skip if we already processed this event ID
49 - match db::webhook_events::try_mark_event_processed(&state.db, &event_id).await {
49 + match db::webhook_events::try_mark_event_processed(&state.db, &event.id).await {
50 50 Ok(false) => {
51 - tracing::info!(event_id = %event_id, "duplicate webhook event, skipping");
51 + tracing::info!(event_id = %event.id, "duplicate webhook event, skipping");
52 52 return Ok(StatusCode::OK);
53 53 }
54 54 Err(e) => {
55 55 // Dedup check failed — return 503 so Stripe retries later.
56 56 // Processing without dedup risks double-credit or double-refund.
57 - tracing::error!(event_id = %event_id, error = ?e, "webhook dedup check failed, returning 503 for retry");
57 + tracing::error!(event_id = %event.id, error = ?e, "webhook dedup check failed, returning 503 for retry");
58 58 return Ok(StatusCode::SERVICE_UNAVAILABLE);
59 59 }
60 60 Ok(true) => {} // First time seeing this event
61 61 }
62 62
63 - // Handle the event — on failure, persist to retry queue and return 200
64 - // so Stripe doesn't double-retry (our queue handles retries with backoff).
65 - let result = process_webhook_event(&state, &event, &event_id).await;
63 + // For retry-queue persistence we need id+type after `event` is consumed.
64 + // Move both out without cloning the underlying allocations.
65 + let UntypedEvent { id: event_id, type_: event_type_str, data_object } = event;
66 + let result = process_webhook_event(&state, &event_type_str, &event_id, data_object).await;
66 67
67 68 if let Err(ref e) = result {
68 69 tracing::error!(
69 70 event_id = %event_id, event_type = %event_type_str,
70 71 error = ?e, "webhook handler failed, queueing for retry"
71 72 );
72 - // Persist to retry queue (best-effort — don't fail the webhook response)
73 73 if let Err(queue_err) = db::webhook_events::insert_failed_event(
74 74 &state.db,
75 75 "stripe",
@@ -88,65 +88,72 @@ pub(in crate::routes::stripe) async fn webhook(
88 88 /// Process a verified Stripe webhook event. Extracted to allow the caller
89 89 /// to catch errors and persist to the retry queue. Also called by the
90 90 /// scheduler's webhook retry worker.
91 + /// Dispatch a verified Stripe webhook event. Consumes `data_object` exactly
92 + /// once into a typed rc.5 struct based on `event_type`. Shared by the live
93 + /// webhook handler and the scheduler retry worker.
91 94 pub(crate) async fn process_webhook_event(
92 95 state: &AppState,
93 - event: &stripe::Event,
96 + event_type: &str,
94 97 event_id: &str,
98 + data_object: serde_json::Value,
95 99 ) -> Result<()> {
96 - match event.type_ {
97 - stripe::EventType::CheckoutSessionCompleted => {
98 - if let Some(session) = payments::extract_checkout_completed(event) {
99 - if payments::is_fan_plus_checkout(session) {
100 - checkout::handle_fan_plus_checkout_completed(state, session, event_id).await?;
101 - } else if payments::is_creator_tier_checkout(session) {
102 - checkout::handle_creator_tier_checkout_completed(state, session, event_id).await?;
103 - } else if payments::is_app_sync_checkout(session) {
104 - checkout::handle_app_sync_checkout_completed(state, session, event_id).await?;
105 - } else if payments::is_tip_checkout(session) {
106 - checkout::handle_tip_checkout_completed(state, session, event_id).await?;
107 - } else if payments::is_subscription_checkout(session) {
108 - checkout::handle_subscription_checkout_completed(state, session, event_id).await?;
109 - } else if payments::is_guest_checkout(session) {
110 - checkout::handle_guest_checkout_completed(state, session, event_id).await?;
111 - } else if payments::is_cart_checkout(session) {
112 - checkout::handle_cart_checkout_completed(state, session, event_id).await?;
113 - } else {
114 - checkout::handle_purchase_checkout_completed(state, session, event_id).await?;
115 - }
100 + match event_type {
101 + "checkout.session.completed" => {
102 + let session: CheckoutSessionView = serde_json::from_value(data_object)
103 + .map_err(|e| AppError::BadRequest(format!("Failed to parse CheckoutSession: {e}")))?;
104 + let meta = session.metadata.as_ref();
105 + if payments::is_fan_plus_checkout(meta) {
106 + checkout::handle_fan_plus_checkout_completed(state, &session, event_id).await?;
107 + } else if payments::is_creator_tier_checkout(meta) {
108 + checkout::handle_creator_tier_checkout_completed(state, &session, event_id).await?;
109 + } else if payments::is_app_sync_checkout(meta) {
110 + checkout::handle_app_sync_checkout_completed(state, &session, event_id).await?;
111 + } else if payments::is_tip_checkout(meta) {
112 + checkout::handle_tip_checkout_completed(state, &session, event_id).await?;
113 + } else if payments::is_subscription_checkout(meta) {
114 + checkout::handle_subscription_checkout_completed(state, &session, event_id).await?;
115 + } else if payments::is_guest_checkout(meta) {
116 + checkout::handle_guest_checkout_completed(state, &session, event_id).await?;
117 + } else if payments::is_cart_checkout(meta) {
118 + checkout::handle_cart_checkout_completed(state, &session, event_id).await?;
119 + } else {
120 + checkout::handle_purchase_checkout_completed(state, &session, event_id).await?;
116 121 }
117 122 }
118 - stripe::EventType::AccountUpdated => {
119 - if let Some(update) = payments::extract_account_updated(event) {
120 - handle_account_updated(state, &update).await?;
121 - }
123 + "account.updated" => {
124 + let account: AccountView = serde_json::from_value(data_object)
125 + .map_err(|e| AppError::BadRequest(format!("Failed to parse Account: {e}")))?;
126 + handle_account_updated(state, &AccountUpdate::from(account)).await?;
122 127 }
123 - stripe::EventType::ChargeRefunded => {
124 - if let Some(refund_data) = payments::extract_charge_refunded(event) {
128 + "charge.refunded" => {
129 + let charge: ChargeView = serde_json::from_value(data_object)
130 + .map_err(|e| AppError::BadRequest(format!("Failed to parse Charge: {e}")))?;
131 + if let Some(refund_data) = ChargeRefundData::from_view(charge) {
125 132 billing::handle_charge_refunded(state, &refund_data).await?;
126 133 }
127 134 }
128 - stripe::EventType::CustomerSubscriptionUpdated => {
129 - if let Some(sub) = payments::extract_subscription_updated(event) {
130 - subscriptions::handle_subscription_updated(state, sub, event_id).await?;
131 - }
135 + "customer.subscription.updated" => {
136 + let sub: SubscriptionView = serde_json::from_value(data_object)
137 + .map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?;
138 + subscriptions::handle_subscription_updated(state, &sub, event_id).await?;
132 139 }
133 - stripe::EventType::CustomerSubscriptionDeleted => {
134 - if let Some(sub) = payments::extract_subscription_deleted(event) {
135 - subscriptions::handle_subscription_deleted(state, sub, event_id).await?;
136 - }
140 + "customer.subscription.deleted" => {
141 + let sub: SubscriptionView = serde_json::from_value(data_object)
142 + .map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?;
143 + subscriptions::handle_subscription_deleted(state, &sub, event_id).await?;
137 144 }
138 - stripe::EventType::InvoicePaymentSucceeded => {
139 - if let Some(invoice) = payments::extract_invoice_payment_succeeded(event) {
140 - billing::handle_invoice_payment_succeeded(state, invoice, event_id).await?;
141 - }
145 + "invoice.payment_succeeded" => {
146 + let invoice: InvoiceView = serde_json::from_value(data_object)
147 + .map_err(|e| AppError::BadRequest(format!("Failed to parse Invoice: {e}")))?;
148 + billing::handle_invoice_payment_succeeded(state, &invoice, event_id).await?;
142 149 }
143 - stripe::EventType::InvoicePaymentFailed => {
144 - if let Some(invoice) = payments::extract_invoice_payment_failed(event) {
145 - billing::handle_invoice_payment_failed(state, invoice, event_id).await?;
146 - }
150 + "invoice.payment_failed" => {
151 + let invoice: InvoiceView = serde_json::from_value(data_object)
152 + .map_err(|e| AppError::BadRequest(format!("Failed to parse Invoice: {e}")))?;
153 + billing::handle_invoice_payment_failed(state, &invoice, event_id).await?;
147 154 }
148 - _ => {
149 - tracing::debug!(event_type = ?event.type_, "unhandled webhook event type");
155 + other => {
156 + tracing::debug!(event_type = %other, "unhandled webhook event type");
150 157 }
151 158 }
152 159
@@ -10,10 +10,10 @@ use crate::{
10 10 /// Handle customer.subscription.updated — update status + period
11 11 pub(super) async fn handle_subscription_updated(
12 12 state: &AppState,
13 - sub: &stripe::Subscription,
13 + sub: &crate::payments::SubscriptionView,
14 14 event_id: &str,
15 15 ) -> Result<()> {
16 - let stripe_sub_id = sub.id.to_string();
16 + let stripe_sub_id = sub.id.clone();
17 17 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription updated");
18 18
19 19 // Check if this is a Fan+ subscription
@@ -23,8 +23,9 @@ pub(super) async fn handle_subscription_updated(
23 23 .map_err(|_| AppError::BadRequest(format!("Unknown subscription status: {}", status_str)))?;
24 24 db::fan_plus::update_fan_plus_status(&state.db, &stripe_sub_id, status).await.context("update fan plus status")?;
25 25
26 - let period_start = stripe_timestamp(sub.current_period_start);
27 - let period_end = stripe_timestamp(sub.current_period_end);
26 + let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
27 + let period_start = stripe_timestamp(start_ts);
28 + let period_end = stripe_timestamp(end_ts);
28 29 db::fan_plus::update_fan_plus_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update fan plus period")?;
29 30
30 31 // Keep the dashboard flag in sync with Stripe — covers cancellation
@@ -49,8 +50,9 @@ pub(super) async fn handle_subscription_updated(
49 50 .map_err(|_| AppError::BadRequest(format!("Unknown subscription status: {}", status_str)))?;
50 51 db::creator_tiers::update_creator_sub_status(&state.db, &stripe_sub_id, status).await.context("update creator sub status")?;
51 52
52 - let period_start = stripe_timestamp(sub.current_period_start);
53 - let period_end = stripe_timestamp(sub.current_period_end);
53 + let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
54 + let period_start = stripe_timestamp(start_ts);
55 + let period_end = stripe_timestamp(end_ts);
54 56 db::creator_tiers::update_creator_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update creator sub period")?;
55 57
56 58 // Sync the denormalized creator_tier column on users
@@ -72,8 +74,9 @@ pub(super) async fn handle_subscription_updated(
72 74 .map_err(|_| AppError::BadRequest(format!("Unknown subscription status: {}", status_str)))?;
73 75 db::app_sync::update_app_sync_sub_status(&state.db, &stripe_sub_id, status).await.context("update app sync sub status")?;
74 76
75 - let period_start = stripe_timestamp(sub.current_period_start);
76 - let period_end = stripe_timestamp(sub.current_period_end);
77 + let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
78 + let period_start = stripe_timestamp(start_ts);
79 + let period_end = stripe_timestamp(end_ts);
77 80 db::app_sync::update_app_sync_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update app sync sub period")?;
78 81
79 82 if let Err(e) = db::subscriptions::log_subscription_event(
@@ -96,8 +99,9 @@ pub(super) async fn handle_subscription_updated(
96 99 let mut tx = state.db.begin().await.context("begin subscription update transaction")?;
97 100 let updated = db::subscriptions::update_subscription_status(&mut *tx, &stripe_sub_id, status).await.context("update subscription status")?;
98 101
99 - let period_start = stripe_timestamp(sub.current_period_start);
100 - let period_end = stripe_timestamp(sub.current_period_end);
102 + let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
103 + let period_start = stripe_timestamp(start_ts);
104 + let period_end = stripe_timestamp(end_ts);
101 105 db::subscriptions::update_subscription_period(&mut *tx, &stripe_sub_id, period_start, period_end).await.context("update subscription period")?;
102 106 tx.commit().await.context("commit subscription update")?;
103 107
@@ -116,10 +120,10 @@ pub(super) async fn handle_subscription_updated(
116 120 /// Handle customer.subscription.deleted — mark canceled, send email
117 121 pub(super) async fn handle_subscription_deleted(
118 122 state: &AppState,
119 - sub: &stripe::Subscription,
123 + sub: &crate::payments::SubscriptionView,
120 124 event_id: &str,
121 125 ) -> Result<()> {
122 - let stripe_sub_id = sub.id.to_string();
126 + let stripe_sub_id = sub.id.clone();
123 127 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription deleted");
124 128
125 129 // Check if this is a Fan+ subscription
@@ -35,15 +35,12 @@ pub(super) async fn retry_failed_webhooks(state: &AppState) {
35 35 // (use ON CONFLICT / WHERE status='pending' guards) since steps completed
36 36 // before the original failure are not rolled back.
37 37 let result = if event.source == "stripe" {
38 - match serde_json::from_str::<stripe::Event>(&event.payload) {
39 - Ok(parsed_event) => {
40 - crate::routes::stripe::process_webhook_event(
41 - state, &parsed_event, parsed_event.id.as_ref(),
42 - ).await
43 - }
44 - Err(e) => {
45 - Err(crate::error::AppError::BadRequest(format!("Failed to parse stored webhook payload: {e}")))
38 + match crate::payments::UntypedEvent::from_payload(&event.payload) {
39 + Ok(parsed) => {
40 + let crate::payments::UntypedEvent { id, type_, data_object } = parsed;
41 + crate::routes::stripe::process_webhook_event(state, &type_, &id, data_object).await
46 42 }
43 + Err(e) => Err(e),
47 44 }
48 45 } else {
49 46 Err(crate::error::AppError::BadRequest(format!("Unknown webhook source: {}", event.source)))
@@ -156,9 +156,10 @@ impl PaymentProvider for MockPaymentProvider {
156 156 Ok(BalanceSummary { available_cents: 0, pending_cents: 0 })
157 157 }
158 158
159 - fn verify_webhook(&self, payload: &str, signature: &str) -> Result<stripe::Event> {
160 - stripe::Webhook::construct_event(payload, signature, TEST_WEBHOOK_SECRET)
161 - .map_err(|e| AppError::BadRequest(format!("Invalid webhook signature: {}", e)))
159 + fn verify_webhook(&self, payload: &str, signature: &str) -> Result<makenotwork::payments::UntypedEvent> {
160 + makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET)
161 + .map_err(AppError::BadRequest)?;
162 + makenotwork::payments::UntypedEvent::from_payload(payload)
162 163 }
163 164
164 165 fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> {