max / makenotwork
13 files changed,
+1007 insertions,
-1180 deletions
| @@ -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> { |