//! 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/{}/items", project_id), &format!("title=Track&price_cents={}&item_type=audio", price_cents), ).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 { let payload = serde_json::json!({ "id": "evt_mock_001", "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!( resp.status.is_redirection() || resp.status.is_success(), "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); } // --------------------------------------------------------------------------- // 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 sqlx::query("UPDATE users SET notify_sale = true WHERE id = $1") .bind(seller_id) .execute(&h.db) .await .unwrap(); 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!( resp.status.is_client_error(), "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!( resp.status.is_redirection() || resp.status.is_success(), "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) VALUES ($1::uuid, $2, 'member', 30, $3)", ) .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!( resp.status.is_redirection() || resp.status.is_success(), "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!( resp.status.is_redirection() || resp.status.is_success(), "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!( resp.status.is_redirection() || resp.status.is_success(), "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/{}/refund", item_id), &format!(r#"{{"transaction_id": "{}"}}"#, tx_id), ) .await; assert!( resp.status.is_success(), "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/{}/refund", item_id), &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/{}/refund", item_id), &format!(r#"{{"transaction_id": "{}"}}"#, tx_id), ) .await; assert!( resp.status.is_client_error(), "Refunding a free claim 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!( resp.status.is_redirection() || resp.status.is_success(), "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!( resp.status.is_client_error(), "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!( resp.status.is_client_error(), "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={}&share_contact=false", seller_id), ).await; assert!( resp.status.is_redirection() || resp.status.is_success(), "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 let count: 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!(count >= 1, "Should have at least 1 pending transaction"); } #[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={}&share_contact=false", seller_id), ).await; assert!( resp.status.is_client_error(), "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={}&share_contact=false", seller_id), ).await; assert!( resp.status.is_client_error(), "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/{}/items", project_id), "title=Free+Track&item_type=digital&price_cents=0", ).await; assert!(resp.status.is_success()); 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={}&share_contact=false", seller_id), ).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!(resp.status.is_success(), "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={}&share_contact=false&promo_code=CART20", seller_id), ).await; assert!( resp.status.is_redirection() || resp.status.is_success(), "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"); } /// 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!(resp.status.is_success(), "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={}&share_contact=false&promo_code=CARTTINY", seller_id), ).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/{}/items", proj_b_id), "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!( resp.status.is_redirection() || resp.status.is_success(), "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!( resp.status.is_redirection() || resp.status.is_success(), "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!( resp.status.is_client_error(), "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!( resp.status.is_client_error(), "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!( resp.status.is_client_error(), "Creator tier checkout without config should fail: {} {}", resp.status, resp.text ); }