Skip to main content

max / makenotwork

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