Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate stripe (leaf tree) to slices Narrow every stripe checkout entry point and the full webhook money-path helper tree off State<AppState>/&AppState onto slices: - checkout/connect handlers: PgPool + Config + Billing (+Integrations wam where used); item/cart also bg + email - webhook leaf helpers threaded with the exact subset each uses, canonical order db, bg, email, wam, payments(Billing), config - spawned tasks clone the individual slices they capture (no whole-state clone; the line-387 guest-splits nested spawn clones only db) spawn_email! sites in stripe inlined to the behavior-identical form (the macro reads state.email/state.bg). Two external call sites adapted to the narrowed pub(crate) helpers (api/users/library, api/guest_checkout). The thin dispatch boundary (process_webhook_event, process_v2_thin_event, HTTP handlers) still holds &AppState here; narrowed next commit. No behavior change; stripe 56 + webhook 73 + checkout 53 + mock_payment 33 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 22:56 UTC
Commit: 862075b4f5763002f821e8452175cd1e073b8644
Parent: 31590b1
14 files changed, +527 insertions, -390 deletions
@@ -348,7 +348,7 @@ pub(super) async fn claim_purchase(
348 348 // once per purchase.
349 349 if let Some(item_id) = tx.item_id {
350 350 crate::routes::stripe::webhook::checkout_helpers::maybe_generate_license_key(
351 - &state, item_id, user.id, tx.id,
351 + &state.db, state.wam.as_ref(), item_id, user.id, tx.id,
352 352 )
353 353 .await;
354 354 }
@@ -93,7 +93,7 @@ pub(in crate::routes::api) async fn add_to_library(
93 93 // Grant access to bundle child items
94 94 if claimed && item.item_type == db::ItemType::Bundle {
95 95 crate::routes::stripe::grant_bundle_items(
96 - &state, item_id, user.id, project.user_id, None,
96 + &state.db, item_id, user.id, project.user_id, None,
97 97 )
98 98 .await;
99 99 }
@@ -9,11 +9,14 @@ use serde::Deserialize;
9 9
10 10 use crate::{
11 11 auth::AuthUser,
12 + config::Config,
12 13 db::{self, Cents, PromoCodeId, UserId},
13 14 error::{AppError, Result, ResultExt},
14 15 helpers,
15 - AppState,
16 + wam_client::WamClient,
17 + Billing, Integrations,
16 18 };
19 + use sqlx::PgPool;
17 20
18 21 use super::grant_bundle_items;
19 22
@@ -23,8 +26,8 @@ use super::grant_bundle_items;
23 26 /// checkout, so the caller can't surface a failure to the user — but a silent
24 27 /// drop leaves the promo's use-count incremented (a stuck reservation). Log it
25 28 /// so an orphaned reservation is traceable rather than invisible.
26 - async fn release_promo_quietly(state: &AppState, pc_id: PromoCodeId, user_id: UserId) {
27 - if let Err(e) = db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user_id).await {
29 + async fn release_promo_quietly(db: &PgPool, pc_id: PromoCodeId, user_id: UserId) {
30 + if let Err(e) = db::promo_codes::release_use_count_and_detach(db, pc_id, user_id).await {
28 31 tracing::warn!(
29 32 promo_code_id = %pc_id,
30 33 %user_id,
@@ -50,7 +53,10 @@ pub(in crate::routes::stripe) struct CartCheckoutForm {
50 53 /// need, then maps the core's `Option<url>` onto a redirect.
51 54 #[tracing::instrument(skip_all, name = "stripe::cart_checkout", fields(user_id = %user.id))]
52 55 pub(in crate::routes::stripe) async fn create_cart_checkout(
53 - State(state): State<AppState>,
56 + State(db): State<PgPool>,
57 + State(integrations): State<Integrations>,
58 + State(payments): State<Billing>,
59 + State(config): State<Config>,
54 60 AuthUser(user): AuthUser,
55 61 Form(form): Form<CartCheckoutForm>,
56 62 ) -> Result<Response> {
@@ -64,7 +70,7 @@ pub(in crate::routes::stripe) async fn create_cart_checkout(
64 70 return Err(AppError::BadRequest("You cannot purchase your own items".to_string()));
65 71 }
66 72
67 - match checkout_seller_cart(&state, &user, seller_id, form.share_contact, form.promo_code.as_deref()).await? {
73 + match checkout_seller_cart(&db, integrations.wam.as_ref(), &payments, &config, &user, seller_id, form.share_contact, form.promo_code.as_deref()).await? {
68 74 Some(url) => Ok(Redirect::to(&url).into_response()),
69 75 None => Ok(Redirect::to("/library?purchase=success").into_response()),
70 76 }
@@ -83,7 +89,10 @@ pub(in crate::routes::stripe) struct CartCheckoutAllForm {
83 89 /// through the rest via checkout_success redirects.
84 90 #[tracing::instrument(skip_all, name = "stripe::cart_checkout_all", fields(user_id = %user.id))]
85 91 pub(in crate::routes::stripe) async fn create_cart_checkout_all(
86 - State(state): State<AppState>,
92 + State(db): State<PgPool>,
93 + State(integrations): State<Integrations>,
94 + State(payments): State<Billing>,
95 + State(config): State<Config>,
87 96 AuthUser(user): AuthUser,
88 97 session: tower_sessions::Session,
89 98 Form(form): Form<CartCheckoutAllForm>,
@@ -91,7 +100,7 @@ pub(in crate::routes::stripe) async fn create_cart_checkout_all(
91 100 user.check_not_suspended()?;
92 101 user.check_not_sandbox()?;
93 102
94 - let cart_items = db::cart::get_cart_items(&state.db, user.id).await
103 + let cart_items = db::cart::get_cart_items(&db, user.id).await
95 104 .context("fetch all cart items")?;
96 105
97 106 if cart_items.is_empty() {
@@ -123,7 +132,7 @@ pub(in crate::routes::stripe) async fn create_cart_checkout_all(
123 132
124 133 // Process the first seller and chain through the queue until we hit a
125 134 // paid seller (return its Stripe URL) or exhaust everything as free.
126 - match drain_to_paid(&state, &user, first_seller, form.share_contact, &session).await? {
135 + match drain_to_paid(&db, integrations.wam.as_ref(), &payments, &config, &user, first_seller, form.share_contact, &session).await? {
127 136 Some(url) => Ok(Redirect::to(&url).into_response()),
128 137 None => Ok(Redirect::to("/library?purchase=success").into_response()),
129 138 }
@@ -138,7 +147,8 @@ pub(in crate::routes::stripe) async fn create_cart_checkout_all(
138 147 /// loop (Run #8 perf MED). Shared by the free-by-price and discount-zeroed
139 148 /// passes so the claim logic exists in exactly one place.
140 149 async fn claim_free_cart_items(
141 - state: &AppState,
150 + db: &PgPool,
151 + wam: Option<&WamClient>,
142 152 user_id: UserId,
143 153 seller_id: UserId,
144 154 items: &[(&db::cart::CartItem, i64)],
@@ -154,7 +164,7 @@ async fn claim_free_cart_items(
154 164 let mut to_remove: Vec<db::ItemId> = Vec::with_capacity(items.len());
155 165 let mut claimed_items: Vec<&db::cart::CartItem> = Vec::with_capacity(items.len());
156 166
157 - let mut tx = state.db.begin().await.context("begin free-claim transaction")?;
167 + let mut tx = db.begin().await.context("begin free-claim transaction")?;
158 168 for (item, platform_credit_cents) in items {
159 169 let claim = db::transactions::ClaimParams {
160 170 buyer_id: user_id,
@@ -182,12 +192,12 @@ async fn claim_free_cart_items(
182 192
183 193 for item in claimed_items {
184 194 if item.item_type == "bundle" {
185 - grant_bundle_items(state, item.item_id, user_id, seller_id, None).await;
195 + grant_bundle_items(db, item.item_id, user_id, seller_id, None).await;
186 196 }
187 197 if item.enable_license_keys {
188 198 let key_code = helpers::generate_key_code();
189 199 if let Err(e) = db::license_keys::create_license_key(
190 - &state.db, item.item_id, user_id, None, &key_code,
200 + db, item.item_id, user_id, None, &key_code,
191 201 item.default_max_activations,
192 202 ).await {
193 203 // Mirror the paid path (webhook/checkout_helpers.rs): a buyer who
@@ -196,7 +206,7 @@ async fn claim_free_cart_items(
196 206 // 17 Observability). Free claims have no transaction id, so key the
197 207 // ticket on the item.
198 208 tracing::error!(user_id = %user_id, item_id = %item.item_id, error = ?e, "failed to generate license key for free claim");
199 - if let Some(ref wam) = state.wam {
209 + if let Some(wam) = wam {
200 210 let title = format!("License key not issued (free claim): item {}", item.item_id);
201 211 let body = format!(
202 212 "User {user_id} claimed free item {} but license key generation \
@@ -208,7 +218,7 @@ async fn claim_free_cart_items(
208 218 }
209 219 }
210 220 }
211 - if let Err(e) = db::cart::remove_from_cart_bulk(&state.db, user_id, &to_remove).await {
221 + if let Err(e) = db::cart::remove_from_cart_bulk(db, user_id, &to_remove).await {
212 222 // Non-fatal: the items were claimed; a failed cart cleanup just leaves
213 223 // stale rows the user can remove. Log rather than drop silently.
214 224 tracing::warn!(user_id = %user_id, error = ?e, "failed to clear claimed free items from cart");
@@ -225,7 +235,7 @@ async fn claim_free_cart_items(
225 235 /// any error the promo reservation (if any) is released, since the Stripe
226 236 /// session was already created but no fulfilling rows landed.
227 237 async fn create_cart_pending_transactions(
228 - state: &AppState,
238 + db: &PgPool,
229 239 user_id: UserId,
230 240 seller_id: UserId,
231 241 session_id: &str,
@@ -233,7 +243,7 @@ async fn create_cart_pending_transactions(
233 243 share_contact: bool,
234 244 promo_code_id: Option<PromoCodeId>,
235 245 ) -> Result<()> {
236 - let mut db_tx = state.db.begin().await.context("begin cart transaction creation")?;
246 + let mut db_tx = db.begin().await.context("begin cart transaction creation")?;
237 247 for (item, final_price, platform_credit_cents) in items {
238 248 match db::transactions::create_transaction(
239 249 &mut *db_tx,
@@ -264,7 +274,7 @@ async fn create_cart_pending_transactions(
264 274 "23505 raced past pre-check during cart pending insert"
265 275 );
266 276 if let Some(pc_id) = promo_code_id {
267 - release_promo_quietly(state, pc_id, user_id).await;
277 + release_promo_quietly(db, pc_id, user_id).await;
268 278 }
269 279 return Err(AppError::BadRequest(
270 280 "Another checkout for one of these items started while this one was loading. \
@@ -274,7 +284,7 @@ async fn create_cart_pending_transactions(
274 284 Err(e) => {
275 285 // Transaction auto-rolls back on drop.
276 286 if let Some(pc_id) = promo_code_id {
277 - release_promo_quietly(state, pc_id, user_id).await;
287 + release_promo_quietly(db, pc_id, user_id).await;
278 288 }
279 289 return Err(e).context("create pending transaction for cart item");
280 290 }
@@ -298,20 +308,24 @@ async fn create_cart_pending_transactions(
298 308 /// rejects (inert only because the chain never passed a promo); folding the two
299 309 /// copies into one removes that divergence.
300 310 #[tracing::instrument(skip_all, name = "stripe::checkout_seller_cart", fields(user_id = %user.id, %seller_id))]
311 + #[allow(clippy::too_many_arguments)]
301 312 async fn checkout_seller_cart(
302 - state: &AppState,
313 + db: &PgPool,
314 + wam: Option<&WamClient>,
315 + payments: &Billing,
316 + config: &Config,
303 317 user: &crate::auth::SessionUser,
304 318 seller_id: UserId,
305 319 share_contact: bool,
306 320 promo_code: Option<&str>,
307 321 ) -> Result<Option<String>> {
308 - let cart_items = db::cart::get_cart_items_for_seller(&state.db, user.id, seller_id).await
322 + let cart_items = db::cart::get_cart_items_for_seller(db, user.id, seller_id).await
309 323 .context("fetch cart items for seller")?;
310 324 if cart_items.is_empty() {
311 325 return Err(AppError::BadRequest("No items in cart for this creator".to_string()));
312 326 }
313 327
314 - let seller = db::users::get_user_by_id(&state.db, seller_id)
328 + let seller = db::users::get_user_by_id(db, seller_id)
315 329 .await
316 330 .context("fetch seller")?
317 331 .ok_or(AppError::NotFound)?;
@@ -321,7 +335,7 @@ async fn checkout_seller_cart(
321 335
322 336 // Bulk-check ownership in a single query instead of N sequential roundtrips.
323 337 let cart_item_ids: Vec<db::ItemId> = cart_items.iter().map(|c| c.item_id).collect();
324 - let already_owned = db::transactions::purchased_subset(&state.db, user.id, &cart_item_ids)
338 + let already_owned = db::transactions::purchased_subset(db, user.id, &cart_item_ids)
325 339 .await
326 340 .context("bulk check existing purchases")?;
327 341
@@ -329,7 +343,7 @@ async fn checkout_seller_cart(
329 343 let mut paid_items: Vec<&db::cart::CartItem> = Vec::new();
330 344 for item in &cart_items {
331 345 if already_owned.contains(&item.item_id) {
332 - if let Err(e) = db::cart::remove_from_cart(&state.db, user.id, item.item_id).await {
346 + if let Err(e) = db::cart::remove_from_cart(db, user.id, item.item_id).await {
333 347 tracing::warn!(
334 348 user_id = %user.id, item_id = %item.item_id, error = ?e,
335 349 "failed to remove already-purchased item from cart; buyer will see it lingering on /cart"
@@ -354,7 +368,7 @@ async fn checkout_seller_cart(
354 368 let mut platform_credits: std::collections::HashMap<db::ItemId, i64> = std::collections::HashMap::new();
355 369 if let Some(code_str) = promo_code.map(str::trim).filter(|s| !s.is_empty())
356 370 && let Some(validated) =
357 - db::promo_codes::lookup_and_validate_promo(&state.db, seller_id, Some(user.id), code_str).await?
371 + db::promo_codes::lookup_and_validate_promo(db, seller_id, Some(user.id), code_str).await?
358 372 {
359 373 use db::promo_codes::PromoApplication;
360 374 // Apply to each eligible paid item; ineligible items (scope/min-price)
@@ -414,7 +428,7 @@ async fn checkout_seller_cart(
414 428 if still_paid.is_empty()
415 429 && let Some(pc_id) = promo_code_id
416 430 {
417 - let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
431 + let reserved = db::promo_codes::try_increment_use_count(db, pc_id)
418 432 .await
419 433 .context("reserve promo code use at free cart checkout")?;
420 434 if !reserved {
@@ -426,14 +440,14 @@ async fn checkout_seller_cart(
426 440 // they're safe to grant now on either path.
427 441 let free_with_credit: Vec<(&db::cart::CartItem, i64)> =
428 442 free_items.iter().map(|it| (*it, 0i64)).collect();
429 - claim_free_cart_items(state, user.id, seller_id, &free_with_credit, share_contact).await?;
443 + claim_free_cart_items(db, wam, user.id, seller_id, &free_with_credit, share_contact).await?;
430 444
431 445 // No paid items remain after discounts: the single promo use is already
432 446 // reserved above, so grant the promo-freed lines and finish.
433 447 if still_paid.is_empty() {
434 - claim_free_cart_items(state, user.id, seller_id, &newly_free, share_contact).await?;
448 + claim_free_cart_items(db, wam, user.id, seller_id, &newly_free, share_contact).await?;
435 449 if share_contact && claimed_any_free {
436 - db::transactions::clear_contact_revocation(&state.db, user.id, seller_id)
450 + db::transactions::clear_contact_revocation(db, user.id, seller_id)
437 451 .await
438 452 .context("clear contact revocation")?;
439 453 }
@@ -453,7 +467,7 @@ async fn checkout_seller_cart(
453 467 if !seller.stripe_charges_enabled {
454 468 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
455 469 }
456 - let stripe = state.stripe.as_ref()
470 + let stripe = payments.stripe.as_ref()
457 471 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
458 472
459 473 let line_items: Vec<crate::payments::CartLineItem> = still_paid
@@ -479,7 +493,7 @@ async fn checkout_seller_cart(
479 493 // BEFORE creating the Stripe session, so we never charge for an item that
480 494 // can't get a pending row (and would therefore never be fulfilled).
481 495 let paid_item_ids: Vec<db::ItemId> = still_paid.iter().map(|(it, _, _)| it.item_id).collect();
482 - let pending_collisions = db::transactions::pending_subset(&state.db, user.id, &paid_item_ids)
496 + let pending_collisions = db::transactions::pending_subset(db, user.id, &paid_item_ids)
483 497 .await
484 498 .context("pre-check pending cart purchases")?;
485 499 if !pending_collisions.is_empty() {
@@ -498,7 +512,7 @@ async fn checkout_seller_cart(
498 512 // discounted line would be a different product, not a bug fix; keep this a
499 513 // single increment.
500 514 if let Some(pc_id) = promo_code_id {
501 - let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
515 + let reserved = db::promo_codes::try_increment_use_count(db, pc_id)
502 516 .await
503 517 .context("reserve promo code use at cart checkout")?;
504 518 if !reserved {
@@ -511,14 +525,14 @@ async fn checkout_seller_cart(
511 525 // concurrent double-spend: a checkout that loses the single-use reservation
512 526 // errored above and never reaches here.
513 527 if !newly_free.is_empty() {
514 - claim_free_cart_items(state, user.id, seller_id, &newly_free, share_contact).await?;
528 + claim_free_cart_items(db, wam, user.id, seller_id, &newly_free, share_contact).await?;
515 529 }
516 530
517 531 let success_url = format!(
518 532 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
519 - state.config.host_url
533 + config.host_url
520 534 );
521 - let cancel_url = format!("{}/cart", state.config.host_url);
535 + let cancel_url = format!("{}/cart", config.host_url);
522 536 let cart_params = crate::payments::CartCheckoutParams {
523 537 connected_account_id: stripe_account_id,
524 538 line_items: &line_items,
@@ -533,14 +547,14 @@ async fn checkout_seller_cart(
533 547 Ok(r) => r,
534 548 Err(e) => {
535 549 if let Some(pc_id) = promo_code_id {
536 - release_promo_quietly(state, pc_id, user.id).await;
550 + release_promo_quietly(db, pc_id, user.id).await;
537 551 }
538 552 return Err(e).context("create cart checkout session");
539 553 }
540 554 };
541 555
542 556 if let Err(e) = create_cart_pending_transactions(
543 - state, user.id, seller_id, &result.id, &still_paid, share_contact, promo_code_id,
557 + db, user.id, seller_id, &result.id, &still_paid, share_contact, promo_code_id,
544 558 )
545 559 .await
546 560 {
@@ -549,7 +563,7 @@ async fn checkout_seller_cart(
549 563 // escalates it as an orphaned paid session (Run #2 Payments SERIOUS).
550 564 // Release the promo reservation so it isn't stuck held by a dead session.
551 565 if let Some(pc_id) = promo_code_id {
552 - release_promo_quietly(state, pc_id, user.id).await;
566 + release_promo_quietly(db, pc_id, user.id).await;
553 567 }
554 568 tracing::error!(
555 569 session_id = %result.id, error = ?e,
@@ -574,8 +588,12 @@ async fn checkout_seller_cart(
574 588 /// Chained checkout never carries a promo (`promo_code = None`); discounts are
575 589 /// only applied on direct single-seller form submissions.
576 590 #[tracing::instrument(skip_all, name = "stripe::drain_to_paid", fields(user_id = %user.id, first_seller_id = %first_seller_id))]
591 + #[allow(clippy::too_many_arguments)]
577 592 pub(super) async fn drain_to_paid(
578 - state: &AppState,
593 + db: &PgPool,
594 + wam: Option<&WamClient>,
595 + payments: &Billing,
596 + config: &Config,
579 597 user: &crate::auth::SessionUser,
580 598 first_seller_id: String,
581 599 share_contact: bool,
@@ -585,7 +603,7 @@ pub(super) async fn drain_to_paid(
585 603 loop {
586 604 let seller_id: UserId = current.parse()
587 605 .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?;
588 - if let Some(url) = checkout_seller_cart(state, user, seller_id, share_contact, None).await? {
606 + if let Some(url) = checkout_seller_cart(db, wam, payments, config, user, seller_id, share_contact, None).await? {
589 607 return Ok(Some(url));
590 608 }
591 609 // All items for `current` were free. Pop the next queued seller and
@@ -8,19 +8,28 @@ use axum::{
8 8
9 9 use crate::{
10 10 auth::AuthUser,
11 + config::Config,
11 12 db::{self, Cents, ItemId, PromoCodeId},
13 + email::EmailClient,
12 14 error::{AppError, Result, ResultExt},
13 - helpers::{self, spawn_email},
15 + helpers,
14 16 pricing::{self, CheckoutType},
15 - AppState,
17 + Billing, Integrations,
16 18 };
19 + use sqlx::PgPool;
17 20
18 21 use super::CheckoutForm;
19 22
20 23 /// POST /stripe/checkout/{item_id} - Create a checkout session and redirect
21 24 #[tracing::instrument(skip_all, name = "stripe::checkout", fields(item_id))]
25 + #[allow(clippy::too_many_arguments)]
22 26 pub(in crate::routes::stripe) async fn create_checkout(
23 - State(state): State<AppState>,
27 + State(db): State<PgPool>,
28 + State(bg): State<crate::background::BackgroundTx>,
29 + State(email): State<EmailClient>,
30 + State(integrations): State<Integrations>,
31 + State(payments): State<Billing>,
32 + State(config): State<Config>,
24 33 AuthUser(user): AuthUser,
25 34 Path(item_id): Path<String>,
26 35 Form(form): Form<CheckoutForm>,
@@ -33,7 +42,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
33 42 .map_err(|_| AppError::NotFound)?;
34 43
35 44 // Get the item
36 - let item = db::items::get_item_by_id(&state.db, item_uuid)
45 + let item = db::items::get_item_by_id(&db, item_uuid)
37 46 .await
38 47 .with_context(|| format!("fetch item {item_uuid} for checkout"))?
39 48 .ok_or(AppError::NotFound)?;
@@ -55,14 +64,14 @@ pub(in crate::routes::stripe) async fn create_checkout(
55 64 }
56 65
57 66 // Check if already purchased
58 - if db::transactions::has_purchased_item(&state.db, user.id, item_uuid)
67 + if db::transactions::has_purchased_item(&db, user.id, item_uuid)
59 68 .await
60 69 .context("check existing purchase")? {
61 70 return Ok(Redirect::to(&format!("/l/{}", item_id)).into_response());
62 71 }
63 72
64 73 // Get the seller (creator)
65 - let seller_id = db::items::get_item_owner(&state.db, item_uuid)
74 + let seller_id = db::items::get_item_owner(&db, item_uuid)
66 75 .await
67 76 .with_context(|| format!("fetch item owner for {item_uuid}"))?
68 77 .ok_or(AppError::NotFound)?;
@@ -72,7 +81,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
72 81 return Err(AppError::BadRequest("You cannot purchase your own items".to_string()));
73 82 }
74 83
75 - let seller = db::users::get_user_by_id(&state.db, seller_id)
84 + let seller = db::users::get_user_by_id(&db, seller_id)
76 85 .await
77 86 .with_context(|| format!("fetch seller {seller_id}"))?
78 87 .ok_or(AppError::NotFound)?;
@@ -107,7 +116,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
107 116 }
108 117 // Buyer's platform-wide Fan+ credit is the fallback when no seller code matches.
109 118 if let Some(validated) =
110 - db::promo_codes::lookup_and_validate_promo(&state.db, seller_id, Some(user.id), code_str).await?
119 + db::promo_codes::lookup_and_validate_promo(&db, seller_id, Some(user.id), code_str).await?
111 120 {
112 121 use db::promo_codes::{PromoApplication, PromoIneligible};
113 122 match db::promo_codes::apply_promo_to_item(&validated, item_uuid, item.project_id, base_price_cents)? {
@@ -154,7 +163,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
154 163 let (claimed, license_key_created) = if let Some(pc_id) = promo_code_id {
155 164 // Wrap promo code increment + claim + license key in a single transaction
156 165 let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code(
157 - &state.db,
166 + &db,
158 167 pc_id,
159 168 &claim,
160 169 lk_params.as_ref(),
@@ -168,7 +177,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
168 177 (claimed, claimed && item.enable_license_keys)
169 178 } else {
170 179 // Wrap claim + sales count increment in a single transaction
171 - let mut tx = state.db.begin().await.context("begin free-claim transaction")?;
180 + let mut tx = db.begin().await.context("begin free-claim transaction")?;
172 181 let claimed = db::transactions::claim_free_item(&mut *tx, &claim)
173 182 .await
174 183 .context("claim free item")?;
@@ -184,12 +193,12 @@ pub(in crate::routes::stripe) async fn create_checkout(
184 193 if claimed {
185 194 // Grant access to bundle child items (if this is a bundle)
186 195 if item.item_type == db::ItemType::Bundle {
187 - grant_bundle_items(&state, item_uuid, user.id, seller_id, None).await;
196 + grant_bundle_items(&db, item_uuid, user.id, seller_id, None).await;
188 197 }
189 198
190 199 // Clear any prior contact revocation if fan is re-sharing
191 200 if form.share_contact {
192 - db::transactions::clear_contact_revocation(&state.db, user.id, seller_id)
201 + db::transactions::clear_contact_revocation(&db, user.id, seller_id)
193 202 .await
194 203 .context("clear contact revocation")?;
195 204 }
@@ -198,7 +207,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
198 207 if item.enable_license_keys && !license_key_created {
199 208 let key_code = helpers::generate_key_code();
200 209 match db::license_keys::create_license_key(
201 - &state.db,
210 + &db,
202 211 item_uuid,
203 212 user.id,
204 213 None, // no transaction ID for free claims
@@ -220,7 +229,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
220 229 buyer_id = %user.id, item_id = %item_uuid, error = ?e,
221 230 "failed to generate license key for free claim"
222 231 );
223 - if let Some(ref wam) = state.wam {
232 + if let Some(wam) = integrations.wam.as_ref() {
224 233 let title = format!("License key not issued (free claim): item {item_uuid}");
225 234 let body = format!(
226 235 "User {} claimed free item {item_uuid} but license key \
@@ -235,7 +244,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
235 244
236 245 // Notify seller of free claim (fire-and-forget)
237 246 if seller.notify_sale {
238 - let buyer_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten();
247 + let buyer_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
239 248 let buyer_username = buyer_user.as_ref()
240 249 .map(|b| b.username.to_string())
241 250 .unwrap_or_else(|| "Someone".to_string());
@@ -243,17 +252,20 @@ pub(in crate::routes::stripe) async fn create_checkout(
243 252 let seller_email = seller.email.clone();
244 253 let seller_name = seller.display_name.clone();
245 254 let unsub_url = crate::email::generate_unsubscribe_url(
246 - &state.config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &state.config.signing_secret,
255 + &config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &config.signing_secret,
247 256 );
248 - spawn_email!(state, "sale notification", |email| {
249 - email.send_sale_notification(
257 + let email = email.clone();
258 + bg.spawn("sale notification", async move {
259 + if let Err(e) = email.send_sale_notification(
250 260 &seller_email,
251 261 seller_name.as_deref(),
252 262 &buyer_username,
253 263 &item_title,
254 264 "Free",
255 265 Some(&unsub_url),
256 - )
266 + ).await {
267 + tracing::error!(error = ?e, "failed to send sale notification");
268 + }
257 269 });
258 270 }
259 271 }
@@ -277,14 +289,14 @@ pub(in crate::routes::stripe) async fn create_checkout(
277 289 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
278 290 }
279 291
280 - let stripe = state.stripe.as_ref()
292 + let stripe = payments.stripe.as_ref()
281 293 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
282 294
283 295 // Reserve promo code use_count at checkout time (not webhook time) to prevent
284 296 // concurrent checkouts from exceeding max_uses. If the buyer abandons checkout,
285 297 // the scheduler releases the reservation when cleaning up stale pending transactions.
286 298 if let Some(pc_id) = promo_code_id {
287 - let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
299 + let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
288 300 .await
289 301 .context("reserve promo code use at checkout")?;
290 302 if !reserved {
@@ -293,8 +305,8 @@ pub(in crate::routes::stripe) async fn create_checkout(
293 305 }
294 306
295 307 // Build URLs
296 - let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}&item_id={}", state.config.host_url, item_id);
297 - let cancel_url = format!("{}/stripe/cancel?item_id={}", state.config.host_url, item_id);
308 + let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}&item_id={}", config.host_url, item_id);
309 + let cancel_url = format!("{}/stripe/cancel?item_id={}", config.host_url, item_id);
298 310
299 311 // Create the checkout session with the (possibly discounted) price.
300 312 // If this or the transaction INSERT fails, release the promo code reservation.
@@ -314,7 +326,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
314 326 Ok(s) => s,
315 327 Err(e) => {
316 328 if let Some(pc_id) = promo_code_id {
317 - db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
329 + db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id).await.ok();
318 330 }
319 331 return Err(e).with_context(|| format!("create Stripe checkout for item {item_uuid}"));
320 332 }
@@ -325,7 +337,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
325 337 // duplicate checkouts — if another checkout is already in progress,
326 338 // the INSERT fails and we redirect back to the item page.
327 339 match db::transactions::create_transaction(
328 - &state.db,
340 + &db,
329 341 &db::transactions::CreateTransactionParams {
330 342 buyer_id: Some(user.id),
331 343 seller_id,
@@ -347,14 +359,14 @@ pub(in crate::routes::stripe) async fn create_checkout(
347 359 if db_err.code().as_deref() == Some("23505") =>
348 360 {
349 361 if let Some(pc_id) = promo_code_id {
350 - db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
362 + db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id).await.ok();
351 363 }
352 364 tracing::info!(buyer_id = %user.id, item_id = %item_uuid, "duplicate pending checkout blocked");
353 365 return Ok(Redirect::to(&format!("/purchase/{}", item_id)).into_response());
354 366 }
355 367 Err(e) => {
356 368 if let Some(pc_id) = promo_code_id {
357 - db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
369 + db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id).await.ok();
358 370 }
359 371 return Err(e).context("create pending transaction");
360 372 }
@@ -374,7 +386,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
374 386 /// promo code use_count.
375 387 #[tracing::instrument(skip_all, name = "stripe::cancel_pending", fields(item_id))]
376 388 pub(in crate::routes::stripe) async fn cancel_pending_item_checkout(
377 - State(state): State<AppState>,
389 + State(db): State<PgPool>,
378 390 AuthUser(user): AuthUser,
379 391 Path(item_id): Path<String>,
380 392 ) -> Result<Response> {
@@ -382,11 +394,11 @@ pub(in crate::routes::stripe) async fn cancel_pending_item_checkout(
382 394 let item_uuid: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
383 395
384 396 if let Some(promo_id) =
385 - db::transactions::delete_pending_item_purchase(&state.db, user.id, item_uuid)
397 + db::transactions::delete_pending_item_purchase(&db, user.id, item_uuid)
386 398 .await
387 399 .context("delete pending item checkout")?
388 400 {
389 - db::promo_codes::release_use_count(&state.db, promo_id).await.ok();
401 + db::promo_codes::release_use_count(&db, promo_id).await.ok();
390 402 }
391 403
392 404 Ok(Redirect::to(&format!("/purchase/{}", item_id)).into_response())
@@ -398,13 +410,13 @@ pub(in crate::routes::stripe) async fn cancel_pending_item_checkout(
398 410 /// ON CONFLICT DO NOTHING). Does NOT increment child item sales_count --
399 411 /// the bundle sale is what counts.
400 412 pub(crate) async fn grant_bundle_items(
401 - state: &AppState,
413 + db: &PgPool,
402 414 bundle_id: db::ItemId,
403 415 buyer_id: db::UserId,
404 416 seller_id: db::UserId,
405 417 parent_transaction_id: Option<db::TransactionId>,
406 418 ) {
407 - let child_items = match db::bundles::get_bundle_items(&state.db, bundle_id).await {
419 + let child_items = match db::bundles::get_bundle_items(db, bundle_id).await {
408 420 Ok(items) => items,
409 421 Err(e) => {
410 422 tracing::error!(bundle_id = %bundle_id, error = ?e, "failed to load bundle items for granting");
@@ -412,7 +424,7 @@ pub(crate) async fn grant_bundle_items(
412 424 }
413 425 };
414 426
415 - let seller = match db::users::get_user_by_id(&state.db, seller_id).await {
427 + let seller = match db::users::get_user_by_id(db, seller_id).await {
416 428 Ok(Some(u)) => u,
417 429 _ => return,
418 430 };
@@ -423,7 +435,7 @@ pub(crate) async fn grant_bundle_items(
423 435 let items: Vec<(db::ItemId, &str)> =
424 436 child_items.iter().map(|c| (c.id, c.title.as_str())).collect();
425 437 match db::transactions::claim_free_items_batch(
426 - &state.db,
438 + db,
427 439 buyer_id,
428 440 seller_id,
429 441 &seller.username,
@@ -23,7 +23,8 @@ use axum::{
23 23 use serde::Deserialize;
24 24 use tower_sessions::Session;
25 25
26 - use crate::AppState;
26 + use crate::{config::Config, Billing, Integrations};
27 + use sqlx::PgPool;
27 28
28 29 /// Form data for checkout (supports optional promo code).
29 30 #[derive(Debug, Deserialize)]
@@ -56,7 +57,10 @@ pub struct CancelQuery {
56 57 /// processes the next seller automatically.
57 58 #[tracing::instrument(skip_all, name = "stripe_checkout::checkout_success")]
58 59 pub(super) async fn checkout_success(
59 - State(state): State<AppState>,
60 + State(db): State<PgPool>,
61 + State(integrations): State<Integrations>,
62 + State(payments): State<Billing>,
63 + State(config): State<Config>,
60 64 session: Session,
61 65 crate::auth::MaybeUserVerified(maybe_user): crate::auth::MaybeUserVerified,
62 66 Query(query): Query<SuccessQuery>,
@@ -80,7 +84,7 @@ pub(super) async fn checkout_success(
80 84 let share_contact = session.get::<bool>("cart_share_contact").await
81 85 .ok().flatten().unwrap_or(false);
82 86
83 - match cart::drain_to_paid(&state, &user, next_seller_id.clone(), share_contact, &session).await {
87 + match cart::drain_to_paid(&db, integrations.wam.as_ref(), &payments, &config, &user, next_seller_id.clone(), share_contact, &session).await {
84 88 Ok(Some(redirect_url)) => return Redirect::to(&redirect_url),
85 89 Ok(None) => {
86 90 // Queue drained with everything claimed free; fall through to
@@ -9,11 +9,13 @@ use serde::Deserialize;
9 9
10 10 use crate::{
11 11 auth::AuthUser,
12 + config::Config,
12 13 db::{self, Cents},
13 14 error::{AppError, Result, ResultExt},
14 15 pricing::{self, CheckoutType},
15 - AppState,
16 + Billing,
16 17 };
18 + use sqlx::PgPool;
17 19
18 20 /// Form data for project checkout.
19 21 #[derive(Debug, Deserialize)]
@@ -27,7 +29,9 @@ pub(in crate::routes::stripe) struct ProjectCheckoutForm {
27 29 /// POST /stripe/checkout/project/{project_id}: Purchase project-level access.
28 30 #[tracing::instrument(skip_all, name = "stripe::project_checkout")]
29 31 pub(in crate::routes::stripe) async fn create_project_checkout(
30 - State(state): State<AppState>,
32 + State(db): State<PgPool>,
33 + State(payments): State<Billing>,
34 + State(config): State<Config>,
31 35 AuthUser(user): AuthUser,
32 36 Path(project_id): Path<String>,
33 37 Form(form): Form<ProjectCheckoutForm>,
@@ -39,7 +43,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
39 43 .parse()
40 44 .map_err(|_| AppError::NotFound)?;
41 45
42 - let project = db::projects::get_project_by_id(&state.db, project_uuid)
46 + let project = db::projects::get_project_by_id(&db, project_uuid)
43 47 .await?
44 48 .ok_or(AppError::NotFound)?;
45 49
@@ -55,7 +59,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
55 59 }
56 60
57 61 // Check if already purchased
58 - if db::transactions::has_purchased_project(&state.db, user.id, project_uuid).await? {
62 + if db::transactions::has_purchased_project(&db, user.id, project_uuid).await? {
59 63 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
60 64 }
61 65
@@ -66,7 +70,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
66 70 ));
67 71 }
68 72
69 - let seller = db::users::get_user_by_id(&state.db, seller_id)
73 + let seller = db::users::get_user_by_id(&db, seller_id)
70 74 .await?
71 75 .ok_or(AppError::NotFound)?;
72 76
@@ -90,7 +94,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
90 94 // If price is $0 (PWYW with $0 min), record a free claim
91 95 if base_price_cents == 0 {
92 96 let claimed = db::transactions::claim_free_project(
93 - &state.db,
97 + &db,
94 98 user.id,
95 99 seller_id,
96 100 project_uuid,
@@ -107,7 +111,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
107 111 // PWYW purchases were previously silently un-instrumented (no contact
108 112 // revocation clear, no sale notification email).
109 113 if claimed && form.share_contact {
110 - db::transactions::clear_contact_revocation(&state.db, user.id, seller_id)
114 + db::transactions::clear_contact_revocation(&db, user.id, seller_id)
111 115 .await
112 116 .context("clear contact revocation on free project claim")?;
113 117 }
@@ -131,16 +135,16 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
131 135 ));
132 136 }
133 137
134 - let stripe = state
138 + let stripe = payments
135 139 .stripe
136 140 .as_ref()
137 141 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
138 142
139 143 let success_url = format!(
140 144 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
141 - state.config.host_url
145 + config.host_url
142 146 );
143 - let cancel_url = format!("{}/p/{}", state.config.host_url, project.slug);
147 + let cancel_url = format!("{}/p/{}", config.host_url, project.slug);
144 148
145 149 let checkout_params = crate::payments::CheckoutParams {
146 150 connected_account_id: stripe_account_id,
@@ -157,7 +161,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
157 161 let session = stripe.create_checkout_session(&checkout_params).await?;
158 162
159 163 match db::transactions::create_transaction(
160 - &state.db,
164 + &db,
161 165 &db::transactions::CreateTransactionParams {
162 166 buyer_id: Some(user.id),
163 167 seller_id,
@@ -9,34 +9,38 @@ use serde::Deserialize;
9 9
10 10 use crate::{
11 11 auth::AuthUser,
12 + config::Config,
12 13 db::{self, CodePurpose, PromoCodeId, SubscriptionTierId},
13 14 error::{AppError, Result, ResultExt},
14 - AppState,
15 + Billing,
15 16 };
17 + use sqlx::PgPool;
16 18
17 19 /// POST /stripe/fan-plus: Create a Fan+ subscription checkout and redirect
18 20 #[tracing::instrument(skip_all, name = "stripe::fan_plus_checkout")]
19 21 pub(in crate::routes::stripe) async fn create_fan_plus_checkout(
20 - State(state): State<AppState>,
22 + State(db): State<PgPool>,
23 + State(payments): State<Billing>,
24 + State(config): State<Config>,
21 25 AuthUser(user): AuthUser,
22 26 ) -> Result<Response> {
23 27 user.check_not_suspended()?;
24 28 user.check_not_sandbox()?;
25 29
26 30 // Check Fan+ price is configured
27 - let price_id = state.config.creator_pricing.fan_plus_price_id.as_ref()
31 + let price_id = config.creator_pricing.fan_plus_price_id.as_ref()
28 32 .ok_or_else(|| AppError::BadRequest("Fan+ is not configured".to_string()))?;
29 33
30 34 // Check not already a Fan+ subscriber
31 - if db::fan_plus::is_fan_plus_active(&state.db, user.id).await? {
35 + if db::fan_plus::is_fan_plus_active(&db, user.id).await? {
32 36 return Ok(Redirect::to("/fan-plus").into_response());
33 37 }
34 38
35 - let stripe = state.stripe.as_ref()
39 + let stripe = payments.stripe.as_ref()
36 40 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
37 41
38 - let success_url = format!("{}/fan-plus?subscribed=true", state.config.host_url);
39 - let cancel_url = format!("{}/fan-plus", state.config.host_url);
42 + let success_url = format!("{}/fan-plus?subscribed=true", config.host_url);
43 + let cancel_url = format!("{}/fan-plus", config.host_url);
40 44
41 45 let session = stripe.create_fan_plus_checkout_session(
42 46 price_id,
@@ -85,22 +89,23 @@ fn check_sec_fetch_site(headers: &axum::http::HeaderMap) -> Result<()> {
85 89 /// sync if the user later cancels via the customer portal instead.
86 90 #[tracing::instrument(skip_all, name = "stripe::fan_plus_cancel")]
87 91 pub(in crate::routes::stripe) async fn cancel_fan_plus(
88 - State(state): State<AppState>,
92 + State(db): State<PgPool>,
93 + State(payments): State<Billing>,
89 94 headers: axum::http::HeaderMap,
90 95 AuthUser(user): AuthUser,
91 96 ) -> Result<Redirect> {
92 97 check_sec_fetch_site(&headers)?;
93 - let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id)
98 + let sub = db::fan_plus::get_fan_plus_by_user(&db, user.id)
94 99 .await?
95 100 .ok_or_else(|| AppError::BadRequest("No active Fan+ subscription".to_string()))?;
96 101
97 - let stripe = state.stripe.as_ref()
102 + let stripe = payments.stripe.as_ref()
98 103 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
99 104
100 105 stripe
101 106 .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, true)
102 107 .await?;
103 - db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, true).await?;
108 + db::fan_plus::set_cancel_at_period_end(&db, &sub.stripe_subscription_id, true).await?;
104 109
105 110 Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+cancellation+scheduled"))
106 111 }
@@ -108,22 +113,23 @@ pub(in crate::routes::stripe) async fn cancel_fan_plus(
108 113 /// POST /stripe/fan-plus/resume: Undo a scheduled cancellation.
109 114 #[tracing::instrument(skip_all, name = "stripe::fan_plus_resume")]
110 115 pub(in crate::routes::stripe) async fn resume_fan_plus(
111 - State(state): State<AppState>,
116 + State(db): State<PgPool>,
117 + State(payments): State<Billing>,
112 118 headers: axum::http::HeaderMap,
113 119 AuthUser(user): AuthUser,
114 120 ) -> Result<Redirect> {
115 121 check_sec_fetch_site(&headers)?;
116 - let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id)
122 + let sub = db::fan_plus::get_fan_plus_by_user(&db, user.id)
117 123 .await?
118 124 .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?;
119 125
120 - let stripe = state.stripe.as_ref()
126 + let stripe = payments.stripe.as_ref()
121 127 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
122 128
123 129 stripe
124 130 .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, false)
125 131 .await?;
126 - db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, false).await?;
132 + db::fan_plus::set_cancel_at_period_end(&db, &sub.stripe_subscription_id, false).await?;
127 133
128 134 Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+resumed"))
129 135 }
@@ -135,17 +141,19 @@ pub(in crate::routes::stripe) async fn resume_fan_plus(
135 141 /// the dashboard Fan+ pane.
136 142 #[tracing::instrument(skip_all, name = "stripe::billing_portal")]
137 143 pub(in crate::routes::stripe) async fn open_billing_portal(
138 - State(state): State<AppState>,
144 + State(db): State<PgPool>,
145 + State(payments): State<Billing>,
146 + State(config): State<Config>,
139 147 AuthUser(user): AuthUser,
140 148 ) -> Result<Redirect> {
141 - let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id)
149 + let sub = db::fan_plus::get_fan_plus_by_user(&db, user.id)
142 150 .await?
143 151 .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?;
144 152
145 - let stripe = state.stripe.as_ref()
153 + let stripe = payments.stripe.as_ref()
146 154 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
147 155
148 - let return_url = format!("{}/dashboard?tab=account", state.config.host_url);
156 + let return_url = format!("{}/dashboard?tab=account", config.host_url);
149 157 let url = stripe
150 158 .create_billing_portal_session(&sub.stripe_customer_id, &return_url)
151 159 .await?;
@@ -188,7 +196,9 @@ impl BillingInterval {
188 196 /// POST /stripe/creator-tier: Create a creator tier subscription checkout and redirect
189 197 #[tracing::instrument(skip_all, name = "stripe::creator_tier_checkout")]
190 198 pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
191 - State(state): State<AppState>,
199 + State(db): State<PgPool>,
200 + State(payments): State<Billing>,
201 + State(config): State<Config>,
192 202 AuthUser(user): AuthUser,
193 203 Form(form): Form<CreatorTierForm>,
194 204 ) -> Result<Response> {
@@ -211,17 +221,17 @@ pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
211 221 // monthly buyer routed to sticker_annual pays the full year up front at a
212 222 // ~21× larger first charge (Run 21 payments). If the requested cadence
213 223 // isn't configured for a tier we error clearly rather than mischarge.
214 - let db_user = db::users::get_user_by_id(&state.db, user.id)
224 + let db_user = db::users::get_user_by_id(&db, user.id)
215 225 .await?
216 226 .ok_or(AppError::NotFound)?;
217 227 let founder_eligible =
218 - state.config.creator_pricing.founder_window_open || db_user.is_founder_locked();
228 + config.creator_pricing.founder_window_open || db_user.is_founder_locked();
219 229 let interval = BillingInterval::from_form(form.interval.as_deref());
220 230
221 - let founder_annual = state.config.creator_pricing.tier_founder_annual_prices.get(&tier);
222 - let founder_monthly = state.config.creator_pricing.tier_founder_prices.get(&tier);
223 - let sticker_annual = state.config.creator_pricing.tier_annual_prices.get(&tier);
224 - let sticker_monthly = state.config.creator_pricing.tier_prices.get(&tier);
231 + let founder_annual = config.creator_pricing.tier_founder_annual_prices.get(&tier);
232 + let founder_monthly = config.creator_pricing.tier_founder_prices.get(&tier);
233 + let sticker_annual = config.creator_pricing.tier_annual_prices.get(&tier);
234 + let sticker_monthly = config.creator_pricing.tier_prices.get(&tier);
225 235
226 236 let price_id = match (founder_eligible, interval) {
227 237 (true, BillingInterval::Annual) => founder_annual.or(sticker_annual),
@@ -232,15 +242,15 @@ pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
232 242 .ok_or_else(|| AppError::BadRequest("Creator tiers are not configured".to_string()))?;
233 243
234 244 // Check not already subscribed
235 - if db::creator_tiers::get_active_creator_tier(&state.db, user.id).await?.is_some() {
245 + if db::creator_tiers::get_active_creator_tier(&db, user.id).await?.is_some() {
236 246 return Ok(Redirect::to("/dashboard?tab=creator").into_response());
237 247 }
238 248
239 - let stripe = state.stripe.as_ref()
249 + let stripe = payments.stripe.as_ref()
240 250 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
241 251
242 - let success_url = format!("{}/dashboard?tab=creator&subscribed=true", state.config.host_url);
243 - let cancel_url = format!("{}/dashboard?tab=creator", state.config.host_url);
252 + let success_url = format!("{}/dashboard?tab=creator&subscribed=true", config.host_url);
253 + let cancel_url = format!("{}/dashboard?tab=creator", config.host_url);
244 254
245 255 // Validate an optional comp code (platform-wide free-trial). Unlike the
246 256 // project-subscription path there's no creator/project scope to check —
@@ -250,7 +260,7 @@ pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
250 260 if let Some(code_str) = form.promo_code.as_deref() {
251 261 let code_str = code_str.trim().to_uppercase();
252 262 if !code_str.is_empty() {
253 - let pc = db::promo_codes::get_platform_trial_code_by_code(&state.db, &code_str)
263 + let pc = db::promo_codes::get_platform_trial_code_by_code(&db, &code_str)
254 264 .await?
255 265 .ok_or_else(|| AppError::BadRequest("Invalid comp code".to_string()))?;
256 266
@@ -274,18 +284,18 @@ pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
274 284 if let Some(pc_id) = promo_code_id {
275 285 // Once-per-individual: a repeat redemption by the same user is rejected,
276 286 // so a reusable code shared by a creator grants each person one trial.
277 - let first_time = db::promo_codes::try_record_redemption(&state.db, pc_id, user.id)
287 + let first_time = db::promo_codes::try_record_redemption(&db, pc_id, user.id)
278 288 .await
279 289 .context("record comp code redemption at creator-tier checkout")?;
280 290 if !first_time {
281 291 return Err(AppError::BadRequest("You have already used this code.".to_string()));
282 292 }
283 293 // Global usage cap (max_uses). The WHERE clause re-checks the limit.
284 - let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
294 + let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
285 295 .await
286 296 .context("reserve comp code use at creator-tier checkout")?;
287 297 if !reserved {
288 - db::promo_codes::remove_redemption(&state.db, pc_id, user.id).await.ok();
298 + db::promo_codes::remove_redemption(&db, pc_id, user.id).await.ok();
289 299 return Err(AppError::BadRequest("This code has reached its usage limit".to_string()));
290 300 }
291 301 }
@@ -301,8 +311,8 @@ pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
301 311 Ok(s) => s,
302 312 Err(e) => {
303 313 if let Some(pc_id) = promo_code_id {
304 - db::promo_codes::release_use_count(&state.db, pc_id).await.ok();
305 - db::promo_codes::remove_redemption(&state.db, pc_id, user.id).await.ok();
314 + db::promo_codes::release_use_count(&db, pc_id).await.ok();
315 + db::promo_codes::remove_redemption(&db, pc_id, user.id).await.ok();
306 316 }
307 317 return Err(e);
308 318 }
@@ -315,8 +325,8 @@ pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
315 325 // is the correct grain for the snapshot at close-time (the close sweep
316 326 // only locks users with an active subscription, so abandoned checkouts
317 327 // don't get locked in regardless).
318 - if state.config.creator_pricing.founder_window_open && !db_user.is_founder {
319 - db::users::mark_user_as_founder(&state.db, user.id).await?;
328 + if config.creator_pricing.founder_window_open && !db_user.is_founder {
329 + db::users::mark_user_as_founder(&db, user.id).await?;
320 330 }
321 331
322 332 let checkout_url = session.url
@@ -334,7 +344,9 @@ pub(in crate::routes::stripe) struct SubscribeForm {
334 344 /// POST /stripe/subscribe/{tier_id} - Create a subscription checkout and redirect
335 345 #[tracing::instrument(skip_all, name = "stripe::subscribe")]
336 346 pub(in crate::routes::stripe) async fn create_subscription_checkout(
337 - State(state): State<AppState>,
347 + State(db): State<PgPool>,
348 + State(payments): State<Billing>,
349 + State(config): State<Config>,
338 350 AuthUser(user): AuthUser,
339 351 Path(tier_id): Path<String>,
340 352 Form(form): Form<SubscribeForm>,
@@ -346,7 +358,7 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
346 358 .map_err(|_| AppError::NotFound)?;
347 359
348 360 // Get the tier (must be active and have Stripe IDs)
349 - let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tier_uuid)
361 + let tier = db::subscriptions::get_subscription_tier_by_id(&db, tier_uuid)
350 362 .await?
351 363 .ok_or(AppError::NotFound)?;
352 364
@@ -360,11 +372,11 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
360 372 // Get the project and creator
361 373 let tier_project_id = tier.project_id
362 374 .ok_or_else(|| AppError::BadRequest("This tier is not a project subscription".to_string()))?;
363 - let project = db::projects::get_project_by_id(&state.db, tier_project_id)
375 + let project = db::projects::get_project_by_id(&db, tier_project_id)
364 376 .await?
365 377 .ok_or(AppError::NotFound)?;
366 378
367 - let creator = db::users::get_user_by_id(&state.db, project.user_id)
379 + let creator = db::users::get_user_by_id(&db, project.user_id)
368 380 .await?
369 381 .ok_or(AppError::NotFound)?;
370 382
@@ -391,11 +403,11 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
391 403 }
392 404
393 405 // Check if user already has an active subscription to this project
394 - if db::subscriptions::has_access(&state.db, user.id, db::subscriptions::SubscriptionScope::Project(tier_project_id)).await? {
406 + if db::subscriptions::has_access(&db, user.id, db::subscriptions::SubscriptionScope::Project(tier_project_id)).await? {
395 407 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
396 408 }
397 409
398 - let stripe = state.stripe.as_ref()
410 + let stripe = payments.stripe.as_ref()
399 411 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
400 412
401 413 // Validate optional promo code for free trial
@@ -405,7 +417,7 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
405 417 if let Some(code_str) = form.promo_code.as_deref() {
406 418 let code_str = code_str.trim().to_uppercase();
407 419 if !code_str.is_empty() {
408 - let pc = db::promo_codes::get_promo_code_by_creator_and_code(&state.db, project.user_id, &code_str)
420 + let pc = db::promo_codes::get_promo_code_by_creator_and_code(&db, project.user_id, &code_str)
409 421 .await?
410 422 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
411 423
@@ -445,7 +457,7 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
445 457
446 458 // Reserve promo code use_count at checkout time to prevent concurrent over-use
447 459 if let Some(pc_id) = promo_code_id {
448 - let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
460 + let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
449 461 .await
450 462 .context("reserve promo code use at subscription checkout")?;
451 463 if !reserved {
@@ -454,8 +466,8 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
454 466 }
455 467
456 468 // Build URLs
457 - let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url);
458 - let cancel_url = format!("{}/p/{}", state.config.host_url, project.slug);
469 + let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", config.host_url);
470 + let cancel_url = format!("{}/p/{}", config.host_url, project.slug);
459 471
460 472 // Create the subscription checkout session on the connected account.
461 473 // If this fails, release the promo code reservation.
@@ -476,7 +488,7 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
476 488 Ok(s) => s,
477 489 Err(e) => {
478 490 if let Some(pc_id) = promo_code_id {
479 - db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
491 + db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id).await.ok();
480 492 }
481 493 return Err(e);
482 494 }
@@ -487,7 +499,7 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
487 499 // This row is deleted (not completed) when the subscription webhook fires.
488 500 if let Some(pc_id) = promo_code_id
489 501 && let Err(e) = db::transactions::create_subscription_pending_transaction(
490 - &state.db,
502 + &db,
491 503 user.id,
492 504 project.user_id,
493 505 tier_project_id,
@@ -496,7 +508,7 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
496 508 ).await
497 509 {
498 510 // If we can't create the pending row, release the reservation and fail.
499 - db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
511 + db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id).await.ok();
500 512 return Err(e).context("create subscription pending transaction for promo code");
501 513 }
502 514
@@ -11,12 +11,14 @@ use tower_sessions::Session;
11 11
12 12 use crate::{
13 13 auth::AuthUser,
14 + config::Config,
14 15 csrf,
15 16 db::{self, Cents},
16 17 error::{AppError, Result},
17 18 payments,
18 - AppState,
19 + Billing,
19 20 };
21 + use sqlx::PgPool;
20 22
21 23 /// Form data for tip checkout.
22 24 #[derive(Debug, Deserialize)]
@@ -38,8 +40,11 @@ pub(in crate::routes::stripe) struct TipForm {
38 40
39 41 /// POST /stripe/checkout/tip/{recipient_id} - Create a tip checkout session
40 42 #[tracing::instrument(skip_all, name = "stripe_checkout::create_tip_checkout")]
43 + #[allow(clippy::too_many_arguments)]
41 44 pub(in crate::routes::stripe) async fn create_tip_checkout(
42 - State(state): State<AppState>,
45 + State(db): State<PgPool>,
46 + State(payments): State<Billing>,
47 + State(config): State<Config>,
43 48 AuthUser(user): AuthUser,
44 49 session: Session,
45 50 headers: HeaderMap,
@@ -58,7 +63,7 @@ pub(in crate::routes::stripe) async fn create_tip_checkout(
58 63 let _validated = csrf::validate_token_consuming(&session, &token).await?;
59 64 user.check_not_sandbox()?;
60 65 user.check_not_suspended()?;
61 - let stripe = state.stripe.as_ref()
66 + let stripe = payments.stripe.as_ref()
62 67 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
63 68
64 69 // Parse recipient ID
@@ -81,7 +86,7 @@ pub(in crate::routes::stripe) async fn create_tip_checkout(
81 86 let amount_cents = form.amount_dollars * 100;
82 87
83 88 // Get recipient
84 - let recipient = db::users::get_user_by_id(&state.db, recipient_id)
89 + let recipient = db::users::get_user_by_id(&db, recipient_id)
85 90 .await?
86 91 .ok_or(AppError::NotFound)?;
87 92
@@ -109,7 +114,7 @@ pub(in crate::routes::stripe) async fn create_tip_checkout(
109 114 .and_then(|s| s.parse::<uuid::Uuid>().ok().map(db::ProjectId::from))
110 115 {
111 116 Some(pid) => {
112 - let project = db::projects::get_project_by_id(&state.db, pid)
117 + let project = db::projects::get_project_by_id(&db, pid)
113 118 .await?
114 119 .ok_or_else(|| AppError::BadRequest("Project not found".to_string()))?;
115 120 if project.user_id != recipient_id {
@@ -131,9 +136,9 @@ pub(in crate::routes::stripe) async fn create_tip_checkout(
131 136
132 137 let success_url = format!(
133 138 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
134 - state.config.host_url
139 + config.host_url
135 140 );
136 - let cancel_url = format!("{}/u/{}", state.config.host_url, recipient.username);
141 + let cancel_url = format!("{}/u/{}", config.host_url, recipient.username);
137 142
138 143 // Create checkout session
139 144 let session = stripe.create_tip_checkout_session(&payments::TipCheckoutParams {
@@ -152,7 +157,7 @@ pub(in crate::routes::stripe) async fn create_tip_checkout(
152 157 // Record pending tip
153 158 let session_id = session.id;
154 159 db::tips::create_tip(
155 - &state.db,
160 + &db,
156 161 user.id,
157 162 recipient_id,
158 163 project_id,
@@ -10,12 +10,14 @@ use tower_sessions::Session;
10 10
11 11 use crate::{
12 12 auth::AuthUser,
13 + config::Config,
13 14 csrf,
14 15 db,
15 16 error::{AppError, Result},
16 17 templates::StripeConnectDisclaimerTemplate,
17 - AppState,
18 + Billing,
18 19 };
20 + use sqlx::PgPool;
19 21
20 22 /// GET /stripe/connect: Show disclaimer page before Stripe onboarding.
21 23 #[tracing::instrument(skip_all, name = "stripe::connect_disclaimer")]
@@ -34,16 +36,18 @@ pub(super) async fn stripe_connect_disclaimer(
34 36 /// follow cross-origin redirects (Stripe doesn't send CORS headers).
35 37 #[tracing::instrument(skip_all, name = "stripe::connect_proceed")]
36 38 pub(super) async fn stripe_connect_proceed(
37 - State(state): State<AppState>,
39 + State(db): State<PgPool>,
40 + State(payments): State<Billing>,
41 + State(config): State<Config>,
38 42 AuthUser(user): AuthUser,
39 43 ) -> Result<Response> {
40 44 user.check_not_sandbox()?;
41 - let stripe = state.stripe.as_ref()
45 + let stripe = payments.stripe.as_ref()
42 46 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
43 47
44 48 // If the user already has a stripe_account_id (incomplete onboarding),
45 49 // reuse it instead of creating a new account.
46 - let existing = db::users::get_user_by_id(&state.db, user.id)
50 + let existing = db::users::get_user_by_id(&db, user.id)
47 51 .await?
48 52 .ok_or_else(|| AppError::BadRequest("User not found".to_string()))?;
49 53 let stripe_account_id = if let Some(acct_id) = existing.stripe_account_id.filter(|s| !s.is_empty()) {
@@ -57,7 +61,7 @@ pub(super) async fn stripe_connect_proceed(
57 61 // that races past the NULL-check above will get None back instead
58 62 // of creating a duplicate Stripe account entry.
59 63 if let Some(_updated) = db::users::try_set_stripe_account(
60 - &state.db,
64 + &db,
61 65 user.id,
62 66 &acct_id,
63 67 ).await? {
@@ -72,7 +76,7 @@ pub(super) async fn stripe_connect_proceed(
72 76 orphaned_account = %acct_id,
73 77 "stripe connect race: orphaned account created — delete manually in Stripe dashboard"
74 78 );
75 - db::users::get_user_by_id(&state.db, user.id)
79 + db::users::get_user_by_id(&db, user.id)
76 80 .await?
77 81 .and_then(|u| u.stripe_account_id.filter(|s| !s.is_empty()))
78 82 .ok_or_else(|| AppError::Internal(
@@ -81,8 +85,8 @@ pub(super) async fn stripe_connect_proceed(
81 85 }
82 86 };
83 87
84 - let return_url = format!("{}/stripe/connect/return", state.config.host_url);
85 - let refresh_url = format!("{}/stripe/connect/refresh", state.config.host_url);
88 + let return_url = format!("{}/stripe/connect/return", config.host_url);
89 + let refresh_url = format!("{}/stripe/connect/refresh", config.host_url);
86 90
87 91 let link_url = stripe
88 92 .create_account_link(&stripe_account_id, &return_url, &refresh_url)
@@ -2,14 +2,19 @@
2 2
3 3 use crate::{
4 4 db::{self, SubscriptionStatus},
5 + email::EmailClient,
5 6 error::{Result, ResultExt},
6 - helpers::{self, spawn_email},
7 - AppState,
7 + helpers,
8 + wam_client::WamClient,
8 9 };
10 + use sqlx::PgPool;
9 11
10 12 /// Handle invoice.payment_succeeded; update period, send renewal email (not first invoice)
11 13 pub(super) async fn handle_invoice_payment_succeeded(
12 - state: &AppState,
14 + db: &PgPool,
15 + bg: &crate::background::BackgroundTx,
16 + email: &EmailClient,
17 + wam: Option<&WamClient>,
13 18 invoice: &crate::payments::InvoiceView,
14 19 event_id: &str,
15 20 ) -> Result<()> {
@@ -25,23 +30,23 @@ pub(super) async fn handle_invoice_payment_succeeded(
25 30 // End-user SyncKit app subscription? Apply any pending storage-cap change
26 31 // and refresh the period. Only meaningful on renewals; the first invoice's
27 32 // cap was set at checkout.
28 - if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
33 + if db::synckit::get_subscription_by_stripe_id(db, &stripe_sub_id)
29 34 .await
30 35 .context("fetch app sync subscription by stripe id")?
31 36 .is_some()
32 37 {
33 38 db::synckit::update_app_sync_subscription_status(
34 - &state.db, &stripe_sub_id, "active", Some(invoice.period_end),
39 + db, &stripe_sub_id, "active", Some(invoice.period_end),
35 40 )
36 41 .await
37 42 .context("refresh app sync subscription period")?;
38 43 if is_renewal {
39 - db::synckit::apply_pending_storage_cap(&state.db, &stripe_sub_id)
44 + db::synckit::apply_pending_storage_cap(db, &stripe_sub_id)
40 45 .await
41 46 .context("apply pending storage cap")?;
42 47 }
43 48 if let Err(e) = db::subscriptions::log_subscription_event(
44 - &state.db, None, event_id, "invoice.payment_succeeded.synckit_app_sub",
49 + db, None, event_id, "invoice.payment_succeeded.synckit_app_sub",
45 50 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
46 51 ).await {
47 52 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -50,8 +55,8 @@ pub(super) async fn handle_invoice_payment_succeeded(
50 55 }
51 56
52 57 // SyncKit v2 developer subscription? Identified by the local sync_apps row.
53 - if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
54 - let mut tx = state.db.begin().await.context("begin synckit invoice.paid transaction")?;
58 + if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
59 + let mut tx = db.begin().await.context("begin synckit invoice.paid transaction")?;
55 60 // One guarded write for status + period; only reset usage if the app was
56 61 // live (a canceled app is refused, so a stray invoice.paid can't refresh
57 62 // period or usage on it). Raw Stripe period to the sealed writer.
@@ -61,7 +66,7 @@ pub(super) async fn handle_invoice_payment_succeeded(
61 66 }
62 67 tx.commit().await.context("commit synckit invoice.paid")?;
63 68 if let Err(e) = db::subscriptions::log_subscription_event(
64 - &state.db, None, event_id, "invoice.payment_succeeded.synckit",
69 + db, None, event_id, "invoice.payment_succeeded.synckit",
65 70 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
66 71 ).await {
67 72 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -70,10 +75,10 @@ pub(super) async fn handle_invoice_payment_succeeded(
70 75 }
71 76
72 77 // Check if this is a Fan+ subscription
73 - 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")? {
78 + if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(db, &stripe_sub_id).await.context("fetch fan+ by stripe id")? {
74 79 // Refresh period (guarded: a canceled Fan+ sub is left untouched). Raw
75 80 // Stripe period to the sealed writer, which drops a non-positive end.
76 - db::fan_plus::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end))).await.context("refresh fan+ period")?;
81 + db::fan_plus::apply_stripe_update(db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end))).await.context("refresh fan+ period")?;
77 82
78 83 // On renewal, generate a $5 platform-wide promo code and email it.
79 84 //
@@ -88,7 +93,7 @@ pub(super) async fn handle_invoice_payment_succeeded(
88 93 // skips, so a renewal issues at most one $5 credit.
89 94 if is_renewal
90 95 && let Some(claim) = db::promo_codes::try_claim_fan_plus_credit(
91 - &state.db, &stripe_sub_id, invoice.period_end,
96 + db, &stripe_sub_id, invoice.period_end,
92 97 )
93 98 .await
94 99 .context("claim fan+ credit issuance slot")?
@@ -105,7 +110,7 @@ pub(super) async fn handle_invoice_payment_succeeded(
105 110 let code = helpers::generate_key_code();
106 111 match db::promo_codes::issue_fan_plus_credit_code(
107 112 &claim,
108 - &state.db,
113 + db,
109 114 fan_sub.user_id,
110 115 code.as_str(),
111 116 period_end,
@@ -117,18 +122,21 @@ pub(super) async fn handle_invoice_payment_succeeded(
117 122 );
118 123
119 124 // Email the credit code (fire-and-forget)
120 - if let Ok(Some(user)) = db::users::get_user_by_id(&state.db, fan_sub.user_id).await {
125 + if let Ok(Some(user)) = db::users::get_user_by_id(db, fan_sub.user_id).await {
121 126 let code_str = code.to_string();
122 127 let expiry = period_end;
123 128 let user_email = user.email.clone();
124 129 let user_name = user.display_name.clone();
125 - spawn_email!(state, "Fan+ credit", |email| {
126 - email.send_fan_plus_credit(
130 + let email = email.clone();
131 + bg.spawn("Fan+ credit", async move {
132 + if let Err(e) = email.send_fan_plus_credit(
127 133 &user_email,
128 134 user_name.as_deref(),
129 135 &code_str,
130 136 expiry.as_ref(),
131 - )
137 + ).await {
138 + tracing::error!(error = ?e, "failed to send Fan+ credit");
139 + }
132 140 });
133 141 }
134 142 }
@@ -137,7 +145,7 @@ pub(super) async fn handle_invoice_payment_succeeded(
137 145 user_id = %fan_sub.user_id, error = ?e,
138 146 "failed to generate Fan+ monthly credit promo code"
139 147 );
140 - if let Some(ref wam) = state.wam {
148 + if let Some(wam) = wam {
141 149 let title = format!("Fan+ credit not issued: user {}", fan_sub.user_id);
142 150 let body = format!(
143 151 "Fan+ subscriber {} paid renewal but $5 credit promo code \
@@ -151,7 +159,7 @@ pub(super) async fn handle_invoice_payment_succeeded(
151 159 }
152 160
153 161 if let Err(e) = db::subscriptions::log_subscription_event(
154 - &state.db, None, event_id, "invoice.payment_succeeded.fan_plus",
162 + db, None, event_id, "invoice.payment_succeeded.fan_plus",
155 163 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
156 164 ).await {
157 165 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -160,11 +168,11 @@ pub(super) async fn handle_invoice_payment_succeeded(
160 168 }
161 169
162 170 // Check if this is a creator tier subscription
163 - 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")? {
164 - db::creator_tiers::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end))).await.context("refresh creator sub period")?;
171 + if let Some(_ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(db, &stripe_sub_id).await.context("fetch creator sub by stripe id")? {
172 + db::creator_tiers::apply_stripe_update(db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end))).await.context("refresh creator sub period")?;
165 173
166 174 if let Err(e) = db::subscriptions::log_subscription_event(
167 - &state.db, None, event_id, "invoice.payment_succeeded.creator_tier",
175 + db, None, event_id, "invoice.payment_succeeded.creator_tier",
168 176 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
169 177 ).await {
170 178 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -173,36 +181,39 @@ pub(super) async fn handle_invoice_payment_succeeded(
173 181 }
174 182
175 183 // Refresh period for fan subscriptions (guarded: canceled rows untouched).
176 - db::subscriptions::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end))).await.context("refresh subscription period")?;
184 + db::subscriptions::apply_stripe_update(db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end))).await.context("refresh subscription period")?;
177 185
178 186 // Send renewal email only for renewals (not the first invoice)
179 - let db_sub = db::subscriptions::get_subscription_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch subscription by stripe id")?;
187 + let db_sub = db::subscriptions::get_subscription_by_stripe_id(db, &stripe_sub_id).await.context("fetch subscription by stripe id")?;
180 188
181 189 if is_renewal
182 190 && let Some(ref db_sub) = db_sub
183 191 && let (Ok(Some(subscriber)), Ok(Some(tier))) = (
184 - db::users::get_user_by_id(&state.db, db_sub.subscriber_id).await,
185 - db::subscriptions::get_subscription_tier_by_id(&state.db, db_sub.tier_id).await,
192 + db::users::get_user_by_id(db, db_sub.subscriber_id).await,
193 + db::subscriptions::get_subscription_tier_by_id(db, db_sub.tier_id).await,
186 194 )
187 195 {
188 196 let price = helpers::format_price(tier.price_cents);
189 197 let sub_email = subscriber.email.clone();
190 198 let sub_name = subscriber.display_name.clone();
191 199 let tier_name = tier.name.clone();
192 - spawn_email!(state, "subscription renewed", |email| {
193 - email.send_subscription_renewed(
200 + let email = email.clone();
201 + bg.spawn("subscription renewed", async move {
202 + if let Err(e) = email.send_subscription_renewed(
194 203 &sub_email,
195 204 sub_name.as_deref(),
196 205 &tier_name,
197 206 &price,
198 - )
207 + ).await {
208 + tracing::error!(error = ?e, "failed to send subscription renewed");
209 + }
199 210 });
200 211 }
201 212
202 213 // Log event
203 214 let sub_id = db_sub.as_ref().map(|s| s.id);
204 215 if let Err(e) = db::subscriptions::log_subscription_event(
205 - &state.db, sub_id, event_id, "invoice.payment_succeeded",
216 + db, sub_id, event_id, "invoice.payment_succeeded",
206 217 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
207 218 ).await {
208 219 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -213,7 +224,8 @@ pub(super) async fn handle_invoice_payment_succeeded(
213 224
214 225 /// Handle invoice.payment_failed; set status to past_due
215 226 pub(super) async fn handle_invoice_payment_failed(
216 - state: &AppState,
227 + db: &PgPool,
228 + wam: Option<&WamClient>,
217 229 invoice: &crate::payments::InvoiceView,
218 230 event_id: &str,
219 231 ) -> Result<()> {
@@ -225,15 +237,15 @@ pub(super) async fn handle_invoice_payment_failed(
225 237 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing invoice payment failed");
226 238
227 239 // SyncKit v2 developer subscription? Mark suspended_unpaid.
228 - if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
229 - db::synckit_billing::apply_billing_update(&state.db, app_id, Some("suspended_unpaid"), None).await.context("synckit billing -> suspended_unpaid")?;
240 + if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
241 + db::synckit_billing::apply_billing_update(db, app_id, Some("suspended_unpaid"), None).await.context("synckit billing -> suspended_unpaid")?;
230 242 if let Err(e) = db::subscriptions::log_subscription_event(
231 - &state.db, None, event_id, "invoice.payment_failed.synckit",
243 + db, None, event_id, "invoice.payment_failed.synckit",
232 244 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
233 245 ).await {
234 246 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
235 247 }
236 - if let Some(ref wam) = state.wam {
248 + if let Some(wam) = wam {
237 249 let title = format!("SyncKit app payment failed: {app_id}");
238 250 wam.create_ticket(&title, None, "medium", "synckit-payment-failed", Some(&app_id.to_string())).await;
239 251 }
@@ -241,11 +253,11 @@ pub(super) async fn handle_invoice_payment_failed(
241 253 }
242 254
243 255 // Check if this is a Fan+ subscription
244 - 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")? {
245 - db::fan_plus::apply_stripe_update(&state.db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None).await.context("fan+ status -> past_due")?;
256 + if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(db, &stripe_sub_id).await.context("fetch fan+ by stripe id")? {
257 + db::fan_plus::apply_stripe_update(db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None).await.context("fan+ status -> past_due")?;
246 258
247 259 if let Err(e) = db::subscriptions::log_subscription_event(
248 - &state.db, None, event_id, "invoice.payment_failed.fan_plus",
260 + db, None, event_id, "invoice.payment_failed.fan_plus",
249 261 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
250 262 ).await {
251 263 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -254,12 +266,12 @@ pub(super) async fn handle_invoice_payment_failed(
254 266 }
255 267
256 268 // Check if this is a creator tier subscription
257 - 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")? {
258 - db::creator_tiers::apply_stripe_update(&state.db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None).await.context("creator sub status -> past_due")?;
259 - db::creator_tiers::sync_user_creator_tier(&state.db, ct_sub.user_id).await.context("sync user creator tier")?;
269 + if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(db, &stripe_sub_id).await.context("fetch creator sub by stripe id")? {
270 + db::creator_tiers::apply_stripe_update(db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None).await.context("creator sub status -> past_due")?;
271 + db::creator_tiers::sync_user_creator_tier(db, ct_sub.user_id).await.context("sync user creator tier")?;
260 272
261 273 if let Err(e) = db::subscriptions::log_subscription_event(
262 - &state.db, None, event_id, "invoice.payment_failed.creator_tier",
274 + db, None, event_id, "invoice.payment_failed.creator_tier",
263 275 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
264 276 ).await {
265 277 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -267,19 +279,19 @@ pub(super) async fn handle_invoice_payment_failed(
267 279 return Ok(());
268 280 }
269 281
270 - let updated = db::subscriptions::apply_stripe_update(&state.db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None).await.context("subscription status -> past_due")?;
282 + let updated = db::subscriptions::apply_stripe_update(db, &stripe_sub_id, Some(SubscriptionStatus::PastDue), None).await.context("subscription status -> past_due")?;
271 283
272 284 // Log event
273 285 let sub_id = updated.as_ref().map(|s| s.id);
274 286 if let Err(e) = db::subscriptions::log_subscription_event(
275 - &state.db, sub_id, event_id, "invoice.payment_failed",
287 + db, sub_id, event_id, "invoice.payment_failed",
276 288 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
277 289 ).await {
278 290 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
279 291 }
280 292
281 293 // Create WAM ticket for subscription payment failures
282 - if let Some(ref wam) = state.wam {
294 + if let Some(wam) = wam {
283 295 let title = format!("Subscription payment failed: {stripe_sub_id}");
284 296 wam.create_ticket(&title, None, "medium", "subscription-payment-failed", Some(&stripe_sub_id)).await;
285 297 }
@@ -327,7 +339,7 @@ async fn revoke_refunded_transaction(
327 339 /// `status = 'completed'` transition guard means re-delivery is a no-op, and a
328 340 /// later `charge.refunded` for the same refund finds nothing left to mark.
329 341 pub(super) async fn handle_refund_created(
330 - state: &AppState,
342 + db: &PgPool,
331 343 refund: &crate::payments::RefundView,
332 344 ) -> Result<()> {
333 345 if !refund.is_succeeded() {
@@ -341,7 +353,7 @@ pub(super) async fn handle_refund_created(
341 353 return Ok(());
342 354 };
343 355
344 - let mut db_tx = state.db.begin().await.context("begin line refund")?;
356 + let mut db_tx = db.begin().await.context("begin line refund")?;
345 357 let refunded = db::transactions::refund_transaction_by_id(&mut *db_tx, tx_id)
346 358 .await
347 359 .context("refund transaction by id")?;
@@ -376,7 +388,7 @@ pub(super) async fn handle_refund_created(
376 388 /// NULL`); instead we signal "still unmatched" so the caller releases the claim
377 389 /// and the stale sweep escalates it (MINOR, Run #23).
378 390 pub(super) async fn handle_charge_refunded(
379 - state: &AppState,
391 + db: &PgPool,
380 392 refund_data: &crate::payments::ChargeRefundData,
381 393 requeue_if_unmatched: bool,
382 394 ) -> Result<()> {
@@ -398,7 +410,7 @@ pub(super) async fn handle_charge_refunded(
398 410 return Ok(());
399 411 }
400 412
401 - let mut db_tx = state.db.begin().await.context("begin refund transaction")?;
413 + let mut db_tx = db.begin().await.context("begin refund transaction")?;
402 414
403 415 // Mark transactions as refunded and get their IDs + item_ids
404 416 // (cart checkouts can have multiple transactions per payment_intent_id)
@@ -425,7 +437,7 @@ pub(super) async fn handle_charge_refunded(
425 437 );
426 438 } else {
427 439 // No transaction found — check if this was a tip refund
428 - let tip_refunded = db::tips::refund_tip_by_payment_intent(&state.db, payment_intent_id)
440 + let tip_refunded = db::tips::refund_tip_by_payment_intent(db, payment_intent_id)
429 441 .await
430 442 .inspect_err(|e| {
431 443 tracing::error!(
@@ -437,7 +449,7 @@ pub(super) async fn handle_charge_refunded(
437 449 .context("refund tip")?;
438 450 if tip_refunded {
439 451 tracing::info!(payment_intent_id = %payment_intent_id, "tip refund processed");
440 - } else if db::transactions::transaction_exists_for_payment_intent(&state.db, payment_intent_id)
452 + } else if db::transactions::transaction_exists_for_payment_intent(db, payment_intent_id)
441 453 .await
442 454 .context("check transaction existence for refund")?
443 455 {
@@ -457,7 +469,7 @@ pub(super) async fn handle_charge_refunded(
457 469 "no transaction or tip found — queuing as pending refund"
458 470 );
459 471 db::pending_refunds::insert_pending_refund(
460 - &state.db,
472 + db,
461 473 payment_intent_id,
462 474 refund_data.amount.as_i64(),
463 475 refund_data.amount_refunded.as_i64(),
@@ -1,12 +1,16 @@
1 1 //! Webhook handlers for checkout.session.completed events.
2 2
3 3 use crate::{
4 + config::Config,
4 5 db,
6 + email::EmailClient,
5 7 error::{AppError, Result, ResultExt},
6 - helpers::{self, spawn_email},
8 + helpers,
7 9 payments::{CheckoutMetadata, CreatorTierCheckoutMetadata, FanPlusCheckoutMetadata, SubscriptionCheckoutMetadata, SynckitAppSubCheckoutMetadata, TipCheckoutMetadata},
8 - AppState,
10 + wam_client::WamClient,
11 + Billing,
9 12 };
13 + use sqlx::PgPool;
10 14
11 15 use super::checkout_helpers::{
12 16 check_pending_refund, finalize_guest_transaction, finalize_purchase_transaction,
@@ -20,12 +24,14 @@ use super::checkout_helpers::{
20 24 /// SERIOUS). Shared by the purchase, cart, and guest completion handlers so the
21 25 /// three can't drift.
22 26 async fn escalate_if_orphaned_session(
23 - state: &AppState,
27 + db: &PgPool,
28 + bg: &crate::background::BackgroundTx,
29 + wam: Option<&WamClient>,
24 30 session_id: &str,
25 31 payment_intent_id: &str,
26 32 label: &str,
27 33 ) -> Result<()> {
28 - let exists = db::transactions::transaction_exists_for_checkout_session(&state.db, session_id)
34 + let exists = db::transactions::transaction_exists_for_checkout_session(db, session_id)
29 35 .await
30 36 .context("check session transaction existence")?;
31 37 if exists {
@@ -35,7 +41,7 @@ async fn escalate_if_orphaned_session(
35 41 session_id = %session_id, payment_intent_id = %payment_intent_id,
36 42 "orphaned paid session ({label}): payment completed but no transaction exists — manual reconciliation required"
37 43 );
38 - if let Some(wam) = state.wam.clone() {
44 + if let Some(wam) = wam.cloned() {
39 45 let sid = session_id.to_string();
40 46 let pi = payment_intent_id.to_string();
41 47 let label = label.to_string();
@@ -44,7 +50,7 @@ async fn escalate_if_orphaned_session(
44 50 // about a charged-but-undelivered buyer, so it must not be dropped
45 51 // when the process is restarted mid-flight during a deploy (Run 9
46 52 // pattern; audit Run 22).
47 - state.bg.spawn("stripe orphaned-session ticket", async move {
53 + bg.spawn("stripe orphaned-session ticket", async move {
48 54 let body = format!(
49 55 "Checkout session {sid} (payment_intent {pi}, {label}) completed at Stripe but has \
50 56 NO transaction — the pending row(s) were never created. The buyer was charged and \
@@ -67,7 +73,8 @@ async fn escalate_if_orphaned_session(
67 73 /// authoritative either way. Shared by the purchase, cart, and guest completion
68 74 /// handlers so the three can't drift.
69 75 fn reconcile_checkout_amount(
70 - state: &AppState,
76 + bg: &crate::background::BackgroundTx,
77 + wam: Option<&WamClient>,
71 78 session_id: &str,
72 79 session: &crate::payments::CheckoutSessionView,
73 80 credited_cents: i64,
@@ -82,11 +89,11 @@ fn reconcile_checkout_amount(
82 89 session_id = %session_id, currency = %currency,
83 90 "checkout session currency is not USD ({label}); integer-cents reconciliation skipped"
84 91 );
85 - if let Some(wam) = state.wam.clone() {
92 + if let Some(wam) = wam.cloned() {
86 93 let session_id = session_id.to_string();
87 94 let currency = currency.to_string();
88 95 let label = label.to_string();
89 - state.bg.spawn("stripe non-usd-session ticket", async move {
96 + bg.spawn("stripe non-usd-session ticket", async move {
90 97 let body = format!(
91 98 "Checkout session {session_id} ({label}) settled in {currency}, not USD. The \
92 99 server credits its own USD amount, but investigate how a non-USD session was created."
@@ -104,10 +111,10 @@ fn reconcile_checkout_amount(
104 111 session_id = %session_id, credited_cents = %credited_cents, stripe_subtotal_cents = %subtotal,
105 112 "checkout amount mismatch ({label}): credited amount differs from Stripe session subtotal"
106 113 );
107 - if let Some(wam) = state.wam.clone() {
114 + if let Some(wam) = wam.cloned() {
108 115 let session_id = session_id.to_string();
109 116 let label = label.to_string();
110 - state.bg.spawn("stripe amount-mismatch ticket", async move {
117 + bg.spawn("stripe amount-mismatch ticket", async move {
111 118 let body = format!(
112 119 "Credited amount {credited_cents} cents != Stripe session subtotal {subtotal} cents \
113 120 (session {session_id}, {label}). The server amount is authoritative; investigate a \
@@ -122,7 +129,11 @@ fn reconcile_checkout_amount(
122 129 /// Handle checkout.session.completed for one-time purchases
123 130 #[tracing::instrument(skip_all, name = "stripe::handle_purchase_checkout")]
124 131 pub(super) async fn handle_purchase_checkout_completed(
125 - state: &AppState,
132 + db: &PgPool,
133 + bg: &crate::background::BackgroundTx,
134 + email: &EmailClient,
135 + wam: Option<&WamClient>,
136 + config: &Config,
126 137 session: &crate::payments::CheckoutSessionView,
127 138 event_id: &str,
128 139 ) -> Result<()> {
@@ -148,7 +159,7 @@ pub(super) async fn handle_purchase_checkout_completed(
148 159 // Complete the transaction (idempotent - returns None if already completed).
149 160 // Steps 1-3 (complete_transaction, increment_sales_count, discount code increment)
150 161 // are wrapped in a single DB transaction to prevent inconsistent state if any step fails.
151 - let mut db_tx = state.db.begin().await.context("begin purchase webhook transaction")?;
162 + let mut db_tx = db.begin().await.context("begin purchase webhook transaction")?;
152 163
153 164 match db::transactions::complete_transaction(&mut *db_tx, &session_id, session.payment_intent.as_deref()).await {
154 165 Ok(Some(tx)) => {
@@ -159,7 +170,7 @@ pub(super) async fn handle_purchase_checkout_completed(
159 170
160 171 // Defense-in-depth reconciliation (currency + subtotal) against the
161 172 // server-authoritative credited amount.
162 - reconcile_checkout_amount(state, &session_id, session, i64::from(tx.amount_cents), "purchase");
173 + reconcile_checkout_amount(bg, wam, &session_id, session, i64::from(tx.amount_cents), "purchase");
163 174
164 175 // Increment denormalized sales_count (inside transaction)
165 176 if let Some(iid) = item_id {
@@ -177,10 +188,10 @@ pub(super) async fn handle_purchase_checkout_completed(
177 188 // --- Secondary effects below (outside transaction) ---
178 189 // Consolidated in one re-runnable finalizer so the purchase and cart
179 190 // paths can't drift and a crash-recovery redelivery re-runs safely.
180 - finalize_purchase_transaction(state, &tx, buyer_id, seller_id).await;
191 + finalize_purchase_transaction(db, bg, email, wam, config, &tx, buyer_id, seller_id).await;
181 192
182 193 if let Err(e) = db::subscriptions::log_subscription_event(
183 - &state.db, None, event_id, "checkout.session.completed.purchase",
194 + db, None, event_id, "checkout.session.completed.purchase",
184 195 &serde_json::json!({"session_id": session_id}),
185 196 ).await {
186 197 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -188,7 +199,7 @@ pub(super) async fn handle_purchase_checkout_completed(
188 199
189 200 // Check for a pending refund that arrived before this payment webhook.
190 201 // If found, process it now that the transaction is completed.
191 - check_pending_refund(state, &payment_intent_id).await;
202 + check_pending_refund(db, &payment_intent_id).await;
192 203 }
193 204 Ok(None) => {
194 205 // No row flipped to completed. Either this is a benign duplicate of
@@ -198,18 +209,18 @@ pub(super) async fn handle_purchase_checkout_completed(
198 209 // key / no splits. Re-fetch completed rows for the session and
199 210 // re-run the idempotent finalizer; only escalate if none exist
200 211 // (genuinely orphaned: payment took, rows never created).
201 - let completed = db::transactions::get_completed_transactions_for_session(&state.db, &session_id)
212 + let completed = db::transactions::get_completed_transactions_for_session(db, &session_id)
202 213 .await
203 214 .context("re-fetch completed transactions for crash recovery")?;
204 215 if completed.is_empty() {
205 - escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "transaction").await?;
216 + escalate_if_orphaned_session(db, bg, wam, &session_id, &payment_intent_id, "transaction").await?;
206 217 } else {
207 218 for tx in &completed {
208 219 tracing::info!(
209 220 session_id = %session_id, transaction_id = %tx.id,
210 221 "crash-recovery: re-running finalize for already-completed session"
211 222 );
212 - finalize_purchase_transaction(state, tx, buyer_id, seller_id).await;
223 + finalize_purchase_transaction(db, bg, email, wam, config, tx, buyer_id, seller_id).await;
213 224 }
214 225 }
215 226 }
@@ -225,7 +236,11 @@ pub(super) async fn handle_purchase_checkout_completed(
225 236 /// Handle checkout.session.completed for cart (multi-item) purchases
226 237 #[tracing::instrument(skip_all, name = "stripe::handle_cart_checkout")]
227 238 pub(super) async fn handle_cart_checkout_completed(
228 - state: &AppState,
239 + db: &PgPool,
240 + bg: &crate::background::BackgroundTx,
241 + email: &EmailClient,
242 + wam: Option<&WamClient>,
243 + config: &Config,
229 244 session: &crate::payments::CheckoutSessionView,
230 245 event_id: &str,
231 246 ) -> Result<()> {
@@ -242,7 +257,7 @@ pub(super) async fn handle_cart_checkout_completed(
242 257 let payment_intent_id = session.payment_intent.clone().unwrap_or_default();
243 258
244 259 // Complete ALL pending transactions for this session in a single DB transaction
245 - let mut db_tx = state.db.begin().await.context("begin cart webhook transaction")?;
260 + let mut db_tx = db.begin().await.context("begin cart webhook transaction")?;
246 261
247 262 let completed_txs = db::transactions::complete_cart_transactions(
248 263 &mut *db_tx, &session_id, session.payment_intent.as_deref(),
@@ -255,18 +270,18 @@ pub(super) async fn handle_cart_checkout_completed(
255 270 // completed rows for the session and re-run the idempotent finalizer
256 271 // before falling through to orphan escalation.
257 272 db_tx.commit().await.ok();
258 - let completed = db::transactions::get_completed_transactions_for_session(&state.db, &session_id)
273 + let completed = db::transactions::get_completed_transactions_for_session(db, &session_id)
259 274 .await
260 275 .context("re-fetch completed cart transactions for crash recovery")?;
261 276 if completed.is_empty() {
262 - escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "cart transactions").await?;
277 + escalate_if_orphaned_session(db, bg, wam, &session_id, &payment_intent_id, "cart transactions").await?;
263 278 } else {
264 279 for tx in &completed {
265 280 tracing::info!(
266 281 session_id = %session_id, transaction_id = %tx.id,
267 282 "crash-recovery: re-running finalize for already-completed cart session"
268 283 );
269 - finalize_purchase_transaction(state, tx, buyer_id, seller_id).await;
284 + finalize_purchase_transaction(db, bg, email, wam, config, tx, buyer_id, seller_id).await;
270 285 }
271 286 }
272 287 return Ok(());
@@ -280,7 +295,7 @@ pub(super) async fn handle_cart_checkout_completed(
280 295 // Defense-in-depth reconciliation (currency + subtotal): the sum of the
281 296 // credited transactions should equal Stripe's pre-tax subtotal.
282 297 let cart_credited: i64 = completed_txs.iter().map(|tx| i64::from(tx.amount_cents)).sum();
283 - reconcile_checkout_amount(state, &session_id, session, cart_credited, "cart");
298 + reconcile_checkout_amount(bg, wam, &session_id, session, cart_credited, "cart");
284 299
285 300 // Increment sales count for each item
286 301 for tx in &completed_txs {
@@ -295,7 +310,7 @@ pub(super) async fn handle_cart_checkout_completed(
295 310
296 311 // Remove purchased items from cart (items stay in cart until payment succeeds,
297 312 // so cancelled checkouts don't lose cart contents)
298 - db::cart::remove_seller_items_from_cart(&state.db, buyer_id, seller_id)
313 + db::cart::remove_seller_items_from_cart(db, buyer_id, seller_id)
299 314 .await
300 315 .context("remove cart items after successful payment")?;
301 316
@@ -305,19 +320,19 @@ pub(super) async fn handle_cart_checkout_completed(
305 320 // redelivery. (clear_contact_revocation runs per-tx that opted in, which is
306 321 // a no-op once already cleared.)
307 322 for tx in &completed_txs {
308 - finalize_purchase_transaction(state, tx, buyer_id, seller_id).await;
323 + finalize_purchase_transaction(db, bg, email, wam, config, tx, buyer_id, seller_id).await;
309 324 }
310 325
311 326 // Log event
312 327 if let Err(e) = db::subscriptions::log_subscription_event(
313 - &state.db, None, event_id, "checkout.session.completed.cart",
328 + db, None, event_id, "checkout.session.completed.cart",
314 329 &serde_json::json!({"session_id": session_id, "item_count": completed_txs.len()}),
315 330 ).await {
316 331 tracing::warn!(event_id = %event_id, error = ?e, "failed to log cart checkout event");
317 332 }
318 333
319 334 // Check for pending refund
320 - check_pending_refund(state, &payment_intent_id).await;
335 + check_pending_refund(db, &payment_intent_id).await;
321 336
322 337 Ok(())
323 338 }
@@ -325,7 +340,9 @@ pub(super) async fn handle_cart_checkout_completed(
325 340 /// Handle checkout.session.completed for subscriptions
326 341 #[tracing::instrument(skip_all, name = "stripe::handle_subscription_checkout")]
327 342 pub(super) async fn handle_subscription_checkout_completed(
328 - state: &AppState,
343 + db: &PgPool,
344 + bg: &crate::background::BackgroundTx,
345 + email: &EmailClient,
329 346 session: &crate::payments::CheckoutSessionView,
330 347 event_id: &str,
331 348 ) -> Result<()> {
@@ -353,7 +370,7 @@ pub(super) async fn handle_subscription_checkout_completed(
353 370 })?;
354 371
355 372 // Create the subscription record + increment promo code in a single transaction.
356 - let mut tx = state.db.begin().await.context("begin subscription webhook transaction")?;
373 + let mut tx = db.begin().await.context("begin subscription webhook transaction")?;
357 374
358 375 let sub = match db::subscriptions::create_subscription(
359 376 &mut tx,
@@ -392,29 +409,32 @@ pub(super) async fn handle_subscription_checkout_completed(
392 409
393 410 // Send subscription started email (fire-and-forget)
394 411 if let (Ok(Some(subscriber)), Ok(Some(tier)), Ok(Some(project))) = (
395 - db::users::get_user_by_id(&state.db, subscriber_id).await,
396 - db::subscriptions::get_subscription_tier_by_id(&state.db, tier_id).await,
397 - db::projects::get_project_by_id(&state.db, project_id).await,
412 + db::users::get_user_by_id(db, subscriber_id).await,
413 + db::subscriptions::get_subscription_tier_by_id(db, tier_id).await,
414 + db::projects::get_project_by_id(db, project_id).await,
398 415 ) {
399 416 let price = helpers::format_price(tier.price_cents);
400 417 let sub_email = subscriber.email.clone();
401 418 let sub_name = subscriber.display_name.clone();
402 419 let tier_name = tier.name.clone();
403 420 let project_title = project.title.clone();
404 - spawn_email!(state, "subscription started", |email| {
405 - email.send_subscription_started(
421 + let email = email.clone();
422 + bg.spawn("subscription started", async move {
423 + if let Err(e) = email.send_subscription_started(
406 424 &sub_email,
407 425 sub_name.as_deref(),
408 426 &tier_name,
409 427 &project_title,
410 428 &price,
411 - )
429 + ).await {
430 + tracing::error!(error = ?e, "failed to send subscription started");
431 + }
412 432 });
413 433 }
414 434
415 435 // Log event
416 436 if let Err(e) = db::subscriptions::log_subscription_event(
417 - &state.db, Some(sub.id), event_id, "checkout.session.completed.subscription",
437 + db, Some(sub.id), event_id, "checkout.session.completed.subscription",
418 438 &serde_json::json!({"session_id": session_id, "stripe_subscription_id": stripe_subscription_id}),
419 439 ).await {
420 440 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -426,7 +446,9 @@ pub(super) async fn handle_subscription_checkout_completed(
426 446 /// Handle checkout.session.completed for Fan+ subscriptions
427 447 #[tracing::instrument(skip_all, name = "stripe::handle_fan_plus_checkout")]
428 448 pub(super) async fn handle_fan_plus_checkout_completed(
429 - state: &AppState,
449 + db: &PgPool,
450 + bg: &crate::background::BackgroundTx,
451 + email: &EmailClient,
430 452 session: &crate::payments::CheckoutSessionView,
431 453 event_id: &str,
432 454 ) -> Result<()> {
@@ -455,7 +477,7 @@ pub(super) async fn handle_fan_plus_checkout_completed(
455 477 // nothing and RETURNING yields no row (-> None below, "already exists"); a
456 478 // genuine re-subscribe updates in place.
457 479 let sub = match db::fan_plus::create_fan_plus_subscription(
458 - &state.db, user_id, &stripe_subscription_id, &stripe_customer_id,
480 + db, user_id, &stripe_subscription_id, &stripe_customer_id,
459 481 ).await
460 482 .with_context(|| format!("create Fan+ subscription for user {user_id}"))? {
461 483 Some(sub) => sub,
@@ -471,17 +493,20 @@ pub(super) async fn handle_fan_plus_checkout_completed(
471 493 );
472 494
473 495 // Send welcome email (fire-and-forget)
474 - if let Ok(Some(user)) = db::users::get_user_by_id(&state.db, user_id).await {
496 + if let Ok(Some(user)) = db::users::get_user_by_id(db, user_id).await {
475 497 let user_email = user.email.clone();
476 498 let user_name = user.display_name.clone();
477 - spawn_email!(state, "Fan+ welcome", |email| {
478 - email.send_fan_plus_welcome(&user_email, user_name.as_deref())
499 + let email = email.clone();
500 + bg.spawn("Fan+ welcome", async move {
501 + if let Err(e) = email.send_fan_plus_welcome(&user_email, user_name.as_deref()).await {
502 + tracing::error!(error = ?e, "failed to send Fan+ welcome");
503 + }
479 504 });
480 505 }
481 506
482 507 // Log event
483 508 if let Err(e) = db::subscriptions::log_subscription_event(
484 - &state.db, None, event_id, "checkout.session.completed.fan_plus",
509 + db, None, event_id, "checkout.session.completed.fan_plus",
485 510 &serde_json::json!({"session_id": session_id, "stripe_subscription_id": stripe_subscription_id}),
486 511 ).await {
487 512 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -493,7 +518,10 @@ pub(super) async fn handle_fan_plus_checkout_completed(
493 518 /// Handle checkout.session.completed for creator tier subscriptions
494 519 #[tracing::instrument(skip_all, name = "stripe::handle_creator_tier_checkout")]
495 520 pub(super) async fn handle_creator_tier_checkout_completed(
496 - state: &AppState,
521 + db: &PgPool,
522 + bg: &crate::background::BackgroundTx,
523 + wam: Option<&WamClient>,
524 + payments: &Billing,
497 525 session: &crate::payments::CheckoutSessionView,
498 526 event_id: &str,
499 527 ) -> Result<()> {
@@ -525,7 +553,7 @@ pub(super) async fn handle_creator_tier_checkout_completed(
525 553 // row (-> None below); a genuine tier-switch or re-subscribe overwrites the
526 554 // row with the new subscription id and flips it active.
527 555 let sub = match db::creator_tiers::create_creator_subscription(
528 - &state.db, user_id, &stripe_subscription_id, &stripe_customer_id, tier,
556 + db, user_id, &stripe_subscription_id, &stripe_customer_id, tier,
529 557 ).await
530 558 .with_context(|| format!("create creator tier subscription for user {user_id}"))? {
531 559 Some(sub) => sub,
@@ -536,12 +564,12 @@ pub(super) async fn handle_creator_tier_checkout_completed(
536 564 };
537 565
538 566 // Sync the denormalized creator_tier column on users
539 - db::creator_tiers::sync_user_creator_tier(&state.db, user_id)
567 + db::creator_tiers::sync_user_creator_tier(db, user_id)
540 568 .await
541 569 .with_context(|| format!("sync creator tier for user {user_id}"))?;
542 570
543 571 // Auto-unhide: restore items hidden by post-grace enforcement
544 - match db::items::unhide_all_items_for_user(&state.db, user_id).await {
572 + match db::items::unhide_all_items_for_user(db, user_id).await {
545 573 Ok(count) if count > 0 => {
546 574 tracing::info!(user_id = %user_id, items_unhidden = count, "auto-unhidden items after tier re-subscription");
547 575 }
@@ -553,10 +581,10 @@ pub(super) async fn handle_creator_tier_checkout_completed(
553 581
554 582 // Auto-unpause: if this creator was paused and just re-subscribed, clear the pause
555 583 // and un-cancel any fan subscriptions that haven't expired yet.
556 - if let Ok(Some(db_user)) = db::users::get_user_by_id(&state.db, user_id).await
584 + if let Ok(Some(db_user)) = db::users::get_user_by_id(db, user_id).await
557 585 && db_user.is_creator_paused()
558 586 {
559 - db::users::unpause_creator(&state.db, user_id)
587 + db::users::unpause_creator(db, user_id)
560 588 .await
561 589 .with_context(|| format!("unpause creator {user_id}"))?;
562 590
@@ -564,18 +592,18 @@ pub(super) async fn handle_creator_tier_checkout_completed(
564 592 // out on the background queue: doing it inline here let a creator with
565 593 // many fans stall the webhook past Stripe's delivery timeout, which
566 594 // triggers a retry that re-runs the whole loop.
567 - if let (Some(stripe), Some(stripe_account_id)) = (&state.stripe, &db_user.stripe_account_id) {
568 - let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, user_id)
595 + if let (Some(stripe), Some(stripe_account_id)) = (&payments.stripe, &db_user.stripe_account_id) {
596 + let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(db, user_id)
569 597 .await
570 598 .with_context(|| format!("fetch active fan subs for unpause {user_id}"))?;
571 599 let ids = fan_subs.into_iter().map(|s| s.stripe_subscription_id).collect();
572 600 crate::payments::fan_ops::spawn_fan_sub_fanout(
573 - &state.bg,
601 + bg,
574 602 std::sync::Arc::clone(stripe),
575 603 stripe_account_id.clone(),
576 604 ids,
577 605 crate::payments::fan_ops::FanSubOp::CancelAtPeriodEnd(false),
578 - state.wam.clone(),
606 + wam.cloned(),
579 607 );
580 608 }
581 609
@@ -589,7 +617,7 @@ pub(super) async fn handle_creator_tier_checkout_completed(
589 617
590 618 // Log event
591 619 if let Err(e) = db::subscriptions::log_subscription_event(
592 - &state.db, None, event_id, "checkout.session.completed.creator_tier",
620 + db, None, event_id, "checkout.session.completed.creator_tier",
593 621 &serde_json::json!({
594 622 "session_id": session_id,
595 623 "stripe_subscription_id": stripe_subscription_id,
@@ -605,7 +633,11 @@ pub(super) async fn handle_creator_tier_checkout_completed(
605 633 /// Handle checkout.session.completed for tips
606 634 #[tracing::instrument(skip_all, name = "stripe::handle_tip_checkout")]
607 635 pub(super) async fn handle_tip_checkout_completed(
608 - state: &AppState,
636 + db: &PgPool,
637 + bg: &crate::background::BackgroundTx,
638 + email: &EmailClient,
639 + wam: Option<&WamClient>,
640 + config: &Config,
609 641 session: &crate::payments::CheckoutSessionView,
610 642 event_id: &str,
611 643 ) -> Result<()> {
@@ -618,7 +650,7 @@ pub(super) async fn handle_tip_checkout_completed(
618 650
619 651 // Complete the tip (idempotent). A PI-less session stores NULL (not a literal
620 652 // "unknown") in the money-keyed lookup column (Run 9).
621 - match db::tips::complete_tip(&state.db, &session_id, session.payment_intent.as_deref())
653 + match db::tips::complete_tip(db, &session_id, session.payment_intent.as_deref())
622 654 .await
623 655 .context("complete tip")? {
624 656 Some(tip) => {
@@ -631,7 +663,7 @@ pub(super) async fn handle_tip_checkout_completed(
631 663 // handlers (record_tip_splits mints split revenue, so an event-id
632 664 // ledger entry matters for reconciliation). MINOR, Run #2 Payments.
633 665 if let Err(e) = db::subscriptions::log_subscription_event(
634 - &state.db, None, event_id, "checkout.session.completed.tip",
666 + db, None, event_id, "checkout.session.completed.tip",
635 667 &serde_json::json!({"session_id": session_id, "tip_id": tip.id}),
636 668 ).await {
637 669 tracing::warn!(event_id = %event_id, error = ?e, "failed to log tip event");
@@ -639,11 +671,11 @@ pub(super) async fn handle_tip_checkout_completed(
639 671
640 672 // Record revenue splits if the tip's project has members
641 673 if let Some(project_id) = tip.project_id {
642 - record_tip_splits(state, tip.id, project_id, tip.amount_cents).await;
674 + record_tip_splits(db, tip.id, project_id, tip.amount_cents).await;
643 675 }
644 676
645 677 // Send tip notification email (fire-and-forget)
646 - send_tip_email(state, &tip, tipper_id, recipient_id);
678 + send_tip_email(db, bg, email, config, &tip, tipper_id, recipient_id);
647 679 }
648 680 None => {
649 681 // No pending row flipped to completed. Either a benign duplicate of
@@ -652,7 +684,7 @@ pub(super) async fn handle_tip_checkout_completed(
652 684 // collaborators hold a completed tip with no split rows. Re-fetch the
653 685 // tip and re-run the idempotent split write (ON CONFLICT DO NOTHING,
654 686 // migration 163); a genuine duplicate is a no-op. Run 20 Payments.
655 - let recovered = db::tips::get_tip_by_session(&state.db, &session_id)
687 + let recovered = db::tips::get_tip_by_session(db, &session_id)
656 688 .await
657 689 .context("re-fetch tip for crash recovery")?;
658 690 match recovered {
@@ -665,7 +697,7 @@ pub(super) async fn handle_tip_checkout_completed(
665 697 session_id = %session_id, tip_id = %tip.id,
666 698 "crash-recovery: re-running tip splits for already-completed tip"
667 699 );
668 - record_tip_splits(state, tip.id, project_id, tip.amount_cents).await;
700 + record_tip_splits(db, tip.id, project_id, tip.amount_cents).await;
669 701 } else {
670 702 tracing::info!(session_id = %session_id, "tip already completed, ignoring duplicate webhook");
671 703 }
@@ -682,13 +714,13 @@ pub(super) async fn handle_tip_checkout_completed(
682 714 session_id = %session_id, payment_intent_id = %pi,
683 715 "orphaned paid session (tip): payment completed but no tip row exists — manual reconciliation required"
684 716 );
685 - if let Some(wam) = state.wam.clone() {
717 + if let Some(wam) = wam.cloned() {
686 718 let sid = session_id.clone();
687 719 let pi = pi.to_string();
688 720 // Drained background pool, not a raw tokio::spawn: a
689 721 // charged-but-undelivered tip ticket must survive a
690 722 // mid-deploy restart (audit Run 22).
691 - state.bg.spawn("stripe orphaned-tip ticket", async move {
723 + bg.spawn("stripe orphaned-tip ticket", async move {
692 724 let body = format!(
693 725 "Tip checkout session {sid} (payment_intent {pi}) completed at Stripe but has \
694 726 NO tip row — the pending tip was never created. The tipper was charged and the \
@@ -711,7 +743,11 @@ pub(super) async fn handle_tip_checkout_completed(
711 743 /// auto-attaches to an existing account if the email matches.
712 744 #[tracing::instrument(skip_all, name = "stripe::handle_guest_checkout")]
713 745 pub(super) async fn handle_guest_checkout_completed(
714 - state: &AppState,
746 + db: &PgPool,
747 + bg: &crate::background::BackgroundTx,
748 + email: &EmailClient,
749 + wam: Option<&WamClient>,
750 + config: &Config,
715 751 session: &crate::payments::CheckoutSessionView,
716 752 _event_id: &str,
717 753 ) -> Result<()> {
@@ -735,7 +771,7 @@ pub(super) async fn handle_guest_checkout_completed(
735 771
736 772 // Complete the guest transaction and increment sales count in a single DB transaction
737 773 // (matching the non-guest path pattern to prevent counter drift on partial failure)
738 - let mut db_tx = state.db.begin().await.context("begin guest checkout webhook transaction")?;
774 + let mut db_tx = db.begin().await.context("begin guest checkout webhook transaction")?;
739 775
740 776 // Do NOT auto-attach on an email match. Stripe collects the buyer's email
741 777 // but does not prove the guest controls it, so matching alone would let
@@ -759,7 +795,7 @@ pub(super) async fn handle_guest_checkout_completed(
759 795
760 796 // Defense-in-depth reconciliation (currency + subtotal), mirroring
Lines truncated
@@ -2,19 +2,23 @@
2 2 //! license key generation, revenue splits, and pending refund processing.
3 3
4 4 use crate::{
5 + config::Config,
5 6 db,
7 + email::EmailClient,
6 8 helpers,
7 - AppState,
9 + wam_client::WamClient,
8 10 };
11 + use sqlx::PgPool;
9 12
10 13 /// Generate a license key for the purchased item if keys are enabled.
11 14 pub(crate) async fn maybe_generate_license_key(
12 - state: &AppState,
15 + db: &PgPool,
16 + wam: Option<&WamClient>,
13 17 item_id: db::ItemId,
14 18 buyer_id: db::UserId,
15 19 transaction_id: db::TransactionId,
16 20 ) {
17 - let item = match db::items::get_item_by_id(&state.db, item_id).await {
21 + let item = match db::items::get_item_by_id(db, item_id).await {
18 22 Ok(Some(item)) if item.enable_license_keys => item,
19 23 _ => return,
20 24 };
@@ -23,7 +27,7 @@ pub(crate) async fn maybe_generate_license_key(
23 27 // purchase mints at most one auto key. If one already exists for this
24 28 // transaction, skip the mint. The `license_keys_transaction_id_key` partial
25 29 // unique index is the structural backstop if this check is ever bypassed.
26 - match db::license_keys::get_license_key_by_transaction_id(&state.db, transaction_id).await {
30 + match db::license_keys::get_license_key_by_transaction_id(db, transaction_id).await {
27 31 Ok(Some(_)) => {
28 32 tracing::debug!(transaction_id = %transaction_id, item_id = %item_id, "license key already minted for transaction; skipping");
29 33 return;
@@ -37,7 +41,7 @@ pub(crate) async fn maybe_generate_license_key(
37 41
38 42 let key_code = helpers::generate_key_code();
39 43 match db::license_keys::create_license_key(
40 - &state.db, item_id, buyer_id, Some(transaction_id),
44 + db, item_id, buyer_id, Some(transaction_id),
41 45 &key_code, item.default_max_activations,
42 46 ).await {
43 47 Ok(key) => {
@@ -45,7 +49,7 @@ pub(crate) async fn maybe_generate_license_key(
45 49 }
46 50 Err(e) => {
47 51 tracing::error!(buyer_id = %buyer_id, item_id = %item_id, error = ?e, "failed to generate license key for purchase");
48 - if let Some(ref wam) = state.wam {
52 + if let Some(wam) = wam {
49 53 let title = format!("License key not issued: item {item_id}");
50 54 let body = format!(
51 55 "Buyer {buyer_id} purchased item {item_id} (tx {transaction_id}) but \
@@ -69,53 +73,61 @@ pub(crate) async fn maybe_generate_license_key(
69 73 /// forget emails may re-send on that rare redelivery; that is acceptable and
70 74 /// consistent with the existing webhook architecture (handlers are idempotent
71 75 /// on data, best-effort on notifications).
76 + #[allow(clippy::too_many_arguments)]
72 77 pub(super) async fn finalize_purchase_transaction(
73 - state: &AppState,
78 + db: &PgPool,
79 + bg: &crate::background::BackgroundTx,
80 + email: &EmailClient,
81 + wam: Option<&WamClient>,
82 + config: &Config,
74 83 tx: &db::DbTransaction,
75 84 buyer_id: db::UserId,
76 85 seller_id: db::UserId,
77 86 ) {
78 87 // Grant access to bundle child items (if this purchase is a bundle).
79 88 if let Some(item_id) = tx.item_id
80 - && let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, item_id).await
89 + && let Ok(Some(purchased_item)) = db::items::get_item_by_id(db, item_id).await
81 90 && purchased_item.item_type == db::ItemType::Bundle
82 91 {
83 - crate::routes::stripe::checkout::grant_bundle_items(state, item_id, buyer_id, seller_id, Some(tx.id)).await;
92 + crate::routes::stripe::checkout::grant_bundle_items(db, item_id, buyer_id, seller_id, Some(tx.id)).await;
84 93 }
85 94
86 95 // Contact-revocation clear (if the buyer opted to share contact).
87 96 if tx.share_contact
88 - && let Err(e) = db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id).await
97 + && let Err(e) = db::transactions::clear_contact_revocation(db, buyer_id, seller_id).await
89 98 {
90 99 tracing::error!(transaction_id = %tx.id, error = ?e, "failed to clear contact revocation after purchase");
91 100 }
92 101
93 102 // Revenue splits, license key, mailing list (each keyed to the item).
94 103 if let Some(item_id) = tx.item_id {
95 - record_transaction_splits(state, tx.id, item_id, tx.amount_cents).await;
96 - maybe_generate_license_key(state, item_id, buyer_id, tx.id).await;
97 - subscribe_buyer_to_mailing_list(state, item_id, buyer_id);
104 + record_transaction_splits(db, tx.id, item_id, tx.amount_cents).await;
105 + maybe_generate_license_key(db, wam, item_id, buyer_id, tx.id).await;
106 + subscribe_buyer_to_mailing_list(db, bg, item_id, buyer_id);
98 107 }
99 108
100 109 // Purchase confirmation + sale notification (fire-and-forget).
101 - send_purchase_emails(state, tx, buyer_id, seller_id);
110 + send_purchase_emails(db, bg, email, config, tx, buyer_id, seller_id);
102 111 }
103 112
104 113 /// Send purchase confirmation to buyer and sale notification to seller (fire-and-forget).
105 114 pub(super) fn send_purchase_emails(
106 - state: &AppState,
115 + db: &PgPool,
116 + bg: &crate::background::BackgroundTx,
117 + email: &EmailClient,
118 + config: &Config,
107 119 tx: &db::DbTransaction,
108 120 buyer_id: db::UserId,
109 121 seller_id: db::UserId,
110 122 ) {
111 - let db = state.db.clone();
112 - let email = state.email.clone();
123 + let db = db.clone();
124 + let email = email.clone();
113 125 let amount_cents = tx.amount_cents;
114 126 let item_title = tx.item_title.clone();
115 - let host_url = state.config.host_url.clone();
116 - let signing_secret = state.config.signing_secret.clone();
127 + let host_url = config.host_url.clone();
128 + let signing_secret = config.signing_secret.clone();
117 129
118 - state.bg.spawn("purchase confirmation + sale notification", async move {
130 + bg.spawn("purchase confirmation + sale notification", async move {
119 131 let buyer = db::users::get_user_by_id(&db, buyer_id).await.ok().flatten();
120 132 let seller = db::users::get_user_by_id(&db, seller_id).await.ok().flatten();
121 133
@@ -151,9 +163,9 @@ pub(super) fn send_purchase_emails(
151 163 }
152 164
153 165 /// Subscribe buyer to the item's project content mailing list (fire-and-forget).
154 - pub(super) fn subscribe_buyer_to_mailing_list(state: &AppState, item_id: db::ItemId, buyer_id: db::UserId) {
155 - let db = state.db.clone();
156 - state.bg.spawn("mailing list subscribe", async move {
166 + pub(super) fn subscribe_buyer_to_mailing_list(db: &PgPool, bg: &crate::background::BackgroundTx, item_id: db::ItemId, buyer_id: db::UserId) {
167 + let db = db.clone();
168 + bg.spawn("mailing list subscribe", async move {
157 169 if let Ok(Some(item)) = db::items::get_item_by_id(&db, item_id).await
158 170 && let Err(e) = db::mailing_lists::subscribe_to_content_list(
159 171 &db, item.project_id, buyer_id,
@@ -169,19 +181,22 @@ pub(super) fn subscribe_buyer_to_mailing_list(state: &AppState, item_id: db::Ite
169 181
170 182 /// Send tip notification to recipient (fire-and-forget).
171 183 pub(super) fn send_tip_email(
172 - state: &AppState,
184 + db: &PgPool,
185 + bg: &crate::background::BackgroundTx,
186 + email: &EmailClient,
187 + config: &Config,
173 188 tip: &db::DbTip,
174 189 tipper_id: db::UserId,
175 190 recipient_id: db::UserId,
176 191 ) {
177 - let db = state.db.clone();
178 - let email = state.email.clone();
192 + let db = db.clone();
193 + let email = email.clone();
179 194 let amount_cents = tip.amount_cents;
180 195 let message = tip.message.clone();
181 - let host_url = state.config.host_url.clone();
182 - let signing_secret = state.config.signing_secret.clone();
196 + let host_url = config.host_url.clone();
197 + let signing_secret = config.signing_secret.clone();
183 198
184 - state.bg.spawn("tip notification", async move {
199 + bg.spawn("tip notification", async move {
185 200 let tipper = db::users::get_user_by_id(&db, tipper_id).await.ok().flatten();
186 201 let recipient = db::users::get_user_by_id(&db, recipient_id).await.ok().flatten();
187 202
@@ -208,8 +223,8 @@ pub(super) fn send_tip_email(
208 223 ///
209 224 /// Called after a transaction is completed to handle out-of-order webhook
210 225 /// delivery (refund arrived before payment confirmation).
211 - pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &str) {
212 - let pending = match db::pending_refunds::claim_pending_refund(&state.db, payment_intent_id).await {
226 + pub(super) async fn check_pending_refund(db: &PgPool, payment_intent_id: &str) {
227 + let pending = match db::pending_refunds::claim_pending_refund(db, payment_intent_id).await {
213 228 Ok(Some(p)) => p,
214 229 Ok(None) => return,
215 230 Err(e) => {
@@ -232,13 +247,13 @@ pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &s
232 247
233 248 // requeue_if_unmatched = false: this row is already claimed, so an unmatched
234 249 // result must release the claim (below), not insert a duplicate pending row.
235 - match super::billing::handle_charge_refunded(state, &refund_data, false).await {
250 + match super::billing::handle_charge_refunded(db, &refund_data, false).await {
236 251 Ok(()) => {
237 252 // Record completion only after the refund work succeeded. If the process
238 253 // dies between the claim and this point, the row stays matched-but-
239 254 // incomplete and the stale-refund sweep escalates it for manual
240 255 // reconciliation (PAY-S1) instead of silently dropping the refund.
241 - if let Err(e) = db::pending_refunds::mark_refund_completed(&state.db, pending.id).await {
256 + if let Err(e) = db::pending_refunds::mark_refund_completed(db, pending.id).await {
242 257 tracing::error!(
243 258 error = ?e, pending_refund_id = %pending.id,
244 259 "processed pending refund but failed to mark it completed — \
@@ -254,7 +269,7 @@ pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &s
254 269 // `handle_charge_refunded` is atomic, so on a graceful error nothing
255 270 // committed; release the claim so a later delivery can re-claim and
256 271 // retry, and the sweep escalates it in the meantime.
257 - if let Err(e2) = db::pending_refunds::unclaim_pending_refund(&state.db, pending.id).await {
272 + if let Err(e2) = db::pending_refunds::unclaim_pending_refund(db, pending.id).await {
258 273 tracing::error!(
259 274 error = ?e2, pending_refund_id = %pending.id,
260 275 "failed to release pending refund claim after a processing failure — \
@@ -274,24 +289,24 @@ pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &s
274 289 /// Splits are recorded as obligations; actual payment transfer to members
275 290 /// is handled by the project owner outside the platform for now.
276 291 pub(super) async fn record_transaction_splits(
277 - state: &AppState,
292 + db: &PgPool,
278 293 transaction_id: db::TransactionId,
279 294 item_id: db::ItemId,
280 295 amount_cents: db::Cents,
281 296 ) {
282 - let item = match db::items::get_item_by_id(&state.db, item_id).await {
297 + let item = match db::items::get_item_by_id(db, item_id).await {
283 298 Ok(Some(item)) => item,
284 299 _ => return,
285 300 };
286 301
287 - let members = match db::project_members::get_project_members(&state.db, item.project_id).await {
302 + let members = match db::project_members::get_project_members(db, item.project_id).await {
288 303 Ok(m) if !m.is_empty() => m,
289 304 _ => return,
290 305 };
291 306
292 307 let splits = compute_splits(amount_cents, &members);
293 308
294 - if let Err(e) = db::project_members::create_transaction_splits(&state.db, transaction_id, &splits).await {
309 + if let Err(e) = db::project_members::create_transaction_splits(db, transaction_id, &splits).await {
295 310 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to record transaction splits");
296 311 } else {
297 312 tracing::info!(transaction_id = %transaction_id, member_count = splits.len(), "revenue splits recorded");
@@ -300,19 +315,19 @@ pub(super) async fn record_transaction_splits(
300 315
301 316 /// Record revenue splits for a completed tip on a project with members.
302 317 pub(super) async fn record_tip_splits(
303 - state: &AppState,
318 + db: &PgPool,
304 319 tip_id: db::TipId,
305 320 project_id: db::ProjectId,
306 321 amount_cents: db::Cents,
307 322 ) {
308 - let members = match db::project_members::get_project_members(&state.db, project_id).await {
323 + let members = match db::project_members::get_project_members(db, project_id).await {
309 324 Ok(m) if !m.is_empty() => m,
310 325 _ => return,
311 326 };
312 327
313 328 let splits = compute_splits(amount_cents, &members);
314 329
315 - if let Err(e) = db::project_members::create_tip_splits(&state.db, tip_id, &splits).await {
330 + if let Err(e) = db::project_members::create_tip_splits(db, tip_id, &splits).await {
316 331 tracing::error!(tip_id = %tip_id, error = ?e, "failed to record tip splits");
317 332 } else {
318 333 tracing::info!(tip_id = %tip_id, member_count = splits.len(), "tip splits recorded");
@@ -376,32 +391,36 @@ fn compute_splits(
376 391 /// `claim_purchase`, not here). Re-runnable on a crash-recovery redelivery:
377 392 /// splits go through an ON CONFLICT write and the emails are fire-and-forget
378 393 /// (a re-send on that rare redelivery is acceptable).
394 + #[allow(clippy::too_many_arguments)]
379 395 pub(super) fn finalize_guest_transaction(
380 - state: &AppState,
396 + db: &PgPool,
397 + bg: &crate::background::BackgroundTx,
398 + email: &EmailClient,
399 + config: &Config,
381 400 tx: &db::DbTransaction,
382 401 guest_email: &str,
383 402 item_id: db::ItemId,
384 403 seller_id: db::UserId,
385 404 ) {
386 405 // Revenue splits (idempotent).
387 - let state_for_splits = state.clone();
406 + let db_for_splits = db.clone();
388 407 let tx_id = tx.id;
389 408 let amount_cents = tx.amount_cents;
390 - state.bg.spawn("guest revenue splits", async move {
391 - record_transaction_splits(&state_for_splits, tx_id, item_id, amount_cents).await;
409 + bg.spawn("guest revenue splits", async move {
410 + record_transaction_splits(&db_for_splits, tx_id, item_id, amount_cents).await;
392 411 });
393 412
394 413 // Guest purchase confirmation with the claim link (fire-and-forget).
395 414 if let (Some(download_token), Some(claim_token)) = (tx.download_token, tx.claim_token) {
396 - let email_client = state.email.clone();
397 - let host_url = state.config.host_url.clone();
415 + let email_client = email.clone();
416 + let host_url = config.host_url.clone();
398 417 let item_title = tx.item_title.clone().unwrap_or_else(|| "your item".to_string());
399 418 let price = helpers::format_price(tx.amount_cents);
400 419 let guest_email_addr = guest_email.to_string();
401 420 let download_url = format!("{}/download/{}", host_url, download_token);
402 421 let claim_url = format!("{}/claim?token={}", host_url, claim_token);
403 422
404 - state.bg.spawn("guest purchase confirmation", async move {
423 + bg.spawn("guest purchase confirmation", async move {
405 424 if let Err(e) = email_client.send_guest_purchase_confirmation(
406 425 &guest_email_addr, &item_title, &price, &download_url, &claim_url,
407 426 ).await {
@@ -411,25 +430,28 @@ pub(super) fn finalize_guest_transaction(
411 430 }
412 431
413 432 // Sale notification to the seller (fire-and-forget).
414 - send_guest_sale_notification(state, tx, guest_email, seller_id);
433 + send_guest_sale_notification(db, bg, email, config, tx, guest_email, seller_id);
415 434 }
416 435
417 436 /// Send sale notification to the seller for a guest purchase.
418 437 pub(super) fn send_guest_sale_notification(
419 - state: &AppState,
438 + db: &PgPool,
439 + bg: &crate::background::BackgroundTx,
440 + email: &EmailClient,
441 + config: &Config,
420 442 tx: &db::DbTransaction,
421 443 guest_email: &str,
422 444 seller_id: db::UserId,
423 445 ) {
424 - let db = state.db.clone();
425 - let email_client = state.email.clone();
426 - let host_url = state.config.host_url.clone();
427 - let signing_secret = state.config.signing_secret.clone();
446 + let db = db.clone();
447 + let email_client = email.clone();
448 + let host_url = config.host_url.clone();
449 + let signing_secret = config.signing_secret.clone();
428 450 let amount_cents = tx.amount_cents;
429 451 let item_title = tx.item_title.clone();
430 452 let buyer_label = guest_email.to_string();
431 453
432 - state.bg.spawn("guest sale notification", async move {
454 + bg.spawn("guest sale notification", async move {
433 455 let seller = match db::users::get_user_by_id(&db, seller_id).await.ok().flatten() {
434 456 Some(s) if s.notify_sale => s,
435 457 _ => return,
@@ -7,7 +7,7 @@ mod subscriptions;
7 7
8 8 use axum::{
9 9 body::Bytes,
10 - extract::State,
10 + extract::{FromRef, State},
11 11 http::{header::HeaderMap, StatusCode},
12 12 response::IntoResponse,
13 13 };
@@ -171,7 +171,7 @@ pub(crate) async fn process_webhook_event(
171 171 // Direct webhook: queue as pending if the matching payment hasn't
172 172 // landed yet. Out-of-band (dashboard) FULL refunds are handled
173 173 // here; per-line refunds land via refund.created below.
174 - billing::handle_charge_refunded(state, &refund_data, true).await?;
174 + billing::handle_charge_refunded(&state.db, &refund_data, true).await?;
175 175 }
176 176 }
177 177 "refund.created" | "refund.updated" => {
@@ -181,27 +181,27 @@ pub(crate) async fn process_webhook_event(
181 181 // (Run #2 Payments SERIOUS). Untagged refunds are no-ops here.
182 182 let refund: RefundView = serde_json::from_value(data_object)
183 183 .map_err(|e| AppError::BadRequest(format!("Failed to parse Refund: {e}")))?;
184 - billing::handle_refund_created(state, &refund).await?;
184 + billing::handle_refund_created(&state.db, &refund).await?;
185 185 }
186 186 "customer.subscription.updated" => {
187 187 let sub: SubscriptionView = serde_json::from_value(data_object)
188 188 .map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?;
189 - subscriptions::handle_subscription_updated(state, &sub, event_id).await?;
189 + subscriptions::handle_subscription_updated(&state.db, &sub, event_id).await?;
190 190 }
191 191 "customer.subscription.deleted" => {
192 192 let sub: SubscriptionView = serde_json::from_value(data_object)
193 193 .map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?;
194 - subscriptions::handle_subscription_deleted(state, &sub, event_id).await?;
194 + subscriptions::handle_subscription_deleted(&state.db, &state.bg, &state.email, &sub, event_id).await?;
195 195 }
196 196 "invoice.payment_succeeded" => {
197 197 let invoice: InvoiceView = serde_json::from_value(data_object)
198 198 .map_err(|e| AppError::BadRequest(format!("Failed to parse Invoice: {e}")))?;
199 - billing::handle_invoice_payment_succeeded(state, &invoice, event_id).await?;
199 + billing::handle_invoice_payment_succeeded(&state.db, &state.bg, &state.email, state.wam.as_ref(), &invoice, event_id).await?;
200 200 }
201 201 "invoice.payment_failed" => {
202 202 let invoice: InvoiceView = serde_json::from_value(data_object)
203 203 .map_err(|e| AppError::BadRequest(format!("Failed to parse Invoice: {e}")))?;
204 - billing::handle_invoice_payment_failed(state, &invoice, event_id).await?;
204 + billing::handle_invoice_payment_failed(&state.db, state.wam.as_ref(), &invoice, event_id).await?;
205 205 }
206 206 other => {
207 207 tracing::debug!(event_type = %other, "unhandled webhook event type");
@@ -231,16 +231,16 @@ async fn dispatch_checkout_session(
231 231
232 232 // Subscription-mode: no funds captured at checkout, no settlement gate.
233 233 if payments::is_fan_plus_checkout(meta) {
234 - return checkout::handle_fan_plus_checkout_completed(state, session, event_id).await;
234 + return checkout::handle_fan_plus_checkout_completed(&state.db, &state.bg, &state.email, session, event_id).await;
235 235 }
236 236 if payments::is_creator_tier_checkout(meta) {
237 - return checkout::handle_creator_tier_checkout_completed(state, session, event_id).await;
237 + return checkout::handle_creator_tier_checkout_completed(&state.db, &state.bg, state.wam.as_ref(), &crate::Billing::from_ref(state), session, event_id).await;
238 238 }
239 239 if payments::is_synckit_app_sub_checkout(meta) {
240 - return checkout::handle_synckit_app_sub_checkout_completed(state, session, event_id).await;
240 + return checkout::handle_synckit_app_sub_checkout_completed(&state.db, session, event_id).await;
241 241 }
242 242 if payments::is_subscription_checkout(meta) {
243 - return checkout::handle_subscription_checkout_completed(state, session, event_id).await;
243 + return checkout::handle_subscription_checkout_completed(&state.db, &state.bg, &state.email, session, event_id).await;
244 244 }
245 245
246 246 // One-time payment-mode: funds captured now. Deliver only once settled.
@@ -254,13 +254,13 @@ async fn dispatch_checkout_session(
254 254 }
255 255
256 256 if payments::is_tip_checkout(meta) {
257 - checkout::handle_tip_checkout_completed(state, session, event_id).await
257 + checkout::handle_tip_checkout_completed(&state.db, &state.bg, &state.email, state.wam.as_ref(), &state.config, session, event_id).await
258 258 } else if payments::is_guest_checkout(meta) {
259 - checkout::handle_guest_checkout_completed(state, session, event_id).await
259 + checkout::handle_guest_checkout_completed(&state.db, &state.bg, &state.email, state.wam.as_ref(), &state.config, session, event_id).await
260 260 } else if payments::is_cart_checkout(meta) {
261 - checkout::handle_cart_checkout_completed(state, session, event_id).await
261 + checkout::handle_cart_checkout_completed(&state.db, &state.bg, &state.email, state.wam.as_ref(), &state.config, session, event_id).await
262 262 } else {
263 - checkout::handle_purchase_checkout_completed(state, session, event_id).await
263 + checkout::handle_purchase_checkout_completed(&state.db, &state.bg, &state.email, state.wam.as_ref(), &state.config, session, event_id).await
264 264 }
265 265 }
266 266
@@ -2,10 +2,10 @@
2 2
3 3 use crate::{
4 4 db::{self, SubscriptionStatus},
5 + email::EmailClient,
5 6 error::{Result, ResultExt},
6 - helpers::spawn_email,
7 - AppState,
8 7 };
8 + use sqlx::PgPool;
9 9
10 10 /// Parse a Stripe subscription status, returning `None` for unknown values.
11 11 ///
@@ -30,7 +30,7 @@ fn parse_status_or_log(status_str: &str, event_id: &str, stripe_sub_id: &str) ->
30 30
31 31 /// Handle customer.subscription.updated; update status + period
32 32 pub(super) async fn handle_subscription_updated(
33 - state: &AppState,
33 + db: &PgPool,
34 34 sub: &crate::payments::SubscriptionView,
35 35 event_id: &str,
36 36 ) -> Result<()> {
@@ -39,7 +39,7 @@ pub(super) async fn handle_subscription_updated(
39 39
40 40 // SyncKit v2 developer subscription? If Stripe moved it to past_due/unpaid,
41 41 // mirror that as suspended_unpaid. Active or trialing → 'active'.
42 - if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
42 + if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
43 43 let new_status = match sub.status.as_str() {
44 44 "past_due" | "unpaid" => Some("suspended_unpaid"),
45 45 "canceled" => Some("canceled"),
@@ -47,10 +47,10 @@ pub(super) async fn handle_subscription_updated(
47 47 _ => None,
48 48 };
49 49 if let Some(s) = new_status {
50 - db::synckit_billing::apply_billing_update(&state.db, app_id, Some(s), None).await.context("synckit apply_billing_update")?;
50 + db::synckit_billing::apply_billing_update(db, app_id, Some(s), None).await.context("synckit apply_billing_update")?;
51 51 }
52 52 if let Err(e) = db::subscriptions::log_subscription_event(
53 - &state.db, None, event_id, "customer.subscription.updated.synckit",
53 + db, None, event_id, "customer.subscription.updated.synckit",
54 54 &serde_json::json!({"status": sub.status, "synckit_app_id": app_id.to_string()}),
55 55 ).await {
56 56 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -59,13 +59,13 @@ pub(super) async fn handle_subscription_updated(
59 59 }
60 60
61 61 // Check if this is an end-user SyncKit app subscription.
62 - if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
62 + if db::synckit::get_subscription_by_stripe_id(db, &stripe_sub_id)
63 63 .await
64 64 .context("fetch app sync subscription by stripe id")?
65 65 .is_some()
66 66 {
67 67 db::synckit::update_app_sync_subscription_status(
68 - &state.db,
68 + db,
69 69 &stripe_sub_id,
70 70 sub.status.as_str(),
71 71 sub.current_period().map(|(_, end)| end),
@@ -73,7 +73,7 @@ pub(super) async fn handle_subscription_updated(
73 73 .await
74 74 .context("update app sync subscription status")?;
75 75 if let Err(e) = db::subscriptions::log_subscription_event(
76 - &state.db, None, event_id, "customer.subscription.updated.synckit_app_sub",
76 + db, None, event_id, "customer.subscription.updated.synckit_app_sub",
77 77 &serde_json::json!({"status": sub.status}),
78 78 ).await {
79 79 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -82,7 +82,7 @@ pub(super) async fn handle_subscription_updated(
82 82 }
83 83
84 84 // Check if this is a Fan+ subscription
85 - if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? {
85 + if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? {
86 86 let status_str = sub.status.as_str();
87 87 let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else { return Ok(()); };
88 88
@@ -90,16 +90,16 @@ pub(super) async fn handle_subscription_updated(
90 90 // revived nor period-refreshed by an out-of-order update. The raw Stripe
91 91 // period goes straight to the writer, which drops a missing/zero end so
92 92 // an active row never gets an epoch period (CHRONIC C is sealed there).
93 - db::fan_plus::apply_stripe_update(&state.db, &stripe_sub_id, Some(status), sub.current_period()).await.context("apply fan plus update")?;
93 + db::fan_plus::apply_stripe_update(db, &stripe_sub_id, Some(status), sub.current_period()).await.context("apply fan plus update")?;
94 94
95 95 // Keep the dashboard flag in sync with Stripe — covers cancellation
96 96 // initiated via the customer portal as well as our dashboard route.
97 - db::fan_plus::set_cancel_at_period_end(&state.db, &stripe_sub_id, sub.cancel_at_period_end)
97 + db::fan_plus::set_cancel_at_period_end(db, &stripe_sub_id, sub.cancel_at_period_end)
98 98 .await
99 99 .context("sync fan plus cancel_at_period_end")?;
100 100
101 101 if let Err(e) = db::subscriptions::log_subscription_event(
102 - &state.db, None, event_id, "customer.subscription.updated.fan_plus",
102 + db, None, event_id, "customer.subscription.updated.fan_plus",
103 103 &serde_json::json!({"status": status_str}),
104 104 ).await {
105 105 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -108,20 +108,20 @@ pub(super) async fn handle_subscription_updated(
108 108 }
109 109
110 110 // Check if this is a creator tier subscription
111 - 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")? {
111 + if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(db, &stripe_sub_id).await.context("fetch creator sub by stripe id")? {
112 112 let status_str = sub.status.as_str();
113 113 let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else { return Ok(()); };
114 114
115 115 // Status + period in one guarded write (canceled is terminal for both).
116 116 // Raw Stripe period to the writer; it drops a missing/zero end so an
117 117 // active row never gets an epoch period (CHRONIC C is sealed there).
118 - db::creator_tiers::apply_stripe_update(&state.db, &stripe_sub_id, Some(status), sub.current_period()).await.context("apply creator sub update")?;
118 + db::creator_tiers::apply_stripe_update(db, &stripe_sub_id, Some(status), sub.current_period()).await.context("apply creator sub update")?;
119 119
120 120 // Sync the denormalized creator_tier column on users
121 - db::creator_tiers::sync_user_creator_tier(&state.db, ct_sub.user_id).await.context("sync user creator tier")?;
121 + db::creator_tiers::sync_user_creator_tier(db, ct_sub.user_id).await.context("sync user creator tier")?;
122 122
123 123 if let Err(e) = db::subscriptions::log_subscription_event(
124 - &state.db, None, event_id, "customer.subscription.updated.creator_tier",
124 + db, None, event_id, "customer.subscription.updated.creator_tier",
125 125 &serde_json::json!({"status": status_str}),
126 126 ).await {
127 127 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -137,12 +137,12 @@ pub(super) async fn handle_subscription_updated(
137 137 // period. The raw Stripe period goes straight to the writer, which drops a
138 138 // missing/zero end (the old `unwrap_or((0,0))` + `stripe_timestamp(0)` here
139 139 // stamped 1970-01-01 and cut off paying fans — CHRONIC C, now sealed).
140 - let updated = db::subscriptions::apply_stripe_update(&state.db, &stripe_sub_id, Some(status), sub.current_period()).await.context("apply subscription update")?;
140 + let updated = db::subscriptions::apply_stripe_update(db, &stripe_sub_id, Some(status), sub.current_period()).await.context("apply subscription update")?;
141 141
142 142 // Log event
143 143 let sub_id = updated.as_ref().map(|s| s.id);
144 144 if let Err(e) = db::subscriptions::log_subscription_event(
145 - &state.db, sub_id, event_id, "customer.subscription.updated",
145 + db, sub_id, event_id, "customer.subscription.updated",
146 146 &serde_json::json!({"status": status.to_string()}),
147 147 ).await {
148 148 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -153,7 +153,9 @@ pub(super) async fn handle_subscription_updated(
153 153
154 154 /// Handle customer.subscription.deleted; mark canceled, send email
155 155 pub(super) async fn handle_subscription_deleted(
156 - state: &AppState,
156 + db: &PgPool,
157 + bg: &crate::background::BackgroundTx,
158 + email: &EmailClient,
157 159 sub: &crate::payments::SubscriptionView,
158 160 event_id: &str,
159 161 ) -> Result<()> {
@@ -161,10 +163,10 @@ pub(super) async fn handle_subscription_deleted(
161 163 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription deleted");
162 164
163 165 // SyncKit v2 developer subscription? Flip to 'canceled'.
164 - if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
165 - db::synckit_billing::apply_billing_update(&state.db, app_id, Some("canceled"), None).await.context("synckit billing -> canceled")?;
166 + if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
167 + db::synckit_billing::apply_billing_update(db, app_id, Some("canceled"), None).await.context("synckit billing -> canceled")?;
166 168 if let Err(e) = db::subscriptions::log_subscription_event(
167 - &state.db, None, event_id, "customer.subscription.deleted.synckit",
169 + db, None, event_id, "customer.subscription.deleted.synckit",
168 170 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
169 171 ).await {
170 172 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -173,18 +175,18 @@ pub(super) async fn handle_subscription_deleted(
173 175 }
174 176
175 177 // Check if this is an end-user SyncKit app subscription.
176 - if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
178 + if db::synckit::get_subscription_by_stripe_id(db, &stripe_sub_id)
177 179 .await
178 180 .context("fetch app sync subscription by stripe id")?
179 181 .is_some()
180 182 {
181 183 db::synckit::update_app_sync_subscription_status(
182 - &state.db, &stripe_sub_id, "canceled", None::<i64>,
184 + db, &stripe_sub_id, "canceled", None::<i64>,
183 185 )
184 186 .await
185 187 .context("cancel app sync subscription")?;
186 188 if let Err(e) = db::subscriptions::log_subscription_event(
187 - &state.db, None, event_id, "customer.subscription.deleted.synckit_app_sub",
189 + db, None, event_id, "customer.subscription.deleted.synckit_app_sub",
188 190 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
189 191 ).await {
190 192 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -193,21 +195,24 @@ pub(super) async fn handle_subscription_deleted(
193 195 }
194 196
195 197 // Check if this is a Fan+ subscription
196 - if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? {
197 - db::fan_plus::cancel_fan_plus(&state.db, &stripe_sub_id).await.context("cancel fan plus")?;
198 + if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? {
199 + db::fan_plus::cancel_fan_plus(db, &stripe_sub_id).await.context("cancel fan plus")?;
198 200
199 201 // Send cancellation email (fire-and-forget)
200 - if let Ok(Some(user)) = db::users::get_user_by_id(&state.db, fan_sub.user_id).await {
202 + if let Ok(Some(user)) = db::users::get_user_by_id(db, fan_sub.user_id).await {
201 203 let period_end = fan_sub.current_period_end;
202 204 let user_email = user.email.clone();
203 205 let user_name = user.display_name.clone();
204 - spawn_email!(state, "Fan+ cancelled", |email| {
205 - email.send_fan_plus_cancelled(&user_email, user_name.as_deref(), period_end.as_ref())
206 + let email = email.clone();
207 + bg.spawn("Fan+ cancelled", async move {
208 + if let Err(e) = email.send_fan_plus_cancelled(&user_email, user_name.as_deref(), period_end.as_ref()).await {
209 + tracing::error!(error = ?e, "failed to send Fan+ cancelled");
210 + }
206 211 });
207 212 }
208 213
209 214 if let Err(e) = db::subscriptions::log_subscription_event(
210 - &state.db, None, event_id, "customer.subscription.deleted.fan_plus",
215 + db, None, event_id, "customer.subscription.deleted.fan_plus",
211 216 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
212 217 ).await {
213 218 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -216,9 +221,9 @@ pub(super) async fn handle_subscription_deleted(
216 221 }
217 222
218 223 // Check if this is a creator tier subscription
219 - 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")? {
220 - db::creator_tiers::cancel_creator_sub(&state.db, &stripe_sub_id).await.context("cancel creator sub")?;
221 - db::creator_tiers::sync_user_creator_tier(&state.db, ct_sub.user_id).await.context("sync user creator tier after cancel")?;
224 + if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(db, &stripe_sub_id).await.context("fetch creator sub by stripe id")? {
225 + db::creator_tiers::cancel_creator_sub(db, &stripe_sub_id).await.context("cancel creator sub")?;
226 + db::creator_tiers::sync_user_creator_tier(db, ct_sub.user_id).await.context("sync user creator tier after cancel")?;
222 227
223 228 tracing::info!(
224 229 user_id = %ct_sub.user_id, tier = %ct_sub.tier,
@@ -226,7 +231,7 @@ pub(super) async fn handle_subscription_deleted(
226 231 );
227 232
228 233 if let Err(e) = db::subscriptions::log_subscription_event(
229 - &state.db, None, event_id, "customer.subscription.deleted.creator_tier",
234 + db, None, event_id, "customer.subscription.deleted.creator_tier",
230 235 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
231 236 ).await {
232 237 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -234,26 +239,29 @@ pub(super) async fn handle_subscription_deleted(
234 239 return Ok(());
235 240 }
236 241
237 - let canceled = db::subscriptions::cancel_subscription(&state.db, &stripe_sub_id).await.context("cancel subscription")?;
242 + let canceled = db::subscriptions::cancel_subscription(db, &stripe_sub_id).await.context("cancel subscription")?;
238 243
239 244 if let Some(ref db_sub) = canceled {
240 245 // Send cancellation email (fire-and-forget)
241 246 if let (Ok(Some(subscriber)), Ok(Some(tier)), Ok(Some(project))) = (
242 - db::users::get_user_by_id(&state.db, db_sub.subscriber_id).await,
243 - db::subscriptions::get_subscription_tier_by_id(&state.db, db_sub.tier_id).await,
244 - async { match db_sub.project_id { Some(pid) => db::projects::get_project_by_id(&state.db, pid).await, None => Ok(None) } }.await,
247 + db::users::get_user_by_id(db, db_sub.subscriber_id).await,
248 + db::subscriptions::get_subscription_tier_by_id(db, db_sub.tier_id).await,
249 + async { match db_sub.project_id { Some(pid) => db::projects::get_project_by_id(db, pid).await, None => Ok(None) } }.await,
245 250 ) {
246 251 let sub_email = subscriber.email.clone();
247 252 let sub_name = subscriber.display_name.clone();
248 253 let tier_name = tier.name.clone();
249 254 let project_title = project.title.clone();
250 - spawn_email!(state, "subscription cancelled", |email| {
251 - email.send_subscription_cancelled(
255 + let email = email.clone();
256 + bg.spawn("subscription cancelled", async move {
257 + if let Err(e) = email.send_subscription_cancelled(
252 258 &sub_email,
253 259 sub_name.as_deref(),
254 260 &tier_name,
255 261 &project_title,
256 - )
262 + ).await {
263 + tracing::error!(error = ?e, "failed to send subscription cancelled");
264 + }
257 265 });
258 266 }
259 267 }
@@ -261,7 +269,7 @@ pub(super) async fn handle_subscription_deleted(
261 269 // Log event
262 270 let sub_id = canceled.as_ref().map(|s| s.id);
263 271 if let Err(e) = db::subscriptions::log_subscription_event(
264 - &state.db, sub_id, event_id, "customer.subscription.deleted",
272 + db, sub_id, event_id, "customer.subscription.deleted",
265 273 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
266 274 ).await {
267 275 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");