Skip to main content

max / makenotwork

15.3 KB · 542 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_eq!(resp.status, 200, "Create project failed: {}", resp.text);
140 let project: Value = resp.json();
141 let project_id = project["id"].as_str().unwrap().to_string();
142
143 // Sign up a collaborator (separate session)
144 h.client.post_form("/logout", "").await;
145 let collab_id = h
146 .signup("collaborator", "collab@test.com", "pass1234")
147 .await;
148
149 // Log back in as creator
150 h.client.post_form("/logout", "").await;
151 h.login("creator1", "password123").await;
152
153 // Add collaborator via API
154 let resp = h
155 .client
156 .post_form(
157 &format!("/api/projects/{project_id}/members"),
158 "username=collaborator&split_percent=30",
159 )
160 .await;
161 assert_eq!(
162 resp.status, 200,
163 "Add member failed: {} {}",
164 resp.status, resp.text
165 );
166
167 // Verify member exists in DB
168 let members: Vec<(db::UserId, i16)> = sqlx::query_as(
169 "SELECT user_id, split_percent FROM project_members WHERE project_id = $1::uuid",
170 )
171 .bind(&project_id)
172 .fetch_all(&h.db)
173 .await
174 .unwrap();
175
176 assert_eq!(members.len(), 1, "Expected 1 project member");
177 assert_eq!(members[0].0, collab_id);
178 assert_eq!(members[0].1, 30);
179 }
180
181 // 2. Update split percentage
182
183 #[tokio::test]
184 async fn update_split_percentage() {
185 let mut h = TestHarness::with_mocks().await;
186
187 let _seller_id = h.create_creator("creator2").await;
188
189 let resp = h
190 .client
191 .post_form("/api/projects", "slug=split-upd&title=Split+Update")
192 .await;
193 assert_eq!(resp.status, 200, "Create project failed: {}", resp.text);
194 let project: Value = resp.json();
195 let project_id = project["id"].as_str().unwrap().to_string();
196
197 // Create collaborator
198 h.client.post_form("/logout", "").await;
199 let collab_id = h.signup("collab2", "collab2@test.com", "pass1234").await;
200
201 // Log back in as creator
202 h.client.post_form("/logout", "").await;
203 h.login("creator2", "password123").await;
204
205 // Add with 30%
206 let resp = h
207 .client
208 .post_form(
209 &format!("/api/projects/{project_id}/members"),
210 "username=collab2&split_percent=30",
211 )
212 .await;
213 assert_eq!(
214 resp.status, 200,
215 "Add member failed: {} {}",
216 resp.status, resp.text
217 );
218
219 // Update to 50% by re-adding (the API uses ON CONFLICT DO UPDATE)
220 let resp = h
221 .client
222 .post_form(
223 &format!("/api/projects/{project_id}/members"),
224 "username=collab2&split_percent=50",
225 )
226 .await;
227 assert_eq!(
228 resp.status, 200,
229 "Update member failed: {} {}",
230 resp.status, resp.text
231 );
232
233 // Verify updated split
234 let split: i16 = sqlx::query_scalar(
235 "SELECT split_percent FROM project_members WHERE project_id = $1::uuid AND user_id = $2",
236 )
237 .bind(&project_id)
238 .bind(collab_id)
239 .fetch_one(&h.db)
240 .await
241 .unwrap();
242
243 assert_eq!(split, 50, "Split should be updated to 50%");
244 }
245
246 // 3. Remove project member
247
248 #[tokio::test]
249 async fn remove_project_member() {
250 let mut h = TestHarness::with_mocks().await;
251
252 let _seller_id = h.create_creator("creator3").await;
253
254 let resp = h
255 .client
256 .post_form("/api/projects", "slug=rm-member&title=Remove+Member")
257 .await;
258 assert_eq!(resp.status, 200, "Create project failed: {}", resp.text);
259 let project: Value = resp.json();
260 let project_id = project["id"].as_str().unwrap().to_string();
261
262 // Create collaborator
263 h.client.post_form("/logout", "").await;
264 let collab_id = h.signup("collab3", "collab3@test.com", "pass1234").await;
265
266 // Log back in as creator
267 h.client.post_form("/logout", "").await;
268 h.login("creator3", "password123").await;
269
270 // Add member
271 let resp = h
272 .client
273 .post_form(
274 &format!("/api/projects/{project_id}/members"),
275 "username=collab3&split_percent=25",
276 )
277 .await;
278 assert_eq!(
279 resp.status, 200,
280 "Add member failed: {} {}",
281 resp.status, resp.text
282 );
283
284 // Remove member
285 let resp = h
286 .client
287 .delete(&format!("/api/projects/{project_id}/members/{collab_id}"))
288 .await;
289 assert_eq!(
290 resp.status, 200,
291 "Remove member failed: {} {}",
292 resp.status, resp.text
293 );
294
295 // Verify member is gone
296 let count: i64 = sqlx::query_scalar(
297 "SELECT COUNT(*) FROM project_members WHERE project_id = $1::uuid AND user_id = $2",
298 )
299 .bind(&project_id)
300 .bind(collab_id)
301 .fetch_one(&h.db)
302 .await
303 .unwrap();
304
305 assert_eq!(count, 0, "Member should be removed");
306 }
307
308 // 3b. A pending invitation earns nothing
309
310 #[tokio::test]
311 async fn a_pending_invitation_is_not_paid_on_purchase() {
312 // The point of the acceptance flow: an invited collaborator who has not
313 // answered has their percentage reserved, but a sale in the meantime is
314 // wholly the owner's. Nobody should be paid a share, in a currency that may
315 // not be theirs, without having agreed to take it.
316 let mut h = TestHarness::with_mocks().await;
317 let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 1000).await;
318
319 let collab_id = h
320 .signup("pendingcollab", "pendingcollab@test.com", "pass1234")
321 .await;
322 h.client.post_form("/logout", "").await;
323
324 // Invited, deliberately without accepted_at.
325 sqlx::query(
326 "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by) \
327 VALUES ($1::uuid, $2, 'member', 50, $3)",
328 )
329 .bind(&project_id)
330 .bind(collab_id)
331 .bind(seller_id)
332 .execute(&h.db)
333 .await
334 .unwrap();
335
336 let _buyer_id = complete_purchase(
337 &mut h,
338 seller_id,
339 &item_id,
340 "pendingbuyer",
341 "pendingbuyer@test.com",
342 )
343 .await;
344
345 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
346
347 let split_count: i64 =
348 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE recipient_id = $1")
349 .bind(collab_id)
350 .fetch_one(&h.db)
351 .await
352 .unwrap();
353
354 assert_eq!(
355 split_count, 0,
356 "a collaborator who has not accepted must not be paid a split"
357 );
358
359 // And the reservation still stands, so the owner cannot re-promise it.
360 let reserved: i64 = sqlx::query_scalar(
361 "SELECT COALESCE(SUM(split_percent), 0)::BIGINT FROM project_members \
362 WHERE project_id = $1::uuid",
363 )
364 .bind(&project_id)
365 .fetch_one(&h.db)
366 .await
367 .unwrap();
368 assert_eq!(reserved, 50, "the pending percentage stays reserved");
369 }
370
371 // 4. Split recorded on purchase
372
373 #[tokio::test]
374 async fn split_recorded_on_purchase() {
375 let mut h = TestHarness::with_mocks().await;
376 let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 1000).await;
377
378 // Create a collaborator
379 let collab_id = h
380 .signup("splitcollab", "splitcollab@test.com", "pass1234")
381 .await;
382 h.client.post_form("/logout", "").await;
383
384 // Add collaborator with 50% split (direct SQL, seller is already logged out)
385 sqlx::query(
386 "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by, accepted_at) VALUES ($1::uuid, $2, 'member', 50, $3, NOW())",
387 )
388 .bind(&project_id)
389 .bind(collab_id)
390 .bind(seller_id)
391 .execute(&h.db)
392 .await
393 .unwrap();
394
395 // Complete a purchase through the webhook pipeline
396 let _buyer_id = complete_purchase(
397 &mut h,
398 seller_id,
399 &item_id,
400 "splitbuyer",
401 "splitbuyer@test.com",
402 )
403 .await;
404
405 // Wait for split recording (runs after transaction commit)
406 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
407
408 // Verify revenue_splits table has a row for the collaborator
409 let split_rows: Vec<(i32, i16)> = sqlx::query_as(
410 "SELECT amount_cents, split_percent FROM revenue_splits WHERE recipient_id = $1",
411 )
412 .bind(collab_id)
413 .fetch_all(&h.db)
414 .await
415 .unwrap();
416
417 assert_eq!(
418 split_rows.len(),
419 1,
420 "Expected 1 revenue split record for collaborator"
421 );
422 // 1000 * 50 / 100 = 500
423 assert_eq!(
424 split_rows[0].0, 500,
425 "Collaborator should get 50% of 1000 = 500 cents"
426 );
427 assert_eq!(
428 split_rows[0].1, 50,
429 "Split percent should be recorded as 50"
430 );
431
432 // Also verify the split is linked to the correct transaction
433 let has_transaction_id: bool = sqlx::query_scalar(
434 "SELECT transaction_id IS NOT NULL FROM revenue_splits WHERE recipient_id = $1",
435 )
436 .bind(collab_id)
437 .fetch_one(&h.db)
438 .await
439 .unwrap();
440 assert!(
441 has_transaction_id,
442 "Revenue split should be linked to a transaction"
443 );
444 }
445
446 // 5. Split export contains data
447
448 #[tokio::test]
449 async fn split_export_contains_data() {
450 let mut h = TestHarness::with_mocks().await;
451 let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 800).await;
452
453 // Create a collaborator
454 let collab_id = h
455 .signup("exportcollab", "exportcollab@test.com", "pass1234")
456 .await;
457 h.client.post_form("/logout", "").await;
458
459 // Add collaborator with 40% split
460 sqlx::query(
461 "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by, accepted_at) VALUES ($1::uuid, $2, 'member', 40, $3, NOW())",
462 )
463 .bind(&project_id)
464 .bind(collab_id)
465 .bind(seller_id)
466 .execute(&h.db)
467 .await
468 .unwrap();
469
470 let _buyer_id = complete_purchase(
471 &mut h,
472 seller_id,
473 &item_id,
474 "exportbuyer",
475 "exportbuyer@test.com",
476 )
477 .await;
478
479 // Wait for split recording
480 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
481
482 // Log in as seller and export splits
483 h.client.post_form("/logout", "").await;
484 h.login("seller", "pass1234").await;
485
486 let resp = h.client.post_form("/api/export/splits", "").await;
487 assert_eq!(
488 resp.status, 200,
489 "Export splits failed: {} {}",
490 resp.status, resp.text
491 );
492
493 // CSV should contain header + at least one data row
494 let csv = &resp.text;
495 assert!(
496 csv.contains("Date,Type,Direction,Recipient,Amount,Split %"),
497 "CSV should have header row"
498 );
499 assert!(
500 csv.contains("exportcollab"),
501 "CSV should contain collaborator username"
502 );
503 assert!(
504 csv.contains("sale"),
505 "CSV should contain 'sale' source type"
506 );
507 assert!(
508 csv.contains("outgoing"),
509 "From seller perspective, split should be 'outgoing'"
510 );
511 // 800 * 40 / 100 = 320 cents = 3.20
512 assert!(
513 csv.contains("3.20"),
514 "CSV should contain split amount of $3.20"
515 );
516 assert!(
517 csv.contains("40"),
518 "CSV should contain split percentage of 40"
519 );
520
521 // Also verify from the collaborator's perspective
522 h.client.post_form("/logout", "").await;
523 h.login("exportcollab", "pass1234").await;
524
525 let resp = h.client.post_form("/api/export/splits", "").await;
526 assert_eq!(
527 resp.status, 200,
528 "Collab export splits failed: {} {}",
529 resp.status, resp.text
530 );
531
532 let csv = &resp.text;
533 assert!(
534 csv.contains("incoming"),
535 "From collaborator perspective, split should be 'incoming'"
536 );
537 assert!(
538 csv.contains("exportcollab"),
539 "CSV should contain collaborator username"
540 );
541 }
542