//! Stripe webhook workflow tests — purchase, refund, account update, //! invalid signature, subscription lifecycle. use crate::harness::stripe::{sign_webhook_payload, TEST_WEBHOOK_SECRET, TEST_WEBHOOK_SECRET_V2}; use crate::harness::TestHarness; use makenotwork::db::UserId; use serde_json::Value; use std::collections::HashMap; /// Build a JSON event with the given type and object, sign it, and POST to /stripe/webhook. async fn post_event_json( h: &mut TestHarness, event_type: &str, object: serde_json::Value, ) -> crate::harness::client::TestResponse { post_event_json_with_id(h, "evt_test_000", event_type, object).await } async fn post_event_json_with_id( h: &mut TestHarness, event_id: &str, event_type: &str, object: serde_json::Value, ) -> crate::harness::client::TestResponse { let payload = serde_json::json!({ "id": event_id, "type": event_type, "data": {"object": object}, }) .to_string(); let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET); h.client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[ ("stripe-signature", &signature), ("content-type", "application/json"), ], ) .await } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[tokio::test] async fn webhook_invalid_signature() { let mut h = TestHarness::with_stripe().await; let payload = r#"{"id":"evt_bad","type":"account.updated","data":{"object":{}}}"#; let bad_sig = "t=0,v1=00000000000000000000000000000000"; let resp = h .client .request_with_headers( "POST", "/stripe/webhook", Some(payload), &[ ("stripe-signature", bad_sig), ("content-type", "application/json"), ], ) .await; assert_eq!( resp.status.as_u16(), 400, "Expected 400 for bad signature, got: {}", resp.status ); } #[tokio::test] async fn webhook_account_updated() { let mut h = TestHarness::with_stripe().await; // Create a user with a known stripe_account_id let user_id = h.signup("stripecreator", "sc@test.com", "password123").await; let acct_id = "acct_test_wh_123"; sqlx::query("UPDATE users SET stripe_account_id = $1 WHERE id = $2") .bind(acct_id) .bind(user_id) .execute(&h.db) .await .unwrap(); // Build account object with valid id prefix let account = serde_json::json!({ "id": acct_id, "object": "account", "charges_enabled": true, "payouts_enabled": true, "details_submitted": true, }); let resp = post_event_json(&mut h, "account.updated", account).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify DB was updated let (charges, payouts, onboarding): (bool, bool, bool) = sqlx::query_as( "SELECT stripe_charges_enabled, stripe_payouts_enabled, stripe_onboarding_complete FROM users WHERE id = $1", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(charges, "charges_enabled should be true"); assert!(payouts, "payouts_enabled should be true"); assert!(onboarding, "onboarding_complete should be true"); } #[tokio::test] async fn webhook_purchase_completed() { let mut h = TestHarness::with_stripe().await; // Create buyer + seller let buyer_id = h.signup("buyer", "buyer@test.com", "password123").await; h.client.post_form("/logout", "").await; let seller_id = h.signup("seller", "seller@test.com", "password123").await; h.grant_creator(seller_id).await; h.client.post_form("/logout", "").await; h.login("seller", "password123").await; // Create project + item let resp = h .client .post_form("/api/projects", "slug=stripeproj&title=Stripe+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Paid+Track&price_cents=999&item_type=audio", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); // Insert a pending transaction via direct SQL let session_id = "cs_test_purchase_123"; sqlx::query( r#"INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status, stripe_checkout_session_id, item_title, seller_username) VALUES ($1, $2, $3::uuid, 999, 'pending', $4, 'Paid Track', 'seller')"#, ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .bind(session_id) .execute(&h.db) .await .unwrap(); // Build checkout session with valid IDs let mut meta = HashMap::new(); meta.insert("buyer_id".to_string(), buyer_id.to_string()); meta.insert("seller_id".to_string(), seller_id.to_string()); meta.insert("item_id".to_string(), item_id.clone()); let session = serde_json::json!({ "id": session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": "pi_test_purchase_123", }); let resp = post_event_json(&mut h, "checkout.session.completed", session).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify transaction was completed let status: String = sqlx::query_scalar( "SELECT status FROM transactions WHERE stripe_checkout_session_id = $1", ) .bind(session_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "completed"); // Verify sales_count was incremented let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sales, 1); } #[tokio::test] async fn webhook_charge_refunded() { let mut h = TestHarness::with_stripe().await; // Create buyer + seller + item let buyer_id = h.signup("rbuyer", "rb@test.com", "password123").await; h.client.post_form("/logout", "").await; let seller_id = h.signup("rseller", "rs@test.com", "password123").await; h.grant_creator(seller_id).await; h.client.post_form("/logout", "").await; h.login("rseller", "password123").await; let resp = h .client .post_form("/api/projects", "slug=refundproj&title=Refund+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Refund+Track&price_cents=500&item_type=audio", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); // Set sales_count to 1 and insert a completed transaction let pi_id = "pi_test_refund_123"; sqlx::query("UPDATE items SET sales_count = 1 WHERE id = $1::uuid") .bind(&item_id) .execute(&h.db) .await .unwrap(); sqlx::query( r#"INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status, stripe_payment_intent_id, stripe_checkout_session_id, item_title, seller_username, completed_at) VALUES ($1, $2, $3::uuid, 500, 'completed', $4, 'cs_refund', 'Refund Track', 'rseller', NOW())"#, ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .bind(pi_id) .execute(&h.db) .await .unwrap(); // Build charge with valid id and payment_intent let charge = serde_json::json!({ "id": "ch_test_refund", "object": "charge", "amount": 500, "amount_refunded": 500, "payment_intent": "pi_test_refund_123", }); let resp = post_event_json(&mut h, "charge.refunded", charge).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify transaction was refunded let status: String = sqlx::query_scalar( "SELECT status FROM transactions WHERE stripe_payment_intent_id = $1", ) .bind(pi_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "refunded"); // Verify sales_count was decremented let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sales, 0); } #[tokio::test] async fn webhook_subscription_deleted() { let mut h = TestHarness::with_stripe().await; // Create subscriber and creator with project + tier let sub_user_id = h.signup("subscriber", "sub@test.com", "password123").await; h.client.post_form("/logout", "").await; let creator_id = h.signup("tiercreator", "tc@test.com", "password123").await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("tiercreator", "password123").await; let resp = h .client .post_form("/api/projects", "slug=subproj&title=Sub+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Create subscription tier via direct SQL let tier_id = uuid::Uuid::new_v4(); sqlx::query( r#"INSERT INTO subscription_tiers (id, project_id, name, price_cents) VALUES ($1, $2::uuid, 'Basic', 500)"#, ) .bind(tier_id) .bind(&project_id) .execute(&h.db) .await .unwrap(); // Create subscription via direct SQL let stripe_sub_id = "sub_test_delete_123"; sqlx::query( r#"INSERT INTO subscriptions (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, $2, $3::uuid, $4, 'cus_test', 'active')"#, ) .bind(sub_user_id) .bind(tier_id) .bind(&project_id) .bind(stripe_sub_id) .execute(&h.db) .await .unwrap(); let sub = serde_json::json!({ "id": stripe_sub_id, "object": "subscription", "status": "canceled", "cancel_at_period_end": false, "items": { "object": "list", "data": [{ "id": "si_test_del", "object": "subscription_item", "subscription": stripe_sub_id, "current_period_start": 1700000000, "current_period_end": 1702592000, "metadata": {}, }], }, }); let resp = post_event_json(&mut h, "customer.subscription.deleted", sub).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify subscription was canceled let status: String = sqlx::query_scalar( "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "canceled"); } // --------------------------------------------------------------------------- // Shared fixture for subscription webhook tests // --------------------------------------------------------------------------- struct SubscriptionFixture { #[allow(dead_code)] creator_id: UserId, subscriber_id: UserId, project_id: String, tier_id: uuid::Uuid, } /// Creates a creator (with project + tier) and a subscriber user. /// Leaves the harness logged out. async fn setup_subscription_fixture(h: &mut TestHarness) -> SubscriptionFixture { let subscriber_id = h.signup("subuser", "subuser@test.com", "password123").await; h.client.post_form("/logout", "").await; let creator_id = h.signup("creator", "creator@test.com", "password123").await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("creator", "password123").await; let resp = h .client .post_form("/api/projects", "slug=subfix&title=Sub+Fixture") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); let tier_id = uuid::Uuid::new_v4(); sqlx::query( r#"INSERT INTO subscription_tiers (id, project_id, name, price_cents) VALUES ($1, $2::uuid, 'Pro', 1000)"#, ) .bind(tier_id) .bind(&project_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; SubscriptionFixture { creator_id, subscriber_id, project_id, tier_id, } } /// Insert an active subscription row into the DB. Returns the stripe subscription ID. async fn insert_active_subscription( h: &TestHarness, fix: &SubscriptionFixture, stripe_sub_id: &str, ) { sqlx::query( r#"INSERT INTO subscriptions (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, $2, $3::uuid, $4, 'cus_test_fixture', 'active')"#, ) .bind(fix.subscriber_id) .bind(fix.tier_id) .bind(&fix.project_id) .bind(stripe_sub_id) .execute(&h.db) .await .unwrap(); } // --------------------------------------------------------------------------- // New tests // --------------------------------------------------------------------------- #[tokio::test] async fn webhook_subscription_checkout_completed() { let mut h = TestHarness::with_stripe().await; let fix = setup_subscription_fixture(&mut h).await; let stripe_sub_id = "sub_test_checkout_001"; let stripe_customer_id = "cus_test_checkout_001"; // Build checkout session with subscription metadata let mut meta = HashMap::new(); meta.insert("checkout_type".to_string(), "subscription".to_string()); meta.insert("subscriber_id".to_string(), fix.subscriber_id.to_string()); meta.insert("project_id".to_string(), fix.project_id.clone()); meta.insert("tier_id".to_string(), fix.tier_id.to_string()); let session = serde_json::json!({ "id": "cs_test_sub_checkout_001", "object": "checkout_session", "mode": "subscription", "metadata": meta, "subscription": stripe_sub_id, "customer": stripe_customer_id, }); let resp = post_event_json(&mut h, "checkout.session.completed", session).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify subscription row was created let (status, sub_stripe_id, sub_customer_id): (String, String, String) = sqlx::query_as( "SELECT status, stripe_subscription_id, stripe_customer_id FROM subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "active"); assert_eq!(sub_stripe_id, stripe_sub_id); assert_eq!(sub_customer_id, stripe_customer_id); } #[tokio::test] async fn webhook_subscription_checkout_completed_idempotent() { let mut h = TestHarness::with_stripe().await; let fix = setup_subscription_fixture(&mut h).await; let stripe_sub_id = "sub_test_checkout_idem"; let stripe_customer_id = "cus_test_checkout_idem"; let build_session = || { let mut meta = HashMap::new(); meta.insert("checkout_type".to_string(), "subscription".to_string()); meta.insert("subscriber_id".to_string(), fix.subscriber_id.to_string()); meta.insert("project_id".to_string(), fix.project_id.clone()); meta.insert("tier_id".to_string(), fix.tier_id.to_string()); serde_json::json!({ "id": "cs_test_sub_idem", "object": "checkout_session", "mode": "subscription", "metadata": meta, "subscription": stripe_sub_id, "customer": stripe_customer_id, }) }; // First event let resp = post_event_json(&mut h, "checkout.session.completed", build_session()).await; assert_eq!(resp.status.as_u16(), 200, "First webhook failed: {}", resp.text); // Second event (duplicate) — use a different event ID let resp = post_event_json_with_id(&mut h, "evt_test_001", "checkout.session.completed", build_session()).await; assert_eq!(resp.status.as_u16(), 200, "Duplicate webhook should succeed: {}", resp.text); // Verify still only one subscription row let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "Should have exactly one subscription row"); } #[tokio::test] async fn webhook_subscription_updated() { let mut h = TestHarness::with_stripe().await; let fix = setup_subscription_fixture(&mut h).await; let stripe_sub_id = "sub_test_updated_001"; insert_active_subscription(&h, &fix, stripe_sub_id).await; let sub = serde_json::json!({ "id": stripe_sub_id, "object": "subscription", "status": "past_due", "cancel_at_period_end": false, "items": { "object": "list", "data": [{ "id": "si_test_upd", "object": "subscription_item", "subscription": stripe_sub_id, "current_period_start": 1702592000, "current_period_end": 1705184000, "metadata": {}, }], }, }); let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify status changed let status: String = sqlx::query_scalar( "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "past_due"); // Verify period was updated let (period_start, period_end): (Option>, Option>) = sqlx::query_as( "SELECT current_period_start, current_period_end FROM subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert!(period_start.is_some(), "period_start should be set"); assert!(period_end.is_some(), "period_end should be set"); assert_eq!(period_start.unwrap().timestamp(), 1702592000); assert_eq!(period_end.unwrap().timestamp(), 1705184000); } #[tokio::test] async fn webhook_invoice_payment_succeeded() { let mut h = TestHarness::with_stripe().await; let fix = setup_subscription_fixture(&mut h).await; let stripe_sub_id = "sub_test_inv_success"; insert_active_subscription(&h, &fix, stripe_sub_id).await; let invoice = serde_json::json!({ "id": "in_test_success_001", "object": "invoice", "subscription": stripe_sub_id, "period_start": 1702592000, "period_end": 1705184000, "billing_reason": "subscription_cycle", "livemode": false, }); let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify subscription period was updated let (period_start, period_end): (Option>, Option>) = sqlx::query_as( "SELECT current_period_start, current_period_end FROM subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert!(period_start.is_some(), "period_start should be set"); assert!(period_end.is_some(), "period_end should be set"); assert_eq!(period_start.unwrap().timestamp(), 1702592000); assert_eq!(period_end.unwrap().timestamp(), 1705184000); } #[tokio::test] async fn webhook_invoice_payment_failed() { let mut h = TestHarness::with_stripe().await; let fix = setup_subscription_fixture(&mut h).await; let stripe_sub_id = "sub_test_inv_failed"; insert_active_subscription(&h, &fix, stripe_sub_id).await; let invoice = serde_json::json!({ "id": "in_test_failed_001", "object": "invoice", "subscription": stripe_sub_id, "period_start": 1700000000, "period_end": 1702592000, "livemode": false, }); let resp = post_event_json(&mut h, "invoice.payment_failed", invoice).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify status changed to past_due let status: String = sqlx::query_scalar( "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "past_due"); } #[tokio::test] async fn webhook_account_updated_partial() { let mut h = TestHarness::with_stripe().await; let user_id = h.signup("partialcreator", "pc@test.com", "password123").await; let acct_id = "acct_test_partial_123"; sqlx::query("UPDATE users SET stripe_account_id = $1 WHERE id = $2") .bind(acct_id) .bind(user_id) .execute(&h.db) .await .unwrap(); // Only details_submitted is true; charges and payouts still false let account = serde_json::json!({ "id": acct_id, "object": "account", "charges_enabled": false, "payouts_enabled": false, "details_submitted": true, }); let resp = post_event_json(&mut h, "account.updated", account).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); let (charges, payouts, onboarding): (bool, bool, bool) = sqlx::query_as( "SELECT stripe_charges_enabled, stripe_payouts_enabled, stripe_onboarding_complete FROM users WHERE id = $1", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(!charges, "charges_enabled should be false"); assert!(!payouts, "payouts_enabled should be false"); assert!(onboarding, "onboarding_complete should be true"); } #[tokio::test] async fn webhook_account_updated_unknown_account() { let mut h = TestHarness::with_stripe().await; // No user has this stripe_account_id let account = serde_json::json!({ "id": "acct_nonexistent", "object": "account", "charges_enabled": true, "payouts_enabled": true, "details_submitted": true, }); let resp = post_event_json(&mut h, "account.updated", account).await; assert_eq!( resp.status.as_u16(), 200, "Unknown account should still return 200: {}", resp.text ); // Verify no users were affected let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM users WHERE stripe_account_id = 'acct_nonexistent'", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0); } #[tokio::test] async fn webhook_purchase_completed_idempotent() { let mut h = TestHarness::with_stripe().await; // Create buyer + seller let buyer_id = h.signup("idembuyer", "ib@test.com", "password123").await; h.client.post_form("/logout", "").await; let seller_id = h.signup("idemseller", "is@test.com", "password123").await; h.grant_creator(seller_id).await; h.client.post_form("/logout", "").await; h.login("idemseller", "password123").await; // Create project + item let resp = h .client .post_form("/api/projects", "slug=idemproj&title=Idem+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Idem+Track&price_cents=500&item_type=audio", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); // Insert a pending transaction let session_id = "cs_test_idem_001"; sqlx::query( r#"INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status, stripe_checkout_session_id, item_title, seller_username) VALUES ($1, $2, $3::uuid, 500, 'pending', $4, 'Idem Track', 'idemseller')"#, ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .bind(session_id) .execute(&h.db) .await .unwrap(); let build_session = || { let mut meta = HashMap::new(); meta.insert("buyer_id".to_string(), buyer_id.to_string()); meta.insert("seller_id".to_string(), seller_id.to_string()); meta.insert("item_id".to_string(), item_id.clone()); serde_json::json!({ "id": session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": "pi_test_idem_001", }) }; // First event let resp = post_event_json(&mut h, "checkout.session.completed", build_session()).await; assert_eq!(resp.status.as_u16(), 200, "First webhook failed: {}", resp.text); // Second event (duplicate) let resp = post_event_json_with_id(&mut h, "evt_test_002", "checkout.session.completed", build_session()).await; assert_eq!(resp.status.as_u16(), 200, "Duplicate webhook should succeed: {}", resp.text); // Verify still one completed transaction let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions WHERE stripe_checkout_session_id = $1 AND status = 'completed'", ) .bind(session_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "Should have exactly one completed transaction"); // Verify sales_count is still 1 (not incremented twice) let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sales, 1, "sales_count should be 1, not 2"); } // --------------------------------------------------------------------------- // v2 thin event tests // --------------------------------------------------------------------------- /// Helper to POST a raw JSON payload to /stripe/webhook/v2 with a given signature. async fn post_v2_raw( h: &mut TestHarness, payload: &str, signature: &str, ) -> crate::harness::client::TestResponse { h.client .request_with_headers( "POST", "/stripe/webhook/v2", Some(payload), &[ ("stripe-signature", signature), ("content-type", "application/json"), ], ) .await } #[tokio::test] async fn webhook_v2_invalid_signature() { let mut h = TestHarness::with_stripe().await; let payload = r#"{"id":"evt_v2_bad","type":"v2.core.account.updated","related_object":{"id":"acct_123","type":"account"}}"#; let bad_sig = "t=0,v1=00000000000000000000000000000000"; let resp = post_v2_raw(&mut h, payload, bad_sig).await; assert_eq!( resp.status.as_u16(), 400, "Expected 400 for bad v2 signature, got: {}", resp.status ); } #[tokio::test] async fn webhook_v2_account_event_accepted() { let mut h = TestHarness::with_mocks().await; // Uses mock Stripe so fetch_account returns success let payload = serde_json::json!({ "id": "evt_v2_acct_001", "type": "v2.core.account.updated", "related_object": { "id": "acct_test_v2_123", "type": "account" } }) .to_string(); let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET_V2); let resp = post_v2_raw(&mut h, &payload, &signature).await; assert_eq!( resp.status.as_u16(), 200, "v2 account event should return 200 even if API fetch fails: {}", resp.text ); } #[tokio::test] async fn webhook_v2_unknown_event_type_returns_200() { let mut h = TestHarness::with_stripe().await; let payload = serde_json::json!({ "id": "evt_v2_unknown_001", "type": "v2.billing.meter.no_meter_found", "related_object": { "id": "mtr_123", "type": "billing.meter" } }) .to_string(); let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET_V2); let resp = post_v2_raw(&mut h, &payload, &signature).await; assert_eq!( resp.status.as_u16(), 200, "Unknown v2 event type should return 200: {}", resp.text ); } // --------------------------------------------------------------------------- // Fan+ subscription webhook lifecycle // // Pins the earlier cascade branches in handle_subscription_updated / // handle_subscription_deleted / handle_invoice_payment_succeeded / // handle_invoice_payment_failed — previously only the generic creator-sub // fallback path was exercised. // --------------------------------------------------------------------------- fn make_subscription(stripe_sub_id: &str, status: &str) -> serde_json::Value { serde_json::json!({ "id": stripe_sub_id, "object": "subscription", "status": status, "cancel_at_period_end": false, "items": { "object": "list", "data": [{ "id": "si_fan_plus_test", "object": "subscription_item", "subscription": stripe_sub_id, "current_period_start": 1700000000_i64, "current_period_end": 1702592000_i64, "metadata": {}, }], }, }) } #[tokio::test] async fn webhook_subscription_updated_fan_plus_path() { let mut h = TestHarness::with_stripe().await; let user_id = h.signup("fpupdate", "fpupdate@test.com", "password123").await; let stripe_sub_id = "sub_fp_update_1"; sqlx::query( r#"INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) VALUES ($1, $2, 'cus_fp_update', 'active', NOW() + interval '30 days')"#, ) .bind(user_id) .bind(stripe_sub_id) .execute(&h.db) .await .unwrap(); let sub = make_subscription(stripe_sub_id, "past_due"); let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // fan_plus row reflects the new status — pins that the fan_plus branch // ran and reached `update_fan_plus_status`, not the generic fallback. let status: String = sqlx::query_scalar( "SELECT status::text FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "past_due"); } #[tokio::test] async fn webhook_subscription_deleted_fan_plus_path() { let mut h = TestHarness::with_stripe().await; let user_id = h.signup("fpdelete", "fpdelete@test.com", "password123").await; let stripe_sub_id = "sub_fp_delete_1"; sqlx::query( r#"INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) VALUES ($1, $2, 'cus_fp_delete', 'active', NOW() + interval '30 days')"#, ) .bind(user_id) .bind(stripe_sub_id) .execute(&h.db) .await .unwrap(); let sub = make_subscription(stripe_sub_id, "canceled"); let resp = post_event_json(&mut h, "customer.subscription.deleted", sub).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Pins that `cancel_fan_plus` ran. We don't pin the exact column the // cancellation writes to (status vs separate canceled_at); just that // a downstream lookup classifies this user as NOT active. let active: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions \ WHERE user_id = $1 AND status = 'active' AND canceled_at IS NULL)", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(!active, "Fan+ subscription must not be active after cancellation"); } fn make_invoice(stripe_sub_id: &str, billing_reason: &str) -> serde_json::Value { serde_json::json!({ "id": "in_test_fp", "object": "invoice", "subscription": stripe_sub_id, "billing_reason": billing_reason, "period_start": 1700000000_i64, "period_end": 1702592000_i64, "currency": "usd", "livemode": false, }) } #[tokio::test] async fn webhook_invoice_payment_succeeded_updates_fan_plus_period() { let mut h = TestHarness::with_stripe().await; let user_id = h.signup("fpinvoice", "fpinvoice@test.com", "password123").await; let stripe_sub_id = "sub_fp_invoice_1"; sqlx::query( r#"INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) VALUES ($1, $2, 'cus_fp_invoice', 'active', NOW())"#, ) .bind(user_id) .bind(stripe_sub_id) .execute(&h.db) .await .unwrap(); // billing_reason != "subscription_cycle" → not a renewal, just updates period. let invoice = make_invoice(stripe_sub_id, "subscription_create"); let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Period_end should now match the invoice's period_end (2024-12-14T22:13:20Z = 1702592000). let period_end: chrono::DateTime = sqlx::query_scalar( "SELECT current_period_end FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(period_end.timestamp(), 1702592000); } #[tokio::test] async fn webhook_invoice_payment_failed_sets_fan_plus_past_due() { let mut h = TestHarness::with_stripe().await; let user_id = h.signup("fpfail", "fpfail@test.com", "password123").await; let stripe_sub_id = "sub_fp_fail_1"; sqlx::query( r#"INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) VALUES ($1, $2, 'cus_fp_fail', 'active', NOW() + interval '30 days')"#, ) .bind(user_id) .bind(stripe_sub_id) .execute(&h.db) .await .unwrap(); let invoice = make_invoice(stripe_sub_id, "subscription_cycle"); let resp = post_event_json(&mut h, "invoice.payment_failed", invoice).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); let status: String = sqlx::query_scalar( "SELECT status::text FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "past_due", "Fan+ must be flipped to past_due on payment failure"); } #[tokio::test] async fn webhook_subscription_updated_unknown_id_returns_200() { // Pins the fall-through: an event for a stripe_sub_id that has no fan_plus, // creator_tier, or app_sync row should still return 200 (no-op). let mut h = TestHarness::with_stripe().await; let sub = make_subscription("sub_does_not_exist", "active"); let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await; // Generic path runs `update_subscription_status` which finds nothing — // current behavior is to return 200 (idempotent / unknown-sub tolerance). assert_eq!(resp.status.as_u16(), 200, "Unknown sub_id should not error: {}", resp.text); }