Skip to main content

max / makenotwork

13.5 KB · 387 lines History Blame Raw
1 //! Revenue split integration tests — project members, split recording on purchase,
2 //! and split CSV export.
3
4 use crate::harness::TestHarness;
5 use makenotwork::db;
6 use serde_json::Value;
7 use std::collections::HashMap;
8
9 // ---------------------------------------------------------------------------
10 // Helpers (mirrors mock_payment_flows patterns)
11 // ---------------------------------------------------------------------------
12
13 /// Create a creator with Stripe "connected" (direct DB override) and a published paid item.
14 /// Returns (seller_id, project_id, item_id).
15 async fn setup_paid_item(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String, String) {
16 let seller_id = h.signup("seller", "seller@test.com", "pass1234").await;
17 h.grant_creator(seller_id).await;
18
19 // Simulate Stripe Connect onboarding complete
20 sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_seller', stripe_charges_enabled = true WHERE id = $1")
21 .bind(seller_id)
22 .execute(&h.db)
23 .await
24 .unwrap();
25
26 h.client.post_form("/logout", "").await;
27 h.login("seller", "pass1234").await;
28
29 let resp = h.client.post_form("/api/projects", "slug=shop&title=Shop").await;
30 let project: Value = resp.json();
31 let project_id = project["id"].as_str().unwrap().to_string();
32
33 let resp = h.client.post_form(
34 &format!("/api/projects/{}/items", project_id),
35 &format!("title=Track&price_cents={}&item_type=audio", price_cents),
36 ).await;
37 let item: Value = resp.json();
38 let item_id = item["id"].as_str().unwrap().to_string();
39
40 // Publish
41 h.client.put_form(&format!("/api/projects/{}", project_id), "is_public=true").await;
42 h.client.put_form(&format!("/api/items/{}", item_id), "is_public=true").await;
43
44 (seller_id, project_id, item_id)
45 }
46
47 async fn post_webhook_json(
48 h: &mut TestHarness,
49 event_type: &str,
50 object: serde_json::Value,
51 ) -> crate::harness::client::TestResponse {
52 let payload = serde_json::json!({
53 "id": "evt_split_test",
54 "type": event_type,
55 "data": {"object": object},
56 })
57 .to_string();
58 let signature = crate::harness::stripe::sign_webhook_payload(
59 &payload,
60 crate::harness::stripe::TEST_WEBHOOK_SECRET,
61 );
62 h.client.request_with_headers(
63 "POST",
64 "/stripe/webhook",
65 Some(&payload),
66 &[
67 ("stripe-signature", &signature),
68 ("content-type", "application/json"),
69 ],
70 ).await
71 }
72
73 /// Complete a purchase through the webhook pipeline.
74 /// Returns the buyer_id.
75 async fn complete_purchase(
76 h: &mut TestHarness,
77 seller_id: db::UserId,
78 item_id: &str,
79 buyer_username: &str,
80 buyer_email: &str,
81 ) -> db::UserId {
82 let buyer_id = h.signup(buyer_username, buyer_email, "pass1234").await;
83
84 h.client.post_form(
85 &format!("/stripe/checkout/{}", item_id),
86 "share_contact=false",
87 ).await;
88
89 let session_id: String = sqlx::query_scalar(
90 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
91 )
92 .bind(buyer_id)
93 .fetch_one(&h.db)
94 .await
95 .unwrap();
96
97 let mut meta = HashMap::new();
98 meta.insert("buyer_id".to_string(), buyer_id.to_string());
99 meta.insert("seller_id".to_string(), seller_id.to_string());
100 meta.insert("item_id".to_string(), item_id.to_string());
101 let session = serde_json::json!({
102 "id": session_id,
103 "object": "checkout_session",
104 "mode": "payment",
105 "metadata": meta,
106 "payment_intent": format!("pi_split_{}", buyer_username),
107 });
108 let resp = post_webhook_json(h, "checkout.session.completed", session).await;
109 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
110
111 buyer_id
112 }
113
114 // ---------------------------------------------------------------------------
115 // 1. Add project member with split
116 // ---------------------------------------------------------------------------
117
118 #[tokio::test]
119 async fn add_project_member_with_split() {
120 let mut h = TestHarness::with_mocks().await;
121
122 let _seller_id = h.create_creator("creator1").await;
123
124 // Create project
125 let resp = h.client.post_form("/api/projects", "slug=collab-proj&title=Collab+Project").await;
126 assert!(resp.status.is_success(), "Create project failed: {}", resp.text);
127 let project: Value = resp.json();
128 let project_id = project["id"].as_str().unwrap().to_string();
129
130 // Sign up a collaborator (separate session)
131 h.client.post_form("/logout", "").await;
132 let collab_id = h.signup("collaborator", "collab@test.com", "pass1234").await;
133
134 // Log back in as creator
135 h.client.post_form("/logout", "").await;
136 h.login("creator1", "password123").await;
137
138 // Add collaborator via API
139 let resp = h.client.post_form(
140 &format!("/api/projects/{}/members", project_id),
141 "username=collaborator&split_percent=30",
142 ).await;
143 assert!(
144 resp.status.is_success(),
145 "Add member failed: {} {}",
146 resp.status, resp.text
147 );
148
149 // Verify member exists in DB
150 let members: Vec<(db::UserId, i16)> = sqlx::query_as(
151 "SELECT user_id, split_percent FROM project_members WHERE project_id = $1::uuid",
152 )
153 .bind(&project_id)
154 .fetch_all(&h.db)
155 .await
156 .unwrap();
157
158 assert_eq!(members.len(), 1, "Expected 1 project member");
159 assert_eq!(members[0].0, collab_id);
160 assert_eq!(members[0].1, 30);
161 }
162
163 // ---------------------------------------------------------------------------
164 // 2. Update split percentage
165 // ---------------------------------------------------------------------------
166
167 #[tokio::test]
168 async fn update_split_percentage() {
169 let mut h = TestHarness::with_mocks().await;
170
171 let _seller_id = h.create_creator("creator2").await;
172
173 let resp = h.client.post_form("/api/projects", "slug=split-upd&title=Split+Update").await;
174 assert!(resp.status.is_success(), "Create project failed: {}", resp.text);
175 let project: Value = resp.json();
176 let project_id = project["id"].as_str().unwrap().to_string();
177
178 // Create collaborator
179 h.client.post_form("/logout", "").await;
180 let collab_id = h.signup("collab2", "collab2@test.com", "pass1234").await;
181
182 // Log back in as creator
183 h.client.post_form("/logout", "").await;
184 h.login("creator2", "password123").await;
185
186 // Add with 30%
187 let resp = h.client.post_form(
188 &format!("/api/projects/{}/members", project_id),
189 "username=collab2&split_percent=30",
190 ).await;
191 assert!(resp.status.is_success(), "Add member failed: {} {}", resp.status, resp.text);
192
193 // Update to 50% by re-adding (the API uses ON CONFLICT DO UPDATE)
194 let resp = h.client.post_form(
195 &format!("/api/projects/{}/members", project_id),
196 "username=collab2&split_percent=50",
197 ).await;
198 assert!(resp.status.is_success(), "Update member failed: {} {}", resp.status, resp.text);
199
200 // Verify updated split
201 let split: i16 = sqlx::query_scalar(
202 "SELECT split_percent FROM project_members WHERE project_id = $1::uuid AND user_id = $2",
203 )
204 .bind(&project_id)
205 .bind(collab_id)
206 .fetch_one(&h.db)
207 .await
208 .unwrap();
209
210 assert_eq!(split, 50, "Split should be updated to 50%");
211 }
212
213 // ---------------------------------------------------------------------------
214 // 3. Remove project member
215 // ---------------------------------------------------------------------------
216
217 #[tokio::test]
218 async fn remove_project_member() {
219 let mut h = TestHarness::with_mocks().await;
220
221 let _seller_id = h.create_creator("creator3").await;
222
223 let resp = h.client.post_form("/api/projects", "slug=rm-member&title=Remove+Member").await;
224 assert!(resp.status.is_success(), "Create project failed: {}", resp.text);
225 let project: Value = resp.json();
226 let project_id = project["id"].as_str().unwrap().to_string();
227
228 // Create collaborator
229 h.client.post_form("/logout", "").await;
230 let collab_id = h.signup("collab3", "collab3@test.com", "pass1234").await;
231
232 // Log back in as creator
233 h.client.post_form("/logout", "").await;
234 h.login("creator3", "password123").await;
235
236 // Add member
237 let resp = h.client.post_form(
238 &format!("/api/projects/{}/members", project_id),
239 "username=collab3&split_percent=25",
240 ).await;
241 assert!(resp.status.is_success(), "Add member failed: {} {}", resp.status, resp.text);
242
243 // Remove member
244 let resp = h.client.delete(
245 &format!("/api/projects/{}/members/{}", project_id, collab_id),
246 ).await;
247 assert!(
248 resp.status.is_success(),
249 "Remove member failed: {} {}",
250 resp.status, resp.text
251 );
252
253 // Verify member is gone
254 let count: i64 = sqlx::query_scalar(
255 "SELECT COUNT(*) FROM project_members WHERE project_id = $1::uuid AND user_id = $2",
256 )
257 .bind(&project_id)
258 .bind(collab_id)
259 .fetch_one(&h.db)
260 .await
261 .unwrap();
262
263 assert_eq!(count, 0, "Member should be removed");
264 }
265
266 // ---------------------------------------------------------------------------
267 // 4. Split recorded on purchase
268 // ---------------------------------------------------------------------------
269
270 #[tokio::test]
271 async fn split_recorded_on_purchase() {
272 let mut h = TestHarness::with_mocks().await;
273 let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 1000).await;
274
275 // Create a collaborator
276 let collab_id = h.signup("splitcollab", "splitcollab@test.com", "pass1234").await;
277 h.client.post_form("/logout", "").await;
278
279 // Add collaborator with 50% split (direct SQL, seller is already logged out)
280 sqlx::query(
281 "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by) VALUES ($1::uuid, $2, 'member', 50, $3)",
282 )
283 .bind(&project_id)
284 .bind(collab_id)
285 .bind(seller_id)
286 .execute(&h.db)
287 .await
288 .unwrap();
289
290 // Complete a purchase through the webhook pipeline
291 let _buyer_id = complete_purchase(&mut h, seller_id, &item_id, "splitbuyer", "splitbuyer@test.com").await;
292
293 // Wait for split recording (runs after transaction commit)
294 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
295
296 // Verify revenue_splits table has a row for the collaborator
297 let split_rows: Vec<(i32, i16)> = sqlx::query_as(
298 "SELECT amount_cents, split_percent FROM revenue_splits WHERE recipient_id = $1",
299 )
300 .bind(collab_id)
301 .fetch_all(&h.db)
302 .await
303 .unwrap();
304
305 assert_eq!(split_rows.len(), 1, "Expected 1 revenue split record for collaborator");
306 // 1000 * 50 / 100 = 500
307 assert_eq!(split_rows[0].0, 500, "Collaborator should get 50% of 1000 = 500 cents");
308 assert_eq!(split_rows[0].1, 50, "Split percent should be recorded as 50");
309
310 // Also verify the split is linked to the correct transaction
311 let has_transaction_id: bool = sqlx::query_scalar(
312 "SELECT transaction_id IS NOT NULL FROM revenue_splits WHERE recipient_id = $1",
313 )
314 .bind(collab_id)
315 .fetch_one(&h.db)
316 .await
317 .unwrap();
318 assert!(has_transaction_id, "Revenue split should be linked to a transaction");
319 }
320
321 // ---------------------------------------------------------------------------
322 // 5. Split export contains data
323 // ---------------------------------------------------------------------------
324
325 #[tokio::test]
326 async fn split_export_contains_data() {
327 let mut h = TestHarness::with_mocks().await;
328 let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 800).await;
329
330 // Create a collaborator
331 let collab_id = h.signup("exportcollab", "exportcollab@test.com", "pass1234").await;
332 h.client.post_form("/logout", "").await;
333
334 // Add collaborator with 40% split
335 sqlx::query(
336 "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by) VALUES ($1::uuid, $2, 'member', 40, $3)",
337 )
338 .bind(&project_id)
339 .bind(collab_id)
340 .bind(seller_id)
341 .execute(&h.db)
342 .await
343 .unwrap();
344
345 // Complete a purchase
346 let _buyer_id = complete_purchase(&mut h, seller_id, &item_id, "exportbuyer", "exportbuyer@test.com").await;
347
348 // Wait for split recording
349 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
350
351 // Log in as seller and export splits
352 h.client.post_form("/logout", "").await;
353 h.login("seller", "pass1234").await;
354
355 let resp = h.client.post_form("/api/export/splits", "").await;
356 assert!(
357 resp.status.is_success(),
358 "Export splits failed: {} {}",
359 resp.status, resp.text
360 );
361
362 // CSV should contain header + at least one data row
363 let csv = &resp.text;
364 assert!(csv.contains("Date,Type,Direction,Recipient,Amount,Split %"), "CSV should have header row");
365 assert!(csv.contains("exportcollab"), "CSV should contain collaborator username");
366 assert!(csv.contains("sale"), "CSV should contain 'sale' source type");
367 assert!(csv.contains("outgoing"), "From seller perspective, split should be 'outgoing'");
368 // 800 * 40 / 100 = 320 cents = 3.20
369 assert!(csv.contains("3.20"), "CSV should contain split amount of $3.20");
370 assert!(csv.contains("40"), "CSV should contain split percentage of 40");
371
372 // Also verify from the collaborator's perspective
373 h.client.post_form("/logout", "").await;
374 h.login("exportcollab", "pass1234").await;
375
376 let resp = h.client.post_form("/api/export/splits", "").await;
377 assert!(
378 resp.status.is_success(),
379 "Collab export splits failed: {} {}",
380 resp.status, resp.text
381 );
382
383 let csv = &resp.text;
384 assert!(csv.contains("incoming"), "From collaborator perspective, split should be 'incoming'");
385 assert!(csv.contains("exportcollab"), "CSV should contain collaborator username");
386 }
387