max / makenotwork
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"); |