//! Revenue split integration tests — project members, split recording on purchase, //! and split CSV export. use crate::harness::TestHarness; use makenotwork::db; use serde_json::Value; use std::collections::HashMap; // --------------------------------------------------------------------------- // Helpers (mirrors mock_payment_flows patterns) // --------------------------------------------------------------------------- /// 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; (seller_id, project_id, item_id) } 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_split_test", "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 } /// Complete a purchase through the webhook pipeline. /// Returns the buyer_id. async fn complete_purchase( h: &mut TestHarness, seller_id: db::UserId, item_id: &str, buyer_username: &str, buyer_email: &str, ) -> db::UserId { let buyer_id = h.signup(buyer_username, buyer_email, "pass1234").await; h.client.post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false", ).await; 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(); 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.to_string()); let session = serde_json::json!({ "id": session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": format!("pi_split_{}", buyer_username), }); let resp = post_webhook_json(h, "checkout.session.completed", session).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); buyer_id } // --------------------------------------------------------------------------- // 1. Add project member with split // --------------------------------------------------------------------------- #[tokio::test] async fn add_project_member_with_split() { let mut h = TestHarness::with_mocks().await; let _seller_id = h.create_creator("creator1").await; // Create project let resp = h.client.post_form("/api/projects", "slug=collab-proj&title=Collab+Project").await; assert!(resp.status.is_success(), "Create project failed: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Sign up a collaborator (separate session) h.client.post_form("/logout", "").await; let collab_id = h.signup("collaborator", "collab@test.com", "pass1234").await; // Log back in as creator h.client.post_form("/logout", "").await; h.login("creator1", "password123").await; // Add collaborator via API let resp = h.client.post_form( &format!("/api/projects/{}/members", project_id), "username=collaborator&split_percent=30", ).await; assert!( resp.status.is_success(), "Add member failed: {} {}", resp.status, resp.text ); // Verify member exists in DB let members: Vec<(db::UserId, i16)> = sqlx::query_as( "SELECT user_id, split_percent FROM project_members WHERE project_id = $1::uuid", ) .bind(&project_id) .fetch_all(&h.db) .await .unwrap(); assert_eq!(members.len(), 1, "Expected 1 project member"); assert_eq!(members[0].0, collab_id); assert_eq!(members[0].1, 30); } // --------------------------------------------------------------------------- // 2. Update split percentage // --------------------------------------------------------------------------- #[tokio::test] async fn update_split_percentage() { let mut h = TestHarness::with_mocks().await; let _seller_id = h.create_creator("creator2").await; let resp = h.client.post_form("/api/projects", "slug=split-upd&title=Split+Update").await; assert!(resp.status.is_success(), "Create project failed: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Create collaborator h.client.post_form("/logout", "").await; let collab_id = h.signup("collab2", "collab2@test.com", "pass1234").await; // Log back in as creator h.client.post_form("/logout", "").await; h.login("creator2", "password123").await; // Add with 30% let resp = h.client.post_form( &format!("/api/projects/{}/members", project_id), "username=collab2&split_percent=30", ).await; assert!(resp.status.is_success(), "Add member failed: {} {}", resp.status, resp.text); // Update to 50% by re-adding (the API uses ON CONFLICT DO UPDATE) let resp = h.client.post_form( &format!("/api/projects/{}/members", project_id), "username=collab2&split_percent=50", ).await; assert!(resp.status.is_success(), "Update member failed: {} {}", resp.status, resp.text); // Verify updated split let split: i16 = sqlx::query_scalar( "SELECT split_percent FROM project_members WHERE project_id = $1::uuid AND user_id = $2", ) .bind(&project_id) .bind(collab_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(split, 50, "Split should be updated to 50%"); } // --------------------------------------------------------------------------- // 3. Remove project member // --------------------------------------------------------------------------- #[tokio::test] async fn remove_project_member() { let mut h = TestHarness::with_mocks().await; let _seller_id = h.create_creator("creator3").await; let resp = h.client.post_form("/api/projects", "slug=rm-member&title=Remove+Member").await; assert!(resp.status.is_success(), "Create project failed: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Create collaborator h.client.post_form("/logout", "").await; let collab_id = h.signup("collab3", "collab3@test.com", "pass1234").await; // Log back in as creator h.client.post_form("/logout", "").await; h.login("creator3", "password123").await; // Add member let resp = h.client.post_form( &format!("/api/projects/{}/members", project_id), "username=collab3&split_percent=25", ).await; assert!(resp.status.is_success(), "Add member failed: {} {}", resp.status, resp.text); // Remove member let resp = h.client.delete( &format!("/api/projects/{}/members/{}", project_id, collab_id), ).await; assert!( resp.status.is_success(), "Remove member failed: {} {}", resp.status, resp.text ); // Verify member is gone let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM project_members WHERE project_id = $1::uuid AND user_id = $2", ) .bind(&project_id) .bind(collab_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "Member should be removed"); } // --------------------------------------------------------------------------- // 4. Split recorded on purchase // --------------------------------------------------------------------------- #[tokio::test] async fn split_recorded_on_purchase() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 1000).await; // Create a collaborator let collab_id = h.signup("splitcollab", "splitcollab@test.com", "pass1234").await; h.client.post_form("/logout", "").await; // Add collaborator with 50% split (direct SQL, seller is already logged out) sqlx::query( "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by) VALUES ($1::uuid, $2, 'member', 50, $3)", ) .bind(&project_id) .bind(collab_id) .bind(seller_id) .execute(&h.db) .await .unwrap(); // Complete a purchase through the webhook pipeline let _buyer_id = complete_purchase(&mut h, seller_id, &item_id, "splitbuyer", "splitbuyer@test.com").await; // Wait for split recording (runs after transaction commit) tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Verify revenue_splits table has a row for the collaborator let split_rows: Vec<(i32, i16)> = sqlx::query_as( "SELECT amount_cents, split_percent FROM revenue_splits WHERE recipient_id = $1", ) .bind(collab_id) .fetch_all(&h.db) .await .unwrap(); assert_eq!(split_rows.len(), 1, "Expected 1 revenue split record for collaborator"); // 1000 * 50 / 100 = 500 assert_eq!(split_rows[0].0, 500, "Collaborator should get 50% of 1000 = 500 cents"); assert_eq!(split_rows[0].1, 50, "Split percent should be recorded as 50"); // Also verify the split is linked to the correct transaction let has_transaction_id: bool = sqlx::query_scalar( "SELECT transaction_id IS NOT NULL FROM revenue_splits WHERE recipient_id = $1", ) .bind(collab_id) .fetch_one(&h.db) .await .unwrap(); assert!(has_transaction_id, "Revenue split should be linked to a transaction"); } // --------------------------------------------------------------------------- // 5. Split export contains data // --------------------------------------------------------------------------- #[tokio::test] async fn split_export_contains_data() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 800).await; // Create a collaborator let collab_id = h.signup("exportcollab", "exportcollab@test.com", "pass1234").await; h.client.post_form("/logout", "").await; // Add collaborator with 40% split sqlx::query( "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by) VALUES ($1::uuid, $2, 'member', 40, $3)", ) .bind(&project_id) .bind(collab_id) .bind(seller_id) .execute(&h.db) .await .unwrap(); // Complete a purchase let _buyer_id = complete_purchase(&mut h, seller_id, &item_id, "exportbuyer", "exportbuyer@test.com").await; // Wait for split recording tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Log in as seller and export splits h.client.post_form("/logout", "").await; h.login("seller", "pass1234").await; let resp = h.client.post_form("/api/export/splits", "").await; assert!( resp.status.is_success(), "Export splits failed: {} {}", resp.status, resp.text ); // CSV should contain header + at least one data row let csv = &resp.text; assert!(csv.contains("Date,Type,Direction,Recipient,Amount,Split %"), "CSV should have header row"); assert!(csv.contains("exportcollab"), "CSV should contain collaborator username"); assert!(csv.contains("sale"), "CSV should contain 'sale' source type"); assert!(csv.contains("outgoing"), "From seller perspective, split should be 'outgoing'"); // 800 * 40 / 100 = 320 cents = 3.20 assert!(csv.contains("3.20"), "CSV should contain split amount of $3.20"); assert!(csv.contains("40"), "CSV should contain split percentage of 40"); // Also verify from the collaborator's perspective h.client.post_form("/logout", "").await; h.login("exportcollab", "pass1234").await; let resp = h.client.post_form("/api/export/splits", "").await; assert!( resp.status.is_success(), "Collab export splits failed: {} {}", resp.status, resp.text ); let csv = &resp.text; assert!(csv.contains("incoming"), "From collaborator perspective, split should be 'incoming'"); assert!(csv.contains("exportcollab"), "CSV should contain collaborator username"); }