//! Payment flow integration tests using MockPaymentProvider and MockEmailTransport. //! //! These tests exercise the full checkout → webhook → assertion pipeline //! without hitting any external service. use crate::harness::TestHarness; use makenotwork::db; use serde_json::Value; use std::collections::HashMap; // Helpers /// Create a creator with Stripe "connected" (direct DB override) and a published paid item. /// Returns (seller_id, project_id, item_id). async fn setup_paid_item(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String, String) { let seller_id = h.signup("seller", "seller@test.com", "pass1234").await; h.grant_creator(seller_id).await; // Simulate Stripe Connect onboarding complete sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_seller', stripe_charges_enabled = true WHERE id = $1") .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("seller", "pass1234").await; let resp = h .client .post_form("/api/projects", "slug=shop&title=Shop") .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/{project_id}/items"), &format!("title=Track&price_cents={price_cents}&item_type=audio"), ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); // Publish h.client .put_form(&format!("/api/projects/{project_id}"), "is_public=true") .await; h.client .put_form(&format!("/api/items/{item_id}"), "is_public=true") .await; h.client.post_form("/logout", "").await; (seller_id, project_id, item_id) } /// Post a JSON webhook event to the harness. async fn post_webhook_json( h: &mut TestHarness, event_type: &str, object: serde_json::Value, ) -> crate::harness::client::TestResponse { post_webhook_json_with_event_id(h, "evt_mock_001", event_type, object).await } /// Post a JSON webhook event with an explicit event id. Distinct ids are /// required when a single test delivers more than one event, since the webhook /// dedup short-circuits a repeated id. async fn post_webhook_json_with_event_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 = crate::harness::stripe::sign_webhook_payload( &payload, crate::harness::stripe::TEST_WEBHOOK_SECRET, ); h.client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[ ("stripe-signature", &signature), ("content-type", "application/json"), ], ) .await } // Checkout → Webhook → Access flow #[tokio::test] async fn checkout_creates_session_and_webhook_completes_purchase() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; // Buyer signs up let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; // Buyer initiates checkout (hits MockPaymentProvider) let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false", ) .await; // MockPaymentProvider returns a redirect URL assert_eq!( resp.status, 303, "Checkout should redirect or succeed, got: {} {}", resp.status, resp.text ); // Verify mock recorded the checkout let mock_stripe = h.mock_stripe.as_ref().unwrap(); let checkouts = mock_stripe.checkouts(); assert_eq!( checkouts.len(), 1, "Expected 1 checkout session, got {}", checkouts.len() ); // Simulate Stripe webhook completing the purchase // Find the pending transaction the checkout handler created let pending_tx: Option<(String,)> = sqlx::query_as( "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_optional(&h.db) .await .unwrap(); assert!( pending_tx.is_some(), "Checkout should have created a pending transaction" ); let actual_session_id = &pending_tx.unwrap().0; // Build webhook event with the actual session ID 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": actual_session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": "pi_mock_001", }); let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify transaction completed let status: String = sqlx::query_scalar( "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid", ) .bind(buyer_id) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "completed"); // Verify sales count 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); } /// An asynchronous payment method (ACH/SEPA/Bacs) delivers /// `checkout.session.completed` with `payment_status="unpaid"` BEFORE funds /// settle. The one-time purchase must NOT finalize then, goods must not ship /// against unpaid funds, and must finalize when Stripe later re-delivers the /// settled session via `checkout.session.async_payment_succeeded`. #[tokio::test] async fn async_unpaid_checkout_defers_until_payment_succeeds() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 777).await; let buyer_id = h .signup("asyncbuyer", "asyncbuyer@test.com", "pass1234") .await; // Buyer initiates checkout, creating the pending transaction. h.client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false", ) .await; let pending_tx: Option<(String,)> = sqlx::query_as( "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_optional(&h.db) .await .unwrap(); let session_id = pending_tx .expect("checkout created a pending transaction") .0; 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()); // 1. completed + unpaid → deferred (still pending, no sale counted). let unpaid = serde_json::json!({ "id": session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": "pi_async_001", "payment_status": "unpaid", "currency": "usd", }); let resp = post_webhook_json_with_event_id( &mut h, "evt_async_unpaid", "checkout.session.completed", unpaid, ) .await; assert_eq!( resp.status.as_u16(), 200, "webhook should ack even when deferring: {}", resp.text ); let status: String = sqlx::query_scalar( "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid", ) .bind(buyer_id) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( status, "pending", "unpaid async session must NOT finalize the purchase" ); 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, "no sale should be counted before settlement"); // 2. async_payment_succeeded (funds settled) → finalize. let settled = serde_json::json!({ "id": session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": "pi_async_001", "payment_status": "paid", "currency": "usd", }); let resp = post_webhook_json_with_event_id( &mut h, "evt_async_paid", "checkout.session.async_payment_succeeded", settled, ) .await; assert_eq!( resp.status.as_u16(), 200, "settlement webhook failed: {}", resp.text ); let status: String = sqlx::query_scalar( "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid", ) .bind(buyer_id) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( status, "completed", "settled async session must finalize the purchase" ); 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, "sale should be counted once settled"); } // Email assertions #[tokio::test] async fn purchase_webhook_sends_emails() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await; // Enable sale notifications for seller let buyer_id = h .signup("emailbuyer", "emailbuyer@test.com", "pass1234") .await; // Insert pending transaction directly (skip checkout for focused email test) let session_id = "cs_email_test"; 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, 'Track', 'seller')", ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .bind(session_id) .execute(&h.db) .await .unwrap(); // Fire webhook 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_email_test", }); let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await; assert_eq!(resp.status.as_u16(), 200); // Wait briefly for fire-and-forget email tasks to complete tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Assert emails were sent let mock_email = h.mock_email.as_ref().unwrap(); let buyer_emails = mock_email.sent_to("emailbuyer@test.com"); assert!( buyer_emails .iter() .any(|e| e.subject.contains("purchase") || e.subject.contains("Purchase")), "Expected purchase confirmation to buyer, got: {:?}", buyer_emails.iter().map(|e| &e.subject).collect::>() ); let seller_emails = mock_email.sent_to("seller@test.com"); assert!( seller_emails .iter() .any(|e| e.subject.contains("sale") || e.subject.contains("Sale")), "Expected sale notification to seller, got: {:?}", seller_emails.iter().map(|e| &e.subject).collect::>() ); } // Free item claim with promo code // Note: free_claim_with_promo_code is already covered by the existing // promo_codes_discount and promo_codes_free_access workflow test suites. // The mock payment provider is validated by the other tests in this file. // Failure modes #[tokio::test] async fn checkout_rejects_own_item() { let mut h = TestHarness::with_mocks().await; let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; // Login as seller and try to buy own item h.login("seller", "pass1234").await; let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false", ) .await; assert_eq!( resp.status.as_u16(), 400, "Should reject self-purchase: {}", resp.text ); // No checkout should have been created let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!(mock_stripe.checkouts().len(), 0); } #[tokio::test] async fn checkout_rejects_unpublished_item() { let mut h = TestHarness::with_mocks().await; let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await; // Unpublish the item h.login("seller", "pass1234").await; h.client .put_form(&format!("/api/items/{item_id}"), "is_public=false") .await; h.client.post_form("/logout", "").await; // Buyer tries to checkout h.signup("draftbuyer", "db@test.com", "pass1234").await; let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false", ) .await; // Should get an error (400 for "not available for purchase") assert_eq!( resp.status, 400, "Should reject unpublished item purchase, got: {} {}", resp.status, resp.text ); // No checkout session created let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!( mock_stripe.checkouts().len(), 0, "Unpublished item should not create checkout" ); } #[tokio::test] async fn checkout_rejects_free_item() { let mut h = TestHarness::with_mocks().await; let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 0).await; h.signup("freebuyer2", "fb2@test.com", "pass1234").await; let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false", ) .await; assert_eq!( resp.status.as_u16(), 400, "Should reject free item checkout: {}", resp.text ); } #[tokio::test] async fn duplicate_purchase_prevented() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; let buyer_id = h.signup("dupbuyer", "dup@test.com", "pass1234").await; // Insert a completed transaction (buyer already purchased) sqlx::query( r"INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status, stripe_checkout_session_id, item_title, seller_username, completed_at) VALUES ($1, $2, $3::uuid, 999, 'completed', 'cs_already', 'Track', 'seller', NOW())", ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .execute(&h.db) .await .unwrap(); // Attempt to checkout again, should redirect (already purchased) let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false", ) .await; assert!( resp.status.is_redirection(), "Already-purchased item should redirect, got: {} {}", resp.status, resp.text ); // No new checkout session should be created let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!(mock_stripe.checkouts().len(), 0); } // Purchase grants access #[tokio::test] async fn purchase_grants_access() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; // Buyer initiates checkout h.client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false", ) .await; // Find pending transaction let session_id: String = sqlx::query_scalar( "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); // Fire webhook to complete 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_access_001", }); let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify buyer has access via has_purchased_item query let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'", ) .bind(buyer_id) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "Buyer should have access after purchase"); } // Refund revokes access #[tokio::test] async fn refund_revokes_access() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; let pi_id = "pi_refund_mock_001"; // Insert a completed transaction with known payment_intent_id 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, 999, 'completed', $4, 'cs_refund_mock', 'Track', 'seller', NOW())", ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .bind(pi_id) .execute(&h.db) .await .unwrap(); // Set sales_count to 1 (since we inserted a completed transaction) sqlx::query("UPDATE items SET sales_count = 1 WHERE id = $1::uuid") .bind(&item_id) .execute(&h.db) .await .unwrap(); // Fire ChargeRefunded webhook let charge = serde_json::json!({ "id": "ch_refund_mock", "object": "charge", "amount": 999, "amount_refunded": 999, "payment_intent": pi_id, }); let resp = post_webhook_json(&mut h, "charge.refunded", charge).await; assert_eq!( resp.status.as_u16(), 200, "Refund webhook failed: {}", resp.text ); // Verify transaction status is '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 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, "sales_count should be decremented after refund"); } // Tip checkout and webhook #[tokio::test] async fn tip_checkout_and_webhook() { let mut h = TestHarness::with_mocks().await; // Create recipient with Stripe connected and tips enabled let recipient_id = h .signup("recipient", "recipient@test.com", "pass1234") .await; h.grant_creator(recipient_id).await; sqlx::query( "UPDATE users SET stripe_account_id = 'acct_mock_recipient', stripe_charges_enabled = true, tips_enabled = true WHERE id = $1", ) .bind(recipient_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; // Buyer signs up let tipper_id = h.signup("tipper", "tipper@test.com", "pass1234").await; // POST to tip checkout (amount is in dollars per TipForm) let resp = h .client .post_form( &format!("/stripe/checkout/tip/{recipient_id}"), "amount_dollars=5", ) .await; assert_eq!( resp.status, 303, "Tip checkout should redirect, got: {} {}", resp.status, resp.text ); // Verify mock checkout was created let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert!( !mock_stripe.checkouts().is_empty(), "Should have created a tip checkout session" ); // Find the pending tip created by the checkout handler let tip_session_id: String = sqlx::query_scalar( "SELECT stripe_checkout_session_id FROM tips WHERE tipper_id = $1 AND status = 'pending'", ) .bind(tipper_id) .fetch_one(&h.db) .await .unwrap(); // Fire CheckoutSessionCompleted webhook with tip metadata let mut meta = HashMap::new(); meta.insert("checkout_type".to_string(), "tip".to_string()); meta.insert("tipper_id".to_string(), tipper_id.to_string()); meta.insert("recipient_id".to_string(), recipient_id.to_string()); let session = serde_json::json!({ "id": tip_session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": "pi_tip_001", }); let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await; assert_eq!( resp.status.as_u16(), 200, "Tip webhook failed: {}", resp.text ); // Verify tip status is 'completed' let status: String = sqlx::query_scalar("SELECT status FROM tips WHERE tipper_id = $1 AND recipient_id = $2") .bind(tipper_id) .bind(recipient_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "completed"); } // Revenue splits recorded on purchase #[tokio::test] async fn revenue_splits_recorded_on_purchase() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 999).await; // Create a collaborator user let collab_id = h .signup("collaborator", "collab@test.com", "pass1234") .await; h.client.post_form("/logout", "").await; // Add collaborator as project member with 30% split sqlx::query( "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by, accepted_at) VALUES ($1::uuid, $2, 'member', 30, $3, NOW())", ) .bind(&project_id) .bind(collab_id) .bind(seller_id) .execute(&h.db) .await .unwrap(); // Buyer checkouts let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; h.client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false", ) .await; // Find pending transaction let session_id: String = sqlx::query_scalar( "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); // Fire purchase webhook 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_split_001", }); let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Wait briefly for split recording (runs after transaction commit) tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Verify revenue_splits has a row for the collaborator let split_amount: i32 = sqlx::query_scalar("SELECT amount_cents FROM revenue_splits WHERE recipient_id = $1") .bind(collab_id) .fetch_one(&h.db) .await .unwrap(); // 999 * 30 / 100 = 299 (integer division) assert_eq!( split_amount, 299, "Collaborator should get 30% of 999 = 299 cents" ); } // PWYW checkout with custom amount #[tokio::test] async fn pwyw_checkout_custom_amount() { let mut h = TestHarness::with_mocks().await; let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await; // Enable PWYW on the item (checkbox uses "on", not "true") h.login("seller", "pass1234").await; h.client .put_form( &format!("/api/items/{item_id}"), "pwyw_enabled=on&pwyw_min_cents=100", ) .await; h.client.post_form("/logout", "").await; // Buyer checkouts with custom amount let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false&amount_cents=2500", ) .await; assert_eq!( resp.status, 303, "PWYW checkout should redirect, got: {} {}", resp.status, resp.text ); // Verify mock checkout was created let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!( mock_stripe.checkouts().len(), 1, "Should have created a checkout" ); // Verify the pending transaction has amount_cents = 2500 let amount: i32 = sqlx::query_scalar( "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( amount, 2500, "PWYW transaction should have buyer's chosen amount" ); } // Discount code reduces checkout amount #[tokio::test] async fn discount_code_reduces_checkout_amount() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await; // Create a 50% discount code for the seller sqlx::query( "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents) VALUES ($1, 'HALF50', 'discount', 'percentage', 50, 0)", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); // Buyer checkouts with promo code let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "share_contact=false&promo_code=HALF50", ) .await; assert_eq!( resp.status, 303, "Discount checkout should redirect, got: {} {}", resp.status, resp.text ); // Verify the pending transaction has amount_cents = 500 (50% of 1000) let amount: i32 = sqlx::query_scalar( "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( amount, 500, "50% discount should halve the price from 1000 to 500" ); } // Contact sharing on purchase #[tokio::test] async fn contact_sharing_on_purchase() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; // Buyer checkouts with share_contact=true let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; let resp = h .client .post_form(&format!("/stripe/checkout/{item_id}"), "share_contact=true") .await; assert_eq!( resp.status, 303, "Checkout should redirect, got: {} {}", resp.status, resp.text ); // Find pending transaction let session_id: String = sqlx::query_scalar( "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); // Verify share_contact is true on the pending transaction let share_contact: bool = sqlx::query_scalar( "SELECT share_contact FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert!( share_contact, "Transaction should have share_contact = true" ); // Complete via webhook 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_contact_001", }); let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify transaction completed and still has share_contact = true let (status, share): (String, bool) = sqlx::query_as( "SELECT status, share_contact FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid", ) .bind(buyer_id) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "completed"); assert!( share, "Completed transaction should preserve share_contact = true" ); } // Creator-initiated refund via API endpoint #[tokio::test] async fn creator_refund_endpoint() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; let pi_id = "pi_refund_api_001"; // Insert a completed transaction with a payment intent 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, 999, 'completed', $4, 'cs_refund_api', 'Track', 'seller', NOW())", ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .bind(pi_id) .execute(&h.db) .await .unwrap(); // Get the transaction ID let tx_id: String = sqlx::query_scalar("SELECT id::text FROM transactions WHERE stripe_payment_intent_id = $1") .bind(pi_id) .fetch_one(&h.db) .await .unwrap(); // Log in as seller and hit the refund endpoint h.client.post_form("/logout", "").await; h.login("seller", "pass1234").await; let resp = h .client .post_json( &format!("/api/items/{item_id}/refund"), &format!(r#"{{"transaction_id": "{tx_id}"}}"#), ) .await; assert_eq!( resp.status, 200, "Creator refund endpoint should succeed: {} {}", resp.status, resp.text ); let data: Value = resp.json(); assert_eq!(data["ok"], true); } #[tokio::test] async fn creator_refund_non_owner_rejected() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; // Insert a completed transaction 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, 999, 'completed', 'pi_notown', 'cs_notown', 'Track', 'seller', NOW())", ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .execute(&h.db) .await .unwrap(); let tx_id: String = sqlx::query_scalar( "SELECT id::text FROM transactions WHERE stripe_payment_intent_id = 'pi_notown'", ) .fetch_one(&h.db) .await .unwrap(); // Log in as a different creator (not the owner) h.client.post_form("/logout", "").await; let _other = h.create_creator("other").await; let resp = h .client .post_json( &format!("/api/items/{item_id}/refund"), &format!(r#"{{"transaction_id": "{tx_id}"}}"#), ) .await; assert!( resp.status == 403 || resp.status == 404, "Non-owner refund should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn creator_refund_free_claim_rejected() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; // Insert a completed transaction WITHOUT payment intent (free claim) sqlx::query( r"INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status, stripe_checkout_session_id, item_title, seller_username, completed_at) VALUES ($1, $2, $3::uuid, 0, 'completed', 'cs_free_claim', 'Track', 'seller', NOW())", ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .execute(&h.db) .await .unwrap(); let tx_id: String = sqlx::query_scalar( "SELECT id::text FROM transactions WHERE stripe_checkout_session_id = 'cs_free_claim'", ) .fetch_one(&h.db) .await .unwrap(); // Log in as seller and try to refund the free claim h.client.post_form("/logout", "").await; h.login("seller", "pass1234").await; let resp = h .client .post_json( &format!("/api/items/{item_id}/refund"), &format!(r#"{{"transaction_id": "{tx_id}"}}"#), ) .await; assert_eq!( resp.status, 400, "Refunding a free claim should fail: {} {}", resp.status, resp.text ); } #[tokio::test] async fn creator_refund_already_claimed_rejected() { // The claim (`completed -> refunding`) is what stops a rapid double-submit // consuming another cart line's refundable balance (Pay-S1, Run 9). The db // layer's own tests cover the primitive; this one covers the route still // asking for the claim, which is the guard an extraction can silently drop. let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await; let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; // A transaction already claimed by an in-flight refund. 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, 999, 'refunding', 'pi_claimed', 'cs_claimed', 'Track', 'seller', NOW())", ) .bind(buyer_id) .bind(seller_id) .bind(&item_id) .execute(&h.db) .await .unwrap(); let tx_id: String = sqlx::query_scalar( "SELECT id::text FROM transactions WHERE stripe_payment_intent_id = 'pi_claimed'", ) .fetch_one(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("seller", "pass1234").await; let resp = h .client .post_json( &format!("/api/items/{item_id}/refund"), &format!(r#"{{"transaction_id": "{tx_id}"}}"#), ) .await; assert_eq!( resp.status, 400, "Refunding an already-claimed transaction should fail: {} {}", resp.status, resp.text ); } // Project-level checkout #[tokio::test] async fn project_checkout_creates_session() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await; // Set project pricing to BuyOnce $19.99 sqlx::query( "UPDATE projects SET pricing_model = 'buy_once', price_cents = 1999 WHERE id = $1::uuid", ) .bind(&project_id) .execute(&h.db) .await .unwrap(); let _buyer_id = h .signup("projbuyer", "projbuyer@test.com", "pass1234") .await; let resp = h .client .post_form( &format!("/stripe/checkout/project/{project_id}"), "share_contact=false", ) .await; assert_eq!( resp.status, 303, "Project checkout should redirect, got: {} {}", resp.status, resp.text ); // Verify pending transaction created let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions WHERE seller_id = $1 AND project_id = $2::uuid AND status = 'pending'", ) .bind(seller_id) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "Should have 1 pending project transaction"); } #[tokio::test] async fn project_checkout_free_project_rejected() { let mut h = TestHarness::with_mocks().await; let (_seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await; // Project pricing defaults to Free let _buyer_id = h.signup("freeproj", "freeproj@test.com", "pass1234").await; let resp = h .client .post_form( &format!("/stripe/checkout/project/{project_id}"), "share_contact=false", ) .await; assert_eq!( resp.status, 400, "Free project checkout should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn project_checkout_self_purchase_rejected() { let mut h = TestHarness::with_mocks().await; let (_seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await; // Set project pricing sqlx::query( "UPDATE projects SET pricing_model = 'buy_once', price_cents = 1999 WHERE id = $1::uuid", ) .bind(&project_id) .execute(&h.db) .await .unwrap(); // Log in as seller and try to buy own project h.login("seller", "pass1234").await; let resp = h .client .post_form( &format!("/stripe/checkout/project/{project_id}"), "share_contact=false", ) .await; assert_eq!( resp.status, 400, "Self-purchase of project should be rejected: {} {}", resp.status, resp.text ); } // Cart checkout via Stripe #[tokio::test] async fn cart_checkout_single_seller() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await; let buyer_id = h.signup("cartchk", "cartchk@test.com", "pass1234").await; // Add item to cart h.client .post_form(&format!("/api/cart/{item_id}"), "") .await; // Checkout cart for this seller let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={seller_id}&share_contact=false"), ) .await; assert_eq!( resp.status, 303, "Cart checkout should redirect, got: {} {}", resp.status, resp.text ); // Verify mock checkout was created let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert!( !mock_stripe.checkouts().is_empty(), "Should have created a checkout session" ); // Verify pending transaction: one row, at the item's price. let (count, total): (i64, i64) = sqlx::query_as( "SELECT COUNT(*), COALESCE(SUM(amount_cents), 0) FROM transactions \ WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "one pending transaction for a one-item cart"); assert_eq!(total, 500, "pending amount must be the item's price"); } /// Two paid items from the same seller in one cart produce one pending /// transaction per item, and their amounts sum to the cart total. Pins the /// cart core's per-line insert against a single-row shortcut. #[tokio::test] async fn cart_checkout_two_items_same_seller_sums_to_cart_total() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, item_a) = setup_paid_item(&mut h, 500).await; // Second paid item under the same seller and project. h.login("seller", "pass1234").await; let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=Second+Track&price_cents=250&item_type=audio", ) .await; assert_eq!(resp.status, 200, "create second item failed: {}", resp.text); let second: Value = resp.json(); let item_b = second["id"].as_str().unwrap().to_string(); h.client .put_form(&format!("/api/items/{item_b}"), "is_public=true") .await; h.client.post_form("/logout", "").await; let buyer_id = h.signup("cartsum", "cartsum@test.com", "pass1234").await; h.client.post_form(&format!("/api/cart/{item_a}"), "").await; h.client.post_form(&format!("/api/cart/{item_b}"), "").await; let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={seller_id}&share_contact=false"), ) .await; assert_eq!( resp.status, 303, "two-item cart checkout should redirect: {} {}", resp.status, resp.text ); let (count, total): (i64, i64) = sqlx::query_as( "SELECT COUNT(*), COALESCE(SUM(amount_cents), 0) FROM transactions \ WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 2, "one pending row per paid cart line"); assert_eq!( total, 750, "pending amounts must sum to the two items' combined price" ); // Both rows belong to one Stripe session: the cart is charged once. let sessions: i64 = sqlx::query_scalar( "SELECT COUNT(DISTINCT stripe_checkout_session_id) FROM transactions \ WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sessions, 1, "a cart checkout is one Stripe session"); } #[tokio::test] async fn cart_checkout_empty_cart_rejected() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, _item_id) = setup_paid_item(&mut h, 500).await; let _buyer_id = h .signup("emptycart", "emptycart@test.com", "pass1234") .await; // Don't add anything to cart let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={seller_id}&share_contact=false"), ) .await; assert_eq!( resp.status, 400, "Empty cart checkout should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn cart_checkout_self_purchase_rejected() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await; // Create a second user who adds the item to cart let _buyer_id = h.signup("cartself", "cartself@test.com", "pass1234").await; h.client .post_form(&format!("/api/cart/{item_id}"), "") .await; // Try to checkout with seller_id = self h.client.post_form("/logout", "").await; h.login("seller", "pass1234").await; // Seller adds own item to cart via DB directly (API blocks it, so simulate) sqlx::query( "INSERT INTO cart_items (user_id, item_id) VALUES ($1, $2::uuid) ON CONFLICT DO NOTHING", ) .bind(seller_id) .bind(&item_id) .execute(&h.db) .await .unwrap(); let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={seller_id}&share_contact=false"), ) .await; assert_eq!( resp.status, 400, "Self-purchase via cart should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn cart_checkout_free_items_claimed_immediately() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, _item_id) = setup_paid_item(&mut h, 0).await; // Create a free item h.login("seller", "pass1234").await; let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=Free+Track&item_type=digital&price_cents=0", ) .await; assert_eq!(resp.status, 200, "{}", resp.text); let free_item: Value = resp.json(); let free_item_id = free_item["id"].as_str().unwrap().to_string(); h.client .put_form(&format!("/api/items/{free_item_id}"), "is_public=true") .await; h.client.post_form("/logout", "").await; let buyer_id = h.signup("freecart", "freecart@test.com", "pass1234").await; // Add free item to cart h.client .post_form(&format!("/api/cart/{free_item_id}"), "") .await; // Cart checkout, free items should be claimed immediately, no Stripe session let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={seller_id}&share_contact=false"), ) .await; // Either redirect back (all free, no Stripe needed) or success assert!( !resp.status.is_server_error(), "Free cart checkout failed: {} {}", resp.status, resp.text ); // Verify free item was claimed (completed transaction exists) let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'", ) .bind(buyer_id) .bind(&free_item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "Free item should be claimed immediately"); // Verify item removed from cart let cart_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM cart_items WHERE user_id = $1 AND item_id = $2::uuid", ) .bind(buyer_id) .bind(&free_item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( cart_count, 0, "Free item should be removed from cart after claim" ); } /// A promo applied at cart checkout discounts the pending transaction and /// reserves exactly one promo use. Exercises the cart core's promo path, which /// the single-item promo suite (`promo_codes_checkout`) does not cover. #[tokio::test] async fn cart_checkout_promo_discount_applied() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await; // Seller is logged in after setup; create a 20%-off code. h.login("seller", "pass1234").await; let resp = h .client .post_form( "/api/promo-codes", "code=CART20&code_purpose=discount&discount_type=percentage&discount_value=20", ) .await; assert_eq!( resp.status, 200, "create promo failed: {} {}", resp.status, resp.text ); h.client.post_form("/logout", "").await; let buyer_id = h .signup("cartpromo", "cartpromo@test.com", "pass1234") .await; h.client .post_form(&format!("/api/cart/{item_id}"), "") .await; let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={seller_id}&share_contact=false&promo_code=CART20"), ) .await; assert_eq!( resp.status, 303, "promo cart checkout should proceed: {} {}", resp.status, resp.text ); // 20% off $5.00 = $4.00 pending. let amount: i32 = sqlx::query_scalar( "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( amount, 400, "pending amount should be the 20%-discounted price" ); let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CART20'") .fetch_one(&h.db) .await .unwrap(); assert_eq!( use_count, 1, "a completed cart checkout reserves exactly one promo use" ); } /// An ALL-FREE cart (a 100%-off promo zeroes every line) must still consume /// exactly one promo use, and a max_uses-limited code must be exhausted after /// it. Regression for the audit finding where the free-cart early-return fired /// before the promo reservation, letting a limited/100%-off code be redeemed /// unlimited times via cart checkout. #[tokio::test] async fn cart_checkout_all_free_promo_consumes_one_use() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await; // 100%-off, single-use code (direct SQL, the API form doesn't take max_uses). sqlx::query( "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \ VALUES ($1, 'CARTFREE', 'discount', 'percentage', 100, 0, 1)", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; // First buyer: all-free cart checkout claims the item and burns the one use. let buyer1 = h .signup("cartfree1", "cartfree1@test.com", "pass1234") .await; h.client .post_form(&format!("/api/cart/{item_id}"), "") .await; let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={seller_id}&share_contact=false&promo_code=CARTFREE"), ) .await; assert_eq!( resp.status, 303, "all-free cart checkout should succeed: {} {}", resp.status, resp.text ); // Free claim recorded (a $0 completed transaction), no Stripe session. let amount: i32 = sqlx::query_scalar("SELECT amount_cents FROM transactions WHERE buyer_id = $1") .bind(buyer1) .fetch_one(&h.db) .await .unwrap(); assert_eq!(amount, 0, "all-free cart should record a $0 claim"); let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'") .fetch_one(&h.db) .await .unwrap(); assert_eq!( use_count, 1, "an all-free cart checkout must consume exactly one promo use" ); // Second buyer: the code is now exhausted; the all-free cart must be rejected // and the use_count must not move past its max. h.client.post_form("/logout", "").await; let _buyer2 = h .signup("cartfree2", "cartfree2@test.com", "pass1234") .await; h.client .post_form(&format!("/api/cart/{item_id}"), "") .await; let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={seller_id}&share_contact=false&promo_code=CARTFREE"), ) .await; assert_eq!( resp.status.as_u16(), 400, "exhausted code on an all-free cart should be rejected: {}", resp.text ); let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(use_count, 1, "exhausted code must not exceed its max_uses"); let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!( mock_stripe.checkouts().len(), 0, "no Stripe session for an all-free cart" ); } /// A promo that drops the cart total below the Stripe minimum is rejected and /// must NOT burn a promo use. Pins the cart core's "reserve only after the /// min-charge gate" ordering (the gate runs before reservation). #[tokio::test] async fn cart_checkout_promo_sub_minimum_not_burned() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 100).await; h.login("seller", "pass1234").await; // $0.70 off $1.00 -> 30¢, below the 50¢ Stripe minimum. let resp = h .client .post_form( "/api/promo-codes", "code=CARTTINY&code_purpose=discount&discount_type=fixed&discount_value=70", ) .await; assert_eq!( resp.status, 200, "create promo failed: {} {}", resp.status, resp.text ); h.client.post_form("/logout", "").await; let buyer_id = h.signup("carttiny", "carttiny@test.com", "pass1234").await; h.client .post_form(&format!("/api/cart/{item_id}"), "") .await; let resp = h .client .post_form( "/stripe/checkout/cart", &format!("seller_id={seller_id}&share_contact=false&promo_code=CARTTINY"), ) .await; assert_eq!( resp.status.as_u16(), 400, "sub-minimum cart total must be rejected: {} {}", resp.status, resp.text ); let pending: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( pending, 0, "rejected sub-minimum cart checkout must not create a pending row" ); let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTTINY'") .fetch_one(&h.db) .await .unwrap(); assert_eq!( use_count, 0, "promo must not be reserved when the cart is rejected pre-reservation" ); } /// Checkout-all across two sellers chains through `drain_to_paid`: it should /// reach a paid seller and return a Stripe URL, exercising the shared core via /// the cross-seller entry point. #[tokio::test] async fn cart_checkout_all_cross_seller_chain() { let mut h = TestHarness::with_mocks().await; let (_seller_a, _proj_a, item_a) = setup_paid_item(&mut h, 500).await; // Second seller with their own paid item. let seller_b = h.signup("sellerb", "sellerb@test.com", "pass1234").await; h.grant_creator(seller_b).await; sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_b', stripe_charges_enabled = true WHERE id = $1") .bind(seller_b) .execute(&h.db) .await .unwrap(); h.login("sellerb", "pass1234").await; let resp = h .client .post_form("/api/projects", "slug=shopb&title=ShopB") .await; let proj_b: Value = resp.json(); let proj_b_id = proj_b["id"].as_str().unwrap().to_string(); let resp = h .client .post_form( &format!("/api/projects/{proj_b_id}/items"), "title=TrackB&price_cents=700&item_type=audio", ) .await; let item_b: Value = resp.json(); let item_b_id = item_b["id"].as_str().unwrap().to_string(); h.client .put_form(&format!("/api/projects/{proj_b_id}"), "is_public=true") .await; h.client .put_form(&format!("/api/items/{item_b_id}"), "is_public=true") .await; h.client.post_form("/logout", "").await; let _buyer_id = h.signup("cartall", "cartall@test.com", "pass1234").await; h.client.post_form(&format!("/api/cart/{item_a}"), "").await; h.client .post_form(&format!("/api/cart/{item_b_id}"), "") .await; let resp = h .client .post_form("/stripe/checkout/cart/all", "share_contact=false") .await; assert_eq!( resp.status, 303, "checkout-all should reach a paid seller: {} {}", resp.status, resp.text ); let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert!( !mock_stripe.checkouts().is_empty(), "chain should create at least one checkout session" ); } // Subscription checkout #[tokio::test] async fn subscription_checkout_creates_session() { let mut h = TestHarness::with_mocks().await; // Create a seller with Stripe connected let seller_id = h .signup("subseller", "subseller@test.com", "pass1234") .await; h.grant_creator(seller_id).await; sqlx::query( "UPDATE users SET stripe_account_id = 'acct_mock_sub', stripe_charges_enabled = true WHERE id = $1", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); // Create a project h.client.post_form("/logout", "").await; h.login("subseller", "pass1234").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(); h.client .put_json( &format!("/api/projects/{project_id}"), r#"{"is_public": true}"#, ) .await; // Create a subscription tier with fake Stripe IDs sqlx::query( r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id) VALUES ($1::uuid, 'Gold', 999, true, 'prod_mock_gold', 'price_mock_gold')", ) .bind(&project_id) .execute(&h.db) .await .unwrap(); let tier_id: String = sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid") .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); // Log out seller, sign up subscriber h.client.post_form("/logout", "").await; let _subscriber_id = h .signup("subscriber", "subscriber@test.com", "pass1234") .await; let resp = h .client .post_form(&format!("/stripe/subscribe/{tier_id}"), "") .await; assert_eq!( resp.status, 303, "Subscription checkout should redirect, got: {} {}", resp.status, resp.text ); let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert!( !mock_stripe.checkouts().is_empty(), "Should have created a subscription checkout" ); } #[tokio::test] async fn subscription_checkout_self_subscribe_rejected() { let mut h = TestHarness::with_mocks().await; let seller_id = h .signup("selfsubseller", "selfsubseller@test.com", "pass1234") .await; h.grant_creator(seller_id).await; sqlx::query( "UPDATE users SET stripe_account_id = 'acct_selfsub', stripe_charges_enabled = true WHERE id = $1", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("selfsubseller", "pass1234").await; let resp = h .client .post_form("/api/projects", "slug=selfsub&title=Self+Sub") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); h.client .put_json( &format!("/api/projects/{project_id}"), r#"{"is_public": true}"#, ) .await; sqlx::query( r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id) VALUES ($1::uuid, 'Self', 999, true, 'prod_self', 'price_self')", ) .bind(&project_id) .execute(&h.db) .await .unwrap(); let tier_id: String = sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid") .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); // Seller tries to subscribe to own project let resp = h .client .post_form(&format!("/stripe/subscribe/{tier_id}"), "") .await; assert_eq!( resp.status, 400, "Self-subscription should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn subscription_checkout_inactive_tier_rejected() { let mut h = TestHarness::with_mocks().await; let seller_id = h .signup("inactseller", "inactseller@test.com", "pass1234") .await; h.grant_creator(seller_id).await; sqlx::query( "UPDATE users SET stripe_account_id = 'acct_inact', stripe_charges_enabled = true WHERE id = $1", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("inactseller", "pass1234").await; let resp = h .client .post_form("/api/projects", "slug=inactproj&title=Inactive") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); h.client .put_json( &format!("/api/projects/{project_id}"), r#"{"is_public": true}"#, ) .await; // Create an INACTIVE tier sqlx::query( r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id) VALUES ($1::uuid, 'Archived', 999, false, 'prod_arch', 'price_arch')", ) .bind(&project_id) .execute(&h.db) .await .unwrap(); let tier_id: String = sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid") .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; let _sub_id = h.signup("inactsub", "inactsub@test.com", "pass1234").await; let resp = h .client .post_form(&format!("/stripe/subscribe/{tier_id}"), "") .await; assert_eq!( resp.status, 400, "Inactive tier should be rejected: {} {}", resp.status, resp.text ); } // Creator tier checkout (requires config) #[tokio::test] async fn creator_tier_checkout_not_configured_rejected() { let mut h = TestHarness::with_mocks().await; let _user_id = h.signup("tierbuy", "tierbuy@test.com", "pass1234").await; // Config has empty creator_tier_prices, so this should fail with "not configured" let resp = h .client .post_form("/stripe/creator-tier", "tier=small_files") .await; assert_eq!( resp.status, 400, "Creator tier checkout without config should fail: {} {}", resp.status, resp.text ); } // Subscription single-live-row invariant (ultra-fuzz Run 6, R6-Pay-N1) /// Post a subscription `checkout.session.completed` webhook with explicit event /// and Stripe-subscription IDs so two calls aren't deduped as a replay. async fn post_subscription_webhook( h: &mut TestHarness, event_id: &str, session_id: &str, stripe_sub_id: &str, subscriber_id: &str, project_id: &str, tier_id: &str, ) -> crate::harness::client::TestResponse { let object = serde_json::json!({ "id": session_id, "subscription": stripe_sub_id, "customer": "cus_n1_invariant", "metadata": { "checkout_type": "subscription", "subscriber_id": subscriber_id, "project_id": project_id, "tier_id": tier_id, } }); let payload = serde_json::json!({ "id": event_id, "type": "checkout.session.completed", "data": {"object": object}, }) .to_string(); let signature = crate::harness::stripe::sign_webhook_payload( &payload, crate::harness::stripe::TEST_WEBHOOK_SECRET, ); h.client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[ ("stripe-signature", &signature), ("content-type", "application/json"), ], ) .await } /// A resubscribe over a lingering `past_due` row must leave exactly one live /// (active) subscription: the stale row is canceled by `create_subscription`'s /// pre-insert cleanup, not left to coexist with the new active row. #[tokio::test] async fn subscription_resubscribe_after_past_due_leaves_one_live_row() { let mut h = TestHarness::with_mocks().await; let seller_id = h.signup("n1seller", "n1seller@test.com", "pass1234").await; h.grant_creator(seller_id).await; sqlx::query( "UPDATE users SET stripe_account_id = 'acct_n1', stripe_charges_enabled = true WHERE id = $1", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("n1seller", "pass1234").await; let resp = h .client .post_form("/api/projects", "slug=n1proj&title=N1+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); h.client .put_json( &format!("/api/projects/{project_id}"), r#"{"is_public": true}"#, ) .await; sqlx::query( r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id) VALUES ($1::uuid, 'Gold', 999, true, 'prod_n1', 'price_n1')", ) .bind(&project_id) .execute(&h.db) .await .unwrap(); let tier_id: String = sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid") .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; let subscriber_id = h.signup("n1fan", "n1fan@test.com", "pass1234").await; let subscriber_str = subscriber_id.to_string(); // First subscription → one active row. let r1 = post_subscription_webhook( &mut h, "evt_n1_1", "cs_n1_1", "sub_n1_1", &subscriber_str, &project_id, &tier_id, ) .await; assert_eq!( r1.status, 200, "first sub webhook: {} {}", r1.status, r1.text ); // Stripe dunning leaves the row past_due (the gate ignores it, so the user // appears unsubscribed and can resubscribe). sqlx::query( "UPDATE subscriptions SET status = 'past_due' WHERE stripe_subscription_id = 'sub_n1_1'", ) .execute(&h.db) .await .unwrap(); // Resubscribe: a brand-new Stripe subscription for the same (subscriber, project). let r2 = post_subscription_webhook( &mut h, "evt_n1_2", "cs_n1_2", "sub_n1_2", &subscriber_str, &project_id, &tier_id, ) .await; assert_eq!( r2.status, 200, "second sub webhook: {} {}", r2.status, r2.text ); let active_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2::uuid AND status = 'active'", ) .bind(subscriber_id) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( active_count, 1, "exactly one active subscription after resubscribe" ); let old_status: String = sqlx::query_scalar( "SELECT status FROM subscriptions WHERE stripe_subscription_id = 'sub_n1_1'", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!( old_status, "canceled", "stale past_due row should be canceled" ); let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2::uuid", ) .bind(subscriber_id) .bind(&project_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(total, 2, "old (canceled) + new (active)"); }