Skip to main content

max / makenotwork

62.7 KB · 2079 lines History Blame Raw
1 //! Payment flow integration tests using MockPaymentProvider and MockEmailTransport.
2 //!
3 //! These tests exercise the full checkout → webhook → assertion pipeline
4 //! without hitting any external service.
5
6 use crate::harness::TestHarness;
7 use makenotwork::db;
8 use serde_json::Value;
9 use std::collections::HashMap;
10
11 // Helpers
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
30 .client
31 .post_form("/api/projects", "slug=shop&title=Shop")
32 .await;
33 let project: Value = resp.json();
34 let project_id = project["id"].as_str().unwrap().to_string();
35
36 let resp = h
37 .client
38 .post_form(
39 &format!("/api/projects/{project_id}/items"),
40 &format!("title=Track&price_cents={price_cents}&item_type=audio"),
41 )
42 .await;
43 let item: Value = resp.json();
44 let item_id = item["id"].as_str().unwrap().to_string();
45
46 // Publish
47 h.client
48 .put_form(&format!("/api/projects/{project_id}"), "is_public=true")
49 .await;
50 h.client
51 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
52 .await;
53
54 h.client.post_form("/logout", "").await;
55
56 (seller_id, project_id, item_id)
57 }
58
59 /// Post a JSON webhook event to the harness.
60 async fn post_webhook_json(
61 h: &mut TestHarness,
62 event_type: &str,
63 object: serde_json::Value,
64 ) -> crate::harness::client::TestResponse {
65 post_webhook_json_with_event_id(h, "evt_mock_001", event_type, object).await
66 }
67
68 /// Post a JSON webhook event with an explicit event id. Distinct ids are
69 /// required when a single test delivers more than one event, since the webhook
70 /// dedup short-circuits a repeated id.
71 async fn post_webhook_json_with_event_id(
72 h: &mut TestHarness,
73 event_id: &str,
74 event_type: &str,
75 object: serde_json::Value,
76 ) -> crate::harness::client::TestResponse {
77 let payload = serde_json::json!({
78 "id": event_id,
79 "type": event_type,
80 "data": {"object": object},
81 })
82 .to_string();
83 let signature = crate::harness::stripe::sign_webhook_payload(
84 &payload,
85 crate::harness::stripe::TEST_WEBHOOK_SECRET,
86 );
87 h.client
88 .request_with_headers(
89 "POST",
90 "/stripe/webhook",
91 Some(&payload),
92 &[
93 ("stripe-signature", &signature),
94 ("content-type", "application/json"),
95 ],
96 )
97 .await
98 }
99
100 // Checkout → Webhook → Access flow
101
102 #[tokio::test]
103 async fn checkout_creates_session_and_webhook_completes_purchase() {
104 let mut h = TestHarness::with_mocks().await;
105 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
106
107 // Buyer signs up
108 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
109
110 // Buyer initiates checkout (hits MockPaymentProvider)
111 let resp = h
112 .client
113 .post_form(
114 &format!("/stripe/checkout/{item_id}"),
115 "share_contact=false",
116 )
117 .await;
118
119 // MockPaymentProvider returns a redirect URL
120 assert_eq!(
121 resp.status, 303,
122 "Checkout should redirect or succeed, got: {} {}",
123 resp.status, resp.text
124 );
125
126 // Verify mock recorded the checkout
127 let mock_stripe = h.mock_stripe.as_ref().unwrap();
128 let checkouts = mock_stripe.checkouts();
129 assert_eq!(
130 checkouts.len(),
131 1,
132 "Expected 1 checkout session, got {}",
133 checkouts.len()
134 );
135
136 // Simulate Stripe webhook completing the purchase
137 // Find the pending transaction the checkout handler created
138 let pending_tx: Option<(String,)> = sqlx::query_as(
139 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
140 )
141 .bind(buyer_id)
142 .fetch_optional(&h.db)
143 .await
144 .unwrap();
145 assert!(
146 pending_tx.is_some(),
147 "Checkout should have created a pending transaction"
148 );
149 let actual_session_id = &pending_tx.unwrap().0;
150
151 // Build webhook event with the actual session ID
152 let mut meta = HashMap::new();
153 meta.insert("buyer_id".to_string(), buyer_id.to_string());
154 meta.insert("seller_id".to_string(), seller_id.to_string());
155 meta.insert("item_id".to_string(), item_id.clone());
156 let session = serde_json::json!({
157 "id": actual_session_id,
158 "object": "checkout_session",
159 "mode": "payment",
160 "metadata": meta,
161 "payment_intent": "pi_mock_001",
162 });
163
164 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
165 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
166
167 // Verify transaction completed
168 let status: String = sqlx::query_scalar(
169 "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
170 )
171 .bind(buyer_id)
172 .bind(&item_id)
173 .fetch_one(&h.db)
174 .await
175 .unwrap();
176 assert_eq!(status, "completed");
177
178 // Verify sales count incremented
179 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
180 .bind(&item_id)
181 .fetch_one(&h.db)
182 .await
183 .unwrap();
184 assert_eq!(sales, 1);
185 }
186
187 /// An asynchronous payment method (ACH/SEPA/Bacs) delivers
188 /// `checkout.session.completed` with `payment_status="unpaid"` BEFORE funds
189 /// settle. The one-time purchase must NOT finalize then, goods must not ship
190 /// against unpaid funds, and must finalize when Stripe later re-delivers the
191 /// settled session via `checkout.session.async_payment_succeeded`.
192 #[tokio::test]
193 async fn async_unpaid_checkout_defers_until_payment_succeeds() {
194 let mut h = TestHarness::with_mocks().await;
195 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 777).await;
196
197 let buyer_id = h
198 .signup("asyncbuyer", "asyncbuyer@test.com", "pass1234")
199 .await;
200
201 // Buyer initiates checkout, creating the pending transaction.
202 h.client
203 .post_form(
204 &format!("/stripe/checkout/{item_id}"),
205 "share_contact=false",
206 )
207 .await;
208
209 let pending_tx: Option<(String,)> = sqlx::query_as(
210 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
211 )
212 .bind(buyer_id)
213 .fetch_optional(&h.db)
214 .await
215 .unwrap();
216 let session_id = pending_tx
217 .expect("checkout created a pending transaction")
218 .0;
219
220 let mut meta = HashMap::new();
221 meta.insert("buyer_id".to_string(), buyer_id.to_string());
222 meta.insert("seller_id".to_string(), seller_id.to_string());
223 meta.insert("item_id".to_string(), item_id.clone());
224
225 // 1. completed + unpaid → deferred (still pending, no sale counted).
226 let unpaid = serde_json::json!({
227 "id": session_id,
228 "object": "checkout_session",
229 "mode": "payment",
230 "metadata": meta,
231 "payment_intent": "pi_async_001",
232 "payment_status": "unpaid",
233 "currency": "usd",
234 });
235 let resp = post_webhook_json_with_event_id(
236 &mut h,
237 "evt_async_unpaid",
238 "checkout.session.completed",
239 unpaid,
240 )
241 .await;
242 assert_eq!(
243 resp.status.as_u16(),
244 200,
245 "webhook should ack even when deferring: {}",
246 resp.text
247 );
248
249 let status: String = sqlx::query_scalar(
250 "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
251 )
252 .bind(buyer_id)
253 .bind(&item_id)
254 .fetch_one(&h.db)
255 .await
256 .unwrap();
257 assert_eq!(
258 status, "pending",
259 "unpaid async session must NOT finalize the purchase"
260 );
261
262 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
263 .bind(&item_id)
264 .fetch_one(&h.db)
265 .await
266 .unwrap();
267 assert_eq!(sales, 0, "no sale should be counted before settlement");
268
269 // 2. async_payment_succeeded (funds settled) → finalize.
270 let settled = serde_json::json!({
271 "id": session_id,
272 "object": "checkout_session",
273 "mode": "payment",
274 "metadata": meta,
275 "payment_intent": "pi_async_001",
276 "payment_status": "paid",
277 "currency": "usd",
278 });
279 let resp = post_webhook_json_with_event_id(
280 &mut h,
281 "evt_async_paid",
282 "checkout.session.async_payment_succeeded",
283 settled,
284 )
285 .await;
286 assert_eq!(
287 resp.status.as_u16(),
288 200,
289 "settlement webhook failed: {}",
290 resp.text
291 );
292
293 let status: String = sqlx::query_scalar(
294 "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
295 )
296 .bind(buyer_id)
297 .bind(&item_id)
298 .fetch_one(&h.db)
299 .await
300 .unwrap();
301 assert_eq!(
302 status, "completed",
303 "settled async session must finalize the purchase"
304 );
305
306 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
307 .bind(&item_id)
308 .fetch_one(&h.db)
309 .await
310 .unwrap();
311 assert_eq!(sales, 1, "sale should be counted once settled");
312 }
313
314 // Email assertions
315
316 #[tokio::test]
317 async fn purchase_webhook_sends_emails() {
318 let mut h = TestHarness::with_mocks().await;
319 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
320
321 // Enable sale notifications for seller
322
323 let buyer_id = h
324 .signup("emailbuyer", "emailbuyer@test.com", "pass1234")
325 .await;
326
327 // Insert pending transaction directly (skip checkout for focused email test)
328 let session_id = "cs_email_test";
329 sqlx::query(
330 r"INSERT INTO transactions
331 (buyer_id, seller_id, item_id, amount_cents, status,
332 stripe_checkout_session_id, item_title, seller_username)
333 VALUES ($1, $2, $3::uuid, 500, 'pending', $4, 'Track', 'seller')",
334 )
335 .bind(buyer_id)
336 .bind(seller_id)
337 .bind(&item_id)
338 .bind(session_id)
339 .execute(&h.db)
340 .await
341 .unwrap();
342
343 // Fire webhook
344 let mut meta = HashMap::new();
345 meta.insert("buyer_id".to_string(), buyer_id.to_string());
346 meta.insert("seller_id".to_string(), seller_id.to_string());
347 meta.insert("item_id".to_string(), item_id.clone());
348 let session = serde_json::json!({
349 "id": session_id,
350 "object": "checkout_session",
351 "mode": "payment",
352 "metadata": meta,
353 "payment_intent": "pi_email_test",
354 });
355 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
356 assert_eq!(resp.status.as_u16(), 200);
357
358 // Wait briefly for fire-and-forget email tasks to complete
359 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
360
361 // Assert emails were sent
362 let mock_email = h.mock_email.as_ref().unwrap();
363 let buyer_emails = mock_email.sent_to("emailbuyer@test.com");
364 assert!(
365 buyer_emails
366 .iter()
367 .any(|e| e.subject.contains("purchase") || e.subject.contains("Purchase")),
368 "Expected purchase confirmation to buyer, got: {:?}",
369 buyer_emails.iter().map(|e| &e.subject).collect::<Vec<_>>()
370 );
371
372 let seller_emails = mock_email.sent_to("seller@test.com");
373 assert!(
374 seller_emails
375 .iter()
376 .any(|e| e.subject.contains("sale") || e.subject.contains("Sale")),
377 "Expected sale notification to seller, got: {:?}",
378 seller_emails.iter().map(|e| &e.subject).collect::<Vec<_>>()
379 );
380 }
381
382 // Free item claim with promo code
383
384 // Note: free_claim_with_promo_code is already covered by the existing
385 // promo_codes_discount and promo_codes_free_access workflow test suites.
386 // The mock payment provider is validated by the other tests in this file.
387
388 // Failure modes
389
390 #[tokio::test]
391 async fn checkout_rejects_own_item() {
392 let mut h = TestHarness::with_mocks().await;
393 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
394
395 // Login as seller and try to buy own item
396 h.login("seller", "pass1234").await;
397 let resp = h
398 .client
399 .post_form(
400 &format!("/stripe/checkout/{item_id}"),
401 "share_contact=false",
402 )
403 .await;
404
405 assert_eq!(
406 resp.status.as_u16(),
407 400,
408 "Should reject self-purchase: {}",
409 resp.text
410 );
411
412 // No checkout should have been created
413 let mock_stripe = h.mock_stripe.as_ref().unwrap();
414 assert_eq!(mock_stripe.checkouts().len(), 0);
415 }
416
417 #[tokio::test]
418 async fn checkout_rejects_unpublished_item() {
419 let mut h = TestHarness::with_mocks().await;
420 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
421
422 // Unpublish the item
423 h.login("seller", "pass1234").await;
424 h.client
425 .put_form(&format!("/api/items/{item_id}"), "is_public=false")
426 .await;
427 h.client.post_form("/logout", "").await;
428
429 // Buyer tries to checkout
430 h.signup("draftbuyer", "db@test.com", "pass1234").await;
431 let resp = h
432 .client
433 .post_form(
434 &format!("/stripe/checkout/{item_id}"),
435 "share_contact=false",
436 )
437 .await;
438
439 // Should get an error (400 for "not available for purchase")
440 assert_eq!(
441 resp.status, 400,
442 "Should reject unpublished item purchase, got: {} {}",
443 resp.status, resp.text
444 );
445
446 // No checkout session created
447 let mock_stripe = h.mock_stripe.as_ref().unwrap();
448 assert_eq!(
449 mock_stripe.checkouts().len(),
450 0,
451 "Unpublished item should not create checkout"
452 );
453 }
454
455 #[tokio::test]
456 async fn checkout_rejects_free_item() {
457 let mut h = TestHarness::with_mocks().await;
458 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 0).await;
459
460 h.signup("freebuyer2", "fb2@test.com", "pass1234").await;
461 let resp = h
462 .client
463 .post_form(
464 &format!("/stripe/checkout/{item_id}"),
465 "share_contact=false",
466 )
467 .await;
468
469 assert_eq!(
470 resp.status.as_u16(),
471 400,
472 "Should reject free item checkout: {}",
473 resp.text
474 );
475 }
476
477 #[tokio::test]
478 async fn duplicate_purchase_prevented() {
479 let mut h = TestHarness::with_mocks().await;
480 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
481
482 let buyer_id = h.signup("dupbuyer", "dup@test.com", "pass1234").await;
483
484 // Insert a completed transaction (buyer already purchased)
485 sqlx::query(
486 r"INSERT INTO transactions
487 (buyer_id, seller_id, item_id, amount_cents, status,
488 stripe_checkout_session_id, item_title, seller_username, completed_at)
489 VALUES ($1, $2, $3::uuid, 999, 'completed', 'cs_already', 'Track', 'seller', NOW())",
490 )
491 .bind(buyer_id)
492 .bind(seller_id)
493 .bind(&item_id)
494 .execute(&h.db)
495 .await
496 .unwrap();
497
498 // Attempt to checkout again, should redirect (already purchased)
499 let resp = h
500 .client
501 .post_form(
502 &format!("/stripe/checkout/{item_id}"),
503 "share_contact=false",
504 )
505 .await;
506
507 assert!(
508 resp.status.is_redirection(),
509 "Already-purchased item should redirect, got: {} {}",
510 resp.status,
511 resp.text
512 );
513
514 // No new checkout session should be created
515 let mock_stripe = h.mock_stripe.as_ref().unwrap();
516 assert_eq!(mock_stripe.checkouts().len(), 0);
517 }
518
519 // Purchase grants access
520
521 #[tokio::test]
522 async fn purchase_grants_access() {
523 let mut h = TestHarness::with_mocks().await;
524 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
525
526 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
527
528 // Buyer initiates checkout
529 h.client
530 .post_form(
531 &format!("/stripe/checkout/{item_id}"),
532 "share_contact=false",
533 )
534 .await;
535
536 // Find pending transaction
537 let session_id: String = sqlx::query_scalar(
538 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
539 )
540 .bind(buyer_id)
541 .fetch_one(&h.db)
542 .await
543 .unwrap();
544
545 // Fire webhook to complete
546 let mut meta = HashMap::new();
547 meta.insert("buyer_id".to_string(), buyer_id.to_string());
548 meta.insert("seller_id".to_string(), seller_id.to_string());
549 meta.insert("item_id".to_string(), item_id.clone());
550 let session = serde_json::json!({
551 "id": session_id,
552 "object": "checkout_session",
553 "mode": "payment",
554 "metadata": meta,
555 "payment_intent": "pi_access_001",
556 });
557 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
558 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
559
560 // Verify buyer has access via has_purchased_item query
561 let count: i64 = sqlx::query_scalar(
562 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'",
563 )
564 .bind(buyer_id)
565 .bind(&item_id)
566 .fetch_one(&h.db)
567 .await
568 .unwrap();
569 assert_eq!(count, 1, "Buyer should have access after purchase");
570 }
571
572 // Refund revokes access
573
574 #[tokio::test]
575 async fn refund_revokes_access() {
576 let mut h = TestHarness::with_mocks().await;
577 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
578
579 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
580
581 let pi_id = "pi_refund_mock_001";
582
583 // Insert a completed transaction with known payment_intent_id
584 sqlx::query(
585 r"INSERT INTO transactions
586 (buyer_id, seller_id, item_id, amount_cents, status,
587 stripe_payment_intent_id, stripe_checkout_session_id,
588 item_title, seller_username, completed_at)
589 VALUES ($1, $2, $3::uuid, 999, 'completed', $4, 'cs_refund_mock', 'Track', 'seller', NOW())",
590 )
591 .bind(buyer_id)
592 .bind(seller_id)
593 .bind(&item_id)
594 .bind(pi_id)
595 .execute(&h.db)
596 .await
597 .unwrap();
598
599 // Set sales_count to 1 (since we inserted a completed transaction)
600 sqlx::query("UPDATE items SET sales_count = 1 WHERE id = $1::uuid")
601 .bind(&item_id)
602 .execute(&h.db)
603 .await
604 .unwrap();
605
606 // Fire ChargeRefunded webhook
607 let charge = serde_json::json!({
608 "id": "ch_refund_mock",
609 "object": "charge",
610 "amount": 999,
611 "amount_refunded": 999,
612 "payment_intent": pi_id,
613 });
614 let resp = post_webhook_json(&mut h, "charge.refunded", charge).await;
615 assert_eq!(
616 resp.status.as_u16(),
617 200,
618 "Refund webhook failed: {}",
619 resp.text
620 );
621
622 // Verify transaction status is 'refunded'
623 let status: String =
624 sqlx::query_scalar("SELECT status FROM transactions WHERE stripe_payment_intent_id = $1")
625 .bind(pi_id)
626 .fetch_one(&h.db)
627 .await
628 .unwrap();
629 assert_eq!(status, "refunded");
630
631 // Verify sales_count decremented
632 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
633 .bind(&item_id)
634 .fetch_one(&h.db)
635 .await
636 .unwrap();
637 assert_eq!(sales, 0, "sales_count should be decremented after refund");
638 }
639
640 // Tip checkout and webhook
641
642 #[tokio::test]
643 async fn tip_checkout_and_webhook() {
644 let mut h = TestHarness::with_mocks().await;
645
646 // Create recipient with Stripe connected and tips enabled
647 let recipient_id = h
648 .signup("recipient", "recipient@test.com", "pass1234")
649 .await;
650 h.grant_creator(recipient_id).await;
651 sqlx::query(
652 "UPDATE users SET stripe_account_id = 'acct_mock_recipient', stripe_charges_enabled = true, tips_enabled = true WHERE id = $1",
653 )
654 .bind(recipient_id)
655 .execute(&h.db)
656 .await
657 .unwrap();
658 h.client.post_form("/logout", "").await;
659
660 // Buyer signs up
661 let tipper_id = h.signup("tipper", "tipper@test.com", "pass1234").await;
662
663 // POST to tip checkout (amount is in dollars per TipForm)
664 let resp = h
665 .client
666 .post_form(
667 &format!("/stripe/checkout/tip/{recipient_id}"),
668 "amount_dollars=5",
669 )
670 .await;
671 assert_eq!(
672 resp.status, 303,
673 "Tip checkout should redirect, got: {} {}",
674 resp.status, resp.text
675 );
676
677 // Verify mock checkout was created
678 let mock_stripe = h.mock_stripe.as_ref().unwrap();
679 assert!(
680 !mock_stripe.checkouts().is_empty(),
681 "Should have created a tip checkout session"
682 );
683
684 // Find the pending tip created by the checkout handler
685 let tip_session_id: String = sqlx::query_scalar(
686 "SELECT stripe_checkout_session_id FROM tips WHERE tipper_id = $1 AND status = 'pending'",
687 )
688 .bind(tipper_id)
689 .fetch_one(&h.db)
690 .await
691 .unwrap();
692
693 // Fire CheckoutSessionCompleted webhook with tip metadata
694 let mut meta = HashMap::new();
695 meta.insert("checkout_type".to_string(), "tip".to_string());
696 meta.insert("tipper_id".to_string(), tipper_id.to_string());
697 meta.insert("recipient_id".to_string(), recipient_id.to_string());
698 let session = serde_json::json!({
699 "id": tip_session_id,
700 "object": "checkout_session",
701 "mode": "payment",
702 "metadata": meta,
703 "payment_intent": "pi_tip_001",
704 });
705 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
706 assert_eq!(
707 resp.status.as_u16(),
708 200,
709 "Tip webhook failed: {}",
710 resp.text
711 );
712
713 // Verify tip status is 'completed'
714 let status: String =
715 sqlx::query_scalar("SELECT status FROM tips WHERE tipper_id = $1 AND recipient_id = $2")
716 .bind(tipper_id)
717 .bind(recipient_id)
718 .fetch_one(&h.db)
719 .await
720 .unwrap();
721 assert_eq!(status, "completed");
722 }
723
724 // Revenue splits recorded on purchase
725
726 #[tokio::test]
727 async fn revenue_splits_recorded_on_purchase() {
728 let mut h = TestHarness::with_mocks().await;
729 let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 999).await;
730
731 // Create a collaborator user
732 let collab_id = h
733 .signup("collaborator", "collab@test.com", "pass1234")
734 .await;
735 h.client.post_form("/logout", "").await;
736
737 // Add collaborator as project member with 30% split
738 sqlx::query(
739 "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by, accepted_at) VALUES ($1::uuid, $2, 'member', 30, $3, NOW())",
740 )
741 .bind(&project_id)
742 .bind(collab_id)
743 .bind(seller_id)
744 .execute(&h.db)
745 .await
746 .unwrap();
747
748 // Buyer checkouts
749 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
750 h.client
751 .post_form(
752 &format!("/stripe/checkout/{item_id}"),
753 "share_contact=false",
754 )
755 .await;
756
757 // Find pending transaction
758 let session_id: String = sqlx::query_scalar(
759 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
760 )
761 .bind(buyer_id)
762 .fetch_one(&h.db)
763 .await
764 .unwrap();
765
766 // Fire purchase webhook
767 let mut meta = HashMap::new();
768 meta.insert("buyer_id".to_string(), buyer_id.to_string());
769 meta.insert("seller_id".to_string(), seller_id.to_string());
770 meta.insert("item_id".to_string(), item_id.clone());
771 let session = serde_json::json!({
772 "id": session_id,
773 "object": "checkout_session",
774 "mode": "payment",
775 "metadata": meta,
776 "payment_intent": "pi_split_001",
777 });
778 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
779 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
780
781 // Wait briefly for split recording (runs after transaction commit)
782 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
783
784 // Verify revenue_splits has a row for the collaborator
785 let split_amount: i32 =
786 sqlx::query_scalar("SELECT amount_cents FROM revenue_splits WHERE recipient_id = $1")
787 .bind(collab_id)
788 .fetch_one(&h.db)
789 .await
790 .unwrap();
791 // 999 * 30 / 100 = 299 (integer division)
792 assert_eq!(
793 split_amount, 299,
794 "Collaborator should get 30% of 999 = 299 cents"
795 );
796 }
797
798 // PWYW checkout with custom amount
799
800 #[tokio::test]
801 async fn pwyw_checkout_custom_amount() {
802 let mut h = TestHarness::with_mocks().await;
803 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
804
805 // Enable PWYW on the item (checkbox uses "on", not "true")
806 h.login("seller", "pass1234").await;
807 h.client
808 .put_form(
809 &format!("/api/items/{item_id}"),
810 "pwyw_enabled=on&pwyw_min_cents=100",
811 )
812 .await;
813 h.client.post_form("/logout", "").await;
814
815 // Buyer checkouts with custom amount
816 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
817 let resp = h
818 .client
819 .post_form(
820 &format!("/stripe/checkout/{item_id}"),
821 "share_contact=false&amount_cents=2500",
822 )
823 .await;
824 assert_eq!(
825 resp.status, 303,
826 "PWYW checkout should redirect, got: {} {}",
827 resp.status, resp.text
828 );
829
830 // Verify mock checkout was created
831 let mock_stripe = h.mock_stripe.as_ref().unwrap();
832 assert_eq!(
833 mock_stripe.checkouts().len(),
834 1,
835 "Should have created a checkout"
836 );
837
838 // Verify the pending transaction has amount_cents = 2500
839 let amount: i32 = sqlx::query_scalar(
840 "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
841 )
842 .bind(buyer_id)
843 .fetch_one(&h.db)
844 .await
845 .unwrap();
846 assert_eq!(
847 amount, 2500,
848 "PWYW transaction should have buyer's chosen amount"
849 );
850 }
851
852 // Discount code reduces checkout amount
853
854 #[tokio::test]
855 async fn discount_code_reduces_checkout_amount() {
856 let mut h = TestHarness::with_mocks().await;
857 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
858
859 // Create a 50% discount code for the seller
860 sqlx::query(
861 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents) VALUES ($1, 'HALF50', 'discount', 'percentage', 50, 0)",
862 )
863 .bind(seller_id)
864 .execute(&h.db)
865 .await
866 .unwrap();
867
868 // Buyer checkouts with promo code
869 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
870 let resp = h
871 .client
872 .post_form(
873 &format!("/stripe/checkout/{item_id}"),
874 "share_contact=false&promo_code=HALF50",
875 )
876 .await;
877 assert_eq!(
878 resp.status, 303,
879 "Discount checkout should redirect, got: {} {}",
880 resp.status, resp.text
881 );
882
883 // Verify the pending transaction has amount_cents = 500 (50% of 1000)
884 let amount: i32 = sqlx::query_scalar(
885 "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
886 )
887 .bind(buyer_id)
888 .fetch_one(&h.db)
889 .await
890 .unwrap();
891 assert_eq!(
892 amount, 500,
893 "50% discount should halve the price from 1000 to 500"
894 );
895 }
896
897 // Contact sharing on purchase
898
899 #[tokio::test]
900 async fn contact_sharing_on_purchase() {
901 let mut h = TestHarness::with_mocks().await;
902 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
903
904 // Buyer checkouts with share_contact=true
905 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
906 let resp = h
907 .client
908 .post_form(&format!("/stripe/checkout/{item_id}"), "share_contact=true")
909 .await;
910 assert_eq!(
911 resp.status, 303,
912 "Checkout should redirect, got: {} {}",
913 resp.status, resp.text
914 );
915
916 // Find pending transaction
917 let session_id: String = sqlx::query_scalar(
918 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
919 )
920 .bind(buyer_id)
921 .fetch_one(&h.db)
922 .await
923 .unwrap();
924
925 // Verify share_contact is true on the pending transaction
926 let share_contact: bool = sqlx::query_scalar(
927 "SELECT share_contact FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
928 )
929 .bind(buyer_id)
930 .fetch_one(&h.db)
931 .await
932 .unwrap();
933 assert!(
934 share_contact,
935 "Transaction should have share_contact = true"
936 );
937
938 // Complete via webhook
939 let mut meta = HashMap::new();
940 meta.insert("buyer_id".to_string(), buyer_id.to_string());
941 meta.insert("seller_id".to_string(), seller_id.to_string());
942 meta.insert("item_id".to_string(), item_id.clone());
943 let session = serde_json::json!({
944 "id": session_id,
945 "object": "checkout_session",
946 "mode": "payment",
947 "metadata": meta,
948 "payment_intent": "pi_contact_001",
949 });
950 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
951 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
952
953 // Verify transaction completed and still has share_contact = true
954 let (status, share): (String, bool) = sqlx::query_as(
955 "SELECT status, share_contact FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
956 )
957 .bind(buyer_id)
958 .bind(&item_id)
959 .fetch_one(&h.db)
960 .await
961 .unwrap();
962 assert_eq!(status, "completed");
963 assert!(
964 share,
965 "Completed transaction should preserve share_contact = true"
966 );
967 }
968
969 // Creator-initiated refund via API endpoint
970
971 #[tokio::test]
972 async fn creator_refund_endpoint() {
973 let mut h = TestHarness::with_mocks().await;
974 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
975
976 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
977 let pi_id = "pi_refund_api_001";
978
979 // Insert a completed transaction with a payment intent
980 sqlx::query(
981 r"INSERT INTO transactions
982 (buyer_id, seller_id, item_id, amount_cents, status,
983 stripe_payment_intent_id, stripe_checkout_session_id,
984 item_title, seller_username, completed_at)
985 VALUES ($1, $2, $3::uuid, 999, 'completed', $4, 'cs_refund_api', 'Track', 'seller', NOW())",
986 )
987 .bind(buyer_id)
988 .bind(seller_id)
989 .bind(&item_id)
990 .bind(pi_id)
991 .execute(&h.db)
992 .await
993 .unwrap();
994
995 // Get the transaction ID
996 let tx_id: String =
997 sqlx::query_scalar("SELECT id::text FROM transactions WHERE stripe_payment_intent_id = $1")
998 .bind(pi_id)
999 .fetch_one(&h.db)
1000 .await
1001 .unwrap();
1002
1003 // Log in as seller and hit the refund endpoint
1004 h.client.post_form("/logout", "").await;
1005 h.login("seller", "pass1234").await;
1006
1007 let resp = h
1008 .client
1009 .post_json(
1010 &format!("/api/items/{item_id}/refund"),
1011 &format!(r#"{{"transaction_id": "{tx_id}"}}"#),
1012 )
1013 .await;
1014 assert_eq!(
1015 resp.status, 200,
1016 "Creator refund endpoint should succeed: {} {}",
1017 resp.status, resp.text
1018 );
1019 let data: Value = resp.json();
1020 assert_eq!(data["ok"], true);
1021 }
1022
1023 #[tokio::test]
1024 async fn creator_refund_non_owner_rejected() {
1025 let mut h = TestHarness::with_mocks().await;
1026 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
1027
1028 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
1029
1030 // Insert a completed transaction
1031 sqlx::query(
1032 r"INSERT INTO transactions
1033 (buyer_id, seller_id, item_id, amount_cents, status,
1034 stripe_payment_intent_id, stripe_checkout_session_id,
1035 item_title, seller_username, completed_at)
1036 VALUES ($1, $2, $3::uuid, 999, 'completed', 'pi_notown', 'cs_notown', 'Track', 'seller', NOW())",
1037 )
1038 .bind(buyer_id)
1039 .bind(seller_id)
1040 .bind(&item_id)
1041 .execute(&h.db)
1042 .await
1043 .unwrap();
1044
1045 let tx_id: String = sqlx::query_scalar(
1046 "SELECT id::text FROM transactions WHERE stripe_payment_intent_id = 'pi_notown'",
1047 )
1048 .fetch_one(&h.db)
1049 .await
1050 .unwrap();
1051
1052 // Log in as a different creator (not the owner)
1053 h.client.post_form("/logout", "").await;
1054 let _other = h.create_creator("other").await;
1055
1056 let resp = h
1057 .client
1058 .post_json(
1059 &format!("/api/items/{item_id}/refund"),
1060 &format!(r#"{{"transaction_id": "{tx_id}"}}"#),
1061 )
1062 .await;
1063 assert!(
1064 resp.status == 403 || resp.status == 404,
1065 "Non-owner refund should be rejected: {} {}",
1066 resp.status,
1067 resp.text
1068 );
1069 }
1070
1071 #[tokio::test]
1072 async fn creator_refund_free_claim_rejected() {
1073 let mut h = TestHarness::with_mocks().await;
1074 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
1075
1076 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
1077
1078 // Insert a completed transaction WITHOUT payment intent (free claim)
1079 sqlx::query(
1080 r"INSERT INTO transactions
1081 (buyer_id, seller_id, item_id, amount_cents, status,
1082 stripe_checkout_session_id, item_title, seller_username, completed_at)
1083 VALUES ($1, $2, $3::uuid, 0, 'completed', 'cs_free_claim', 'Track', 'seller', NOW())",
1084 )
1085 .bind(buyer_id)
1086 .bind(seller_id)
1087 .bind(&item_id)
1088 .execute(&h.db)
1089 .await
1090 .unwrap();
1091
1092 let tx_id: String = sqlx::query_scalar(
1093 "SELECT id::text FROM transactions WHERE stripe_checkout_session_id = 'cs_free_claim'",
1094 )
1095 .fetch_one(&h.db)
1096 .await
1097 .unwrap();
1098
1099 // Log in as seller and try to refund the free claim
1100 h.client.post_form("/logout", "").await;
1101 h.login("seller", "pass1234").await;
1102
1103 let resp = h
1104 .client
1105 .post_json(
1106 &format!("/api/items/{item_id}/refund"),
1107 &format!(r#"{{"transaction_id": "{tx_id}"}}"#),
1108 )
1109 .await;
1110 assert_eq!(
1111 resp.status, 400,
1112 "Refunding a free claim should fail: {} {}",
1113 resp.status, resp.text
1114 );
1115 }
1116
1117 // Project-level checkout
1118
1119 #[tokio::test]
1120 async fn project_checkout_creates_session() {
1121 let mut h = TestHarness::with_mocks().await;
1122 let (seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await;
1123
1124 // Set project pricing to BuyOnce $19.99
1125 sqlx::query(
1126 "UPDATE projects SET pricing_model = 'buy_once', price_cents = 1999 WHERE id = $1::uuid",
1127 )
1128 .bind(&project_id)
1129 .execute(&h.db)
1130 .await
1131 .unwrap();
1132
1133 let _buyer_id = h
1134 .signup("projbuyer", "projbuyer@test.com", "pass1234")
1135 .await;
1136
1137 let resp = h
1138 .client
1139 .post_form(
1140 &format!("/stripe/checkout/project/{project_id}"),
1141 "share_contact=false",
1142 )
1143 .await;
1144 assert_eq!(
1145 resp.status, 303,
1146 "Project checkout should redirect, got: {} {}",
1147 resp.status, resp.text
1148 );
1149
1150 // Verify pending transaction created
1151 let count: i64 = sqlx::query_scalar(
1152 "SELECT COUNT(*) FROM transactions WHERE seller_id = $1 AND project_id = $2::uuid AND status = 'pending'",
1153 )
1154 .bind(seller_id)
1155 .bind(&project_id)
1156 .fetch_one(&h.db)
1157 .await
1158 .unwrap();
1159 assert_eq!(count, 1, "Should have 1 pending project transaction");
1160 }
1161
1162 #[tokio::test]
1163 async fn project_checkout_free_project_rejected() {
1164 let mut h = TestHarness::with_mocks().await;
1165 let (_seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await;
1166
1167 // Project pricing defaults to Free
1168 let _buyer_id = h.signup("freeproj", "freeproj@test.com", "pass1234").await;
1169
1170 let resp = h
1171 .client
1172 .post_form(
1173 &format!("/stripe/checkout/project/{project_id}"),
1174 "share_contact=false",
1175 )
1176 .await;
1177 assert_eq!(
1178 resp.status, 400,
1179 "Free project checkout should be rejected: {} {}",
1180 resp.status, resp.text
1181 );
1182 }
1183
1184 #[tokio::test]
1185 async fn project_checkout_self_purchase_rejected() {
1186 let mut h = TestHarness::with_mocks().await;
1187 let (_seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await;
1188
1189 // Set project pricing
1190 sqlx::query(
1191 "UPDATE projects SET pricing_model = 'buy_once', price_cents = 1999 WHERE id = $1::uuid",
1192 )
1193 .bind(&project_id)
1194 .execute(&h.db)
1195 .await
1196 .unwrap();
1197
1198 // Log in as seller and try to buy own project
1199 h.login("seller", "pass1234").await;
1200
1201 let resp = h
1202 .client
1203 .post_form(
1204 &format!("/stripe/checkout/project/{project_id}"),
1205 "share_contact=false",
1206 )
1207 .await;
1208 assert_eq!(
1209 resp.status, 400,
1210 "Self-purchase of project should be rejected: {} {}",
1211 resp.status, resp.text
1212 );
1213 }
1214
1215 // Cart checkout via Stripe
1216
1217 #[tokio::test]
1218 async fn cart_checkout_single_seller() {
1219 let mut h = TestHarness::with_mocks().await;
1220 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1221
1222 let buyer_id = h.signup("cartchk", "cartchk@test.com", "pass1234").await;
1223
1224 // Add item to cart
1225 h.client
1226 .post_form(&format!("/api/cart/{item_id}"), "")
1227 .await;
1228
1229 // Checkout cart for this seller
1230 let resp = h
1231 .client
1232 .post_form(
1233 "/stripe/checkout/cart",
1234 &format!("seller_id={seller_id}&share_contact=false"),
1235 )
1236 .await;
1237 assert_eq!(
1238 resp.status, 303,
1239 "Cart checkout should redirect, got: {} {}",
1240 resp.status, resp.text
1241 );
1242
1243 // Verify mock checkout was created
1244 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1245 assert!(
1246 !mock_stripe.checkouts().is_empty(),
1247 "Should have created a checkout session"
1248 );
1249
1250 // Verify pending transaction
1251 let count: i64 = sqlx::query_scalar(
1252 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
1253 )
1254 .bind(buyer_id)
1255 .fetch_one(&h.db)
1256 .await
1257 .unwrap();
1258 assert!(count >= 1, "Should have at least 1 pending transaction");
1259 }
1260
1261 #[tokio::test]
1262 async fn cart_checkout_empty_cart_rejected() {
1263 let mut h = TestHarness::with_mocks().await;
1264 let (seller_id, _project_id, _item_id) = setup_paid_item(&mut h, 500).await;
1265
1266 let _buyer_id = h
1267 .signup("emptycart", "emptycart@test.com", "pass1234")
1268 .await;
1269
1270 // Don't add anything to cart
1271 let resp = h
1272 .client
1273 .post_form(
1274 "/stripe/checkout/cart",
1275 &format!("seller_id={seller_id}&share_contact=false"),
1276 )
1277 .await;
1278 assert_eq!(
1279 resp.status, 400,
1280 "Empty cart checkout should be rejected: {} {}",
1281 resp.status, resp.text
1282 );
1283 }
1284
1285 #[tokio::test]
1286 async fn cart_checkout_self_purchase_rejected() {
1287 let mut h = TestHarness::with_mocks().await;
1288 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1289
1290 // Create a second user who adds the item to cart
1291 let _buyer_id = h.signup("cartself", "cartself@test.com", "pass1234").await;
1292 h.client
1293 .post_form(&format!("/api/cart/{item_id}"), "")
1294 .await;
1295
1296 // Try to checkout with seller_id = self
1297 h.client.post_form("/logout", "").await;
1298 h.login("seller", "pass1234").await;
1299
1300 // Seller adds own item to cart via DB directly (API blocks it, so simulate)
1301 sqlx::query(
1302 "INSERT INTO cart_items (user_id, item_id) VALUES ($1, $2::uuid) ON CONFLICT DO NOTHING",
1303 )
1304 .bind(seller_id)
1305 .bind(&item_id)
1306 .execute(&h.db)
1307 .await
1308 .unwrap();
1309
1310 let resp = h
1311 .client
1312 .post_form(
1313 "/stripe/checkout/cart",
1314 &format!("seller_id={seller_id}&share_contact=false"),
1315 )
1316 .await;
1317 assert_eq!(
1318 resp.status, 400,
1319 "Self-purchase via cart should be rejected: {} {}",
1320 resp.status, resp.text
1321 );
1322 }
1323
1324 #[tokio::test]
1325 async fn cart_checkout_free_items_claimed_immediately() {
1326 let mut h = TestHarness::with_mocks().await;
1327 let (seller_id, project_id, _item_id) = setup_paid_item(&mut h, 0).await;
1328
1329 // Create a free item
1330 h.login("seller", "pass1234").await;
1331 let resp = h
1332 .client
1333 .post_form(
1334 &format!("/api/projects/{project_id}/items"),
1335 "title=Free+Track&item_type=digital&price_cents=0",
1336 )
1337 .await;
1338 assert_eq!(resp.status, 200, "{}", resp.text);
1339 let free_item: Value = resp.json();
1340 let free_item_id = free_item["id"].as_str().unwrap().to_string();
1341 h.client
1342 .put_form(&format!("/api/items/{free_item_id}"), "is_public=true")
1343 .await;
1344 h.client.post_form("/logout", "").await;
1345
1346 let buyer_id = h.signup("freecart", "freecart@test.com", "pass1234").await;
1347
1348 // Add free item to cart
1349 h.client
1350 .post_form(&format!("/api/cart/{free_item_id}"), "")
1351 .await;
1352
1353 // Cart checkout, free items should be claimed immediately, no Stripe session
1354 let resp = h
1355 .client
1356 .post_form(
1357 "/stripe/checkout/cart",
1358 &format!("seller_id={seller_id}&share_contact=false"),
1359 )
1360 .await;
1361 // Either redirect back (all free, no Stripe needed) or success
1362 assert!(
1363 !resp.status.is_server_error(),
1364 "Free cart checkout failed: {} {}",
1365 resp.status,
1366 resp.text
1367 );
1368
1369 // Verify free item was claimed (completed transaction exists)
1370 let count: i64 = sqlx::query_scalar(
1371 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'",
1372 )
1373 .bind(buyer_id)
1374 .bind(&free_item_id)
1375 .fetch_one(&h.db)
1376 .await
1377 .unwrap();
1378 assert_eq!(count, 1, "Free item should be claimed immediately");
1379
1380 // Verify item removed from cart
1381 let cart_count: i64 = sqlx::query_scalar(
1382 "SELECT COUNT(*) FROM cart_items WHERE user_id = $1 AND item_id = $2::uuid",
1383 )
1384 .bind(buyer_id)
1385 .bind(&free_item_id)
1386 .fetch_one(&h.db)
1387 .await
1388 .unwrap();
1389 assert_eq!(
1390 cart_count, 0,
1391 "Free item should be removed from cart after claim"
1392 );
1393 }
1394
1395 /// A promo applied at cart checkout discounts the pending transaction and
1396 /// reserves exactly one promo use. Exercises the cart core's promo path, which
1397 /// the single-item promo suite (`promo_codes_checkout`) does not cover.
1398 #[tokio::test]
1399 async fn cart_checkout_promo_discount_applied() {
1400 let mut h = TestHarness::with_mocks().await;
1401 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1402
1403 // Seller is logged in after setup; create a 20%-off code.
1404 h.login("seller", "pass1234").await;
1405 let resp = h
1406 .client
1407 .post_form(
1408 "/api/promo-codes",
1409 "code=CART20&code_purpose=discount&discount_type=percentage&discount_value=20",
1410 )
1411 .await;
1412 assert_eq!(
1413 resp.status, 200,
1414 "create promo failed: {} {}",
1415 resp.status, resp.text
1416 );
1417 h.client.post_form("/logout", "").await;
1418
1419 let buyer_id = h
1420 .signup("cartpromo", "cartpromo@test.com", "pass1234")
1421 .await;
1422 h.client
1423 .post_form(&format!("/api/cart/{item_id}"), "")
1424 .await;
1425
1426 let resp = h
1427 .client
1428 .post_form(
1429 "/stripe/checkout/cart",
1430 &format!("seller_id={seller_id}&share_contact=false&promo_code=CART20"),
1431 )
1432 .await;
1433 assert_eq!(
1434 resp.status, 303,
1435 "promo cart checkout should proceed: {} {}",
1436 resp.status, resp.text
1437 );
1438 // 20% off $5.00 = $4.00 pending.
1439 let amount: i32 = sqlx::query_scalar(
1440 "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
1441 )
1442 .bind(buyer_id)
1443 .fetch_one(&h.db)
1444 .await
1445 .unwrap();
1446 assert_eq!(
1447 amount, 400,
1448 "pending amount should be the 20%-discounted price"
1449 );
1450
1451 let use_count: i32 =
1452 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CART20'")
1453 .fetch_one(&h.db)
1454 .await
1455 .unwrap();
1456 assert_eq!(
1457 use_count, 1,
1458 "a completed cart checkout reserves exactly one promo use"
1459 );
1460 }
1461
1462 /// An ALL-FREE cart (a 100%-off promo zeroes every line) must still consume
1463 /// exactly one promo use, and a max_uses-limited code must be exhausted after
1464 /// it. Regression for the audit finding where the free-cart early-return fired
1465 /// before the promo reservation, letting a limited/100%-off code be redeemed
1466 /// unlimited times via cart checkout.
1467 #[tokio::test]
1468 async fn cart_checkout_all_free_promo_consumes_one_use() {
1469 let mut h = TestHarness::with_mocks().await;
1470 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1471
1472 // 100%-off, single-use code (direct SQL, the API form doesn't take max_uses).
1473 sqlx::query(
1474 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
1475 VALUES ($1, 'CARTFREE', 'discount', 'percentage', 100, 0, 1)",
1476 )
1477 .bind(seller_id)
1478 .execute(&h.db)
1479 .await
1480 .unwrap();
1481 h.client.post_form("/logout", "").await;
1482
1483 // First buyer: all-free cart checkout claims the item and burns the one use.
1484 let buyer1 = h
1485 .signup("cartfree1", "cartfree1@test.com", "pass1234")
1486 .await;
1487 h.client
1488 .post_form(&format!("/api/cart/{item_id}"), "")
1489 .await;
1490 let resp = h
1491 .client
1492 .post_form(
1493 "/stripe/checkout/cart",
1494 &format!("seller_id={seller_id}&share_contact=false&promo_code=CARTFREE"),
1495 )
1496 .await;
1497 assert_eq!(
1498 resp.status, 303,
1499 "all-free cart checkout should succeed: {} {}",
1500 resp.status, resp.text
1501 );
1502 // Free claim recorded (a $0 completed transaction), no Stripe session.
1503 let amount: i32 =
1504 sqlx::query_scalar("SELECT amount_cents FROM transactions WHERE buyer_id = $1")
1505 .bind(buyer1)
1506 .fetch_one(&h.db)
1507 .await
1508 .unwrap();
1509 assert_eq!(amount, 0, "all-free cart should record a $0 claim");
1510
1511 let use_count: i32 =
1512 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'")
1513 .fetch_one(&h.db)
1514 .await
1515 .unwrap();
1516 assert_eq!(
1517 use_count, 1,
1518 "an all-free cart checkout must consume exactly one promo use"
1519 );
1520
1521 // Second buyer: the code is now exhausted; the all-free cart must be rejected
1522 // and the use_count must not move past its max.
1523 h.client.post_form("/logout", "").await;
1524 let _buyer2 = h
1525 .signup("cartfree2", "cartfree2@test.com", "pass1234")
1526 .await;
1527 h.client
1528 .post_form(&format!("/api/cart/{item_id}"), "")
1529 .await;
1530 let resp = h
1531 .client
1532 .post_form(
1533 "/stripe/checkout/cart",
1534 &format!("seller_id={seller_id}&share_contact=false&promo_code=CARTFREE"),
1535 )
1536 .await;
1537 assert_eq!(
1538 resp.status.as_u16(),
1539 400,
1540 "exhausted code on an all-free cart should be rejected: {}",
1541 resp.text
1542 );
1543 let use_count: i32 =
1544 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'")
1545 .fetch_one(&h.db)
1546 .await
1547 .unwrap();
1548 assert_eq!(use_count, 1, "exhausted code must not exceed its max_uses");
1549
1550 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1551 assert_eq!(
1552 mock_stripe.checkouts().len(),
1553 0,
1554 "no Stripe session for an all-free cart"
1555 );
1556 }
1557
1558 /// A promo that drops the cart total below the Stripe minimum is rejected and
1559 /// must NOT burn a promo use. Pins the cart core's "reserve only after the
1560 /// min-charge gate" ordering (the gate runs before reservation).
1561 #[tokio::test]
1562 async fn cart_checkout_promo_sub_minimum_not_burned() {
1563 let mut h = TestHarness::with_mocks().await;
1564 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 100).await;
1565
1566 h.login("seller", "pass1234").await;
1567 // $0.70 off $1.00 -> 30¢, below the 50¢ Stripe minimum.
1568 let resp = h
1569 .client
1570 .post_form(
1571 "/api/promo-codes",
1572 "code=CARTTINY&code_purpose=discount&discount_type=fixed&discount_value=70",
1573 )
1574 .await;
1575 assert_eq!(
1576 resp.status, 200,
1577 "create promo failed: {} {}",
1578 resp.status, resp.text
1579 );
1580 h.client.post_form("/logout", "").await;
1581
1582 let buyer_id = h.signup("carttiny", "carttiny@test.com", "pass1234").await;
1583 h.client
1584 .post_form(&format!("/api/cart/{item_id}"), "")
1585 .await;
1586
1587 let resp = h
1588 .client
1589 .post_form(
1590 "/stripe/checkout/cart",
1591 &format!("seller_id={seller_id}&share_contact=false&promo_code=CARTTINY"),
1592 )
1593 .await;
1594 assert_eq!(
1595 resp.status.as_u16(),
1596 400,
1597 "sub-minimum cart total must be rejected: {} {}",
1598 resp.status,
1599 resp.text
1600 );
1601
1602 let pending: i64 = sqlx::query_scalar(
1603 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
1604 )
1605 .bind(buyer_id)
1606 .fetch_one(&h.db)
1607 .await
1608 .unwrap();
1609 assert_eq!(
1610 pending, 0,
1611 "rejected sub-minimum cart checkout must not create a pending row"
1612 );
1613
1614 let use_count: i32 =
1615 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTTINY'")
1616 .fetch_one(&h.db)
1617 .await
1618 .unwrap();
1619 assert_eq!(
1620 use_count, 0,
1621 "promo must not be reserved when the cart is rejected pre-reservation"
1622 );
1623 }
1624
1625 /// Checkout-all across two sellers chains through `drain_to_paid`: it should
1626 /// reach a paid seller and return a Stripe URL, exercising the shared core via
1627 /// the cross-seller entry point.
1628 #[tokio::test]
1629 async fn cart_checkout_all_cross_seller_chain() {
1630 let mut h = TestHarness::with_mocks().await;
1631 let (_seller_a, _proj_a, item_a) = setup_paid_item(&mut h, 500).await;
1632
1633 // Second seller with their own paid item.
1634 let seller_b = h.signup("sellerb", "sellerb@test.com", "pass1234").await;
1635 h.grant_creator(seller_b).await;
1636 sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_b', stripe_charges_enabled = true WHERE id = $1")
1637 .bind(seller_b)
1638 .execute(&h.db)
1639 .await
1640 .unwrap();
1641 h.login("sellerb", "pass1234").await;
1642 let resp = h
1643 .client
1644 .post_form("/api/projects", "slug=shopb&title=ShopB")
1645 .await;
1646 let proj_b: Value = resp.json();
1647 let proj_b_id = proj_b["id"].as_str().unwrap().to_string();
1648 let resp = h
1649 .client
1650 .post_form(
1651 &format!("/api/projects/{proj_b_id}/items"),
1652 "title=TrackB&price_cents=700&item_type=audio",
1653 )
1654 .await;
1655 let item_b: Value = resp.json();
1656 let item_b_id = item_b["id"].as_str().unwrap().to_string();
1657 h.client
1658 .put_form(&format!("/api/projects/{proj_b_id}"), "is_public=true")
1659 .await;
1660 h.client
1661 .put_form(&format!("/api/items/{item_b_id}"), "is_public=true")
1662 .await;
1663 h.client.post_form("/logout", "").await;
1664
1665 let _buyer_id = h.signup("cartall", "cartall@test.com", "pass1234").await;
1666 h.client.post_form(&format!("/api/cart/{item_a}"), "").await;
1667 h.client
1668 .post_form(&format!("/api/cart/{item_b_id}"), "")
1669 .await;
1670
1671 let resp = h
1672 .client
1673 .post_form("/stripe/checkout/cart/all", "share_contact=false")
1674 .await;
1675 assert_eq!(
1676 resp.status, 303,
1677 "checkout-all should reach a paid seller: {} {}",
1678 resp.status, resp.text
1679 );
1680
1681 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1682 assert!(
1683 !mock_stripe.checkouts().is_empty(),
1684 "chain should create at least one checkout session"
1685 );
1686 }
1687
1688 // Subscription checkout
1689
1690 #[tokio::test]
1691 async fn subscription_checkout_creates_session() {
1692 let mut h = TestHarness::with_mocks().await;
1693
1694 // Create a seller with Stripe connected
1695 let seller_id = h
1696 .signup("subseller", "subseller@test.com", "pass1234")
1697 .await;
1698 h.grant_creator(seller_id).await;
1699 sqlx::query(
1700 "UPDATE users SET stripe_account_id = 'acct_mock_sub', stripe_charges_enabled = true WHERE id = $1",
1701 )
1702 .bind(seller_id)
1703 .execute(&h.db)
1704 .await
1705 .unwrap();
1706
1707 // Create a project
1708 h.client.post_form("/logout", "").await;
1709 h.login("subseller", "pass1234").await;
1710 let resp = h
1711 .client
1712 .post_form("/api/projects", "slug=subproj&title=Sub+Project")
1713 .await;
1714 let project: Value = resp.json();
1715 let project_id = project["id"].as_str().unwrap().to_string();
1716 h.client
1717 .put_json(
1718 &format!("/api/projects/{project_id}"),
1719 r#"{"is_public": true}"#,
1720 )
1721 .await;
1722
1723 // Create a subscription tier with fake Stripe IDs
1724 sqlx::query(
1725 r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1726 VALUES ($1::uuid, 'Gold', 999, true, 'prod_mock_gold', 'price_mock_gold')",
1727 )
1728 .bind(&project_id)
1729 .execute(&h.db)
1730 .await
1731 .unwrap();
1732
1733 let tier_id: String =
1734 sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid")
1735 .bind(&project_id)
1736 .fetch_one(&h.db)
1737 .await
1738 .unwrap();
1739
1740 // Log out seller, sign up subscriber
1741 h.client.post_form("/logout", "").await;
1742 let _subscriber_id = h
1743 .signup("subscriber", "subscriber@test.com", "pass1234")
1744 .await;
1745
1746 let resp = h
1747 .client
1748 .post_form(&format!("/stripe/subscribe/{tier_id}"), "")
1749 .await;
1750 assert_eq!(
1751 resp.status, 303,
1752 "Subscription checkout should redirect, got: {} {}",
1753 resp.status, resp.text
1754 );
1755
1756 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1757 assert!(
1758 !mock_stripe.checkouts().is_empty(),
1759 "Should have created a subscription checkout"
1760 );
1761 }
1762
1763 #[tokio::test]
1764 async fn subscription_checkout_self_subscribe_rejected() {
1765 let mut h = TestHarness::with_mocks().await;
1766
1767 let seller_id = h
1768 .signup("selfsubseller", "selfsubseller@test.com", "pass1234")
1769 .await;
1770 h.grant_creator(seller_id).await;
1771 sqlx::query(
1772 "UPDATE users SET stripe_account_id = 'acct_selfsub', stripe_charges_enabled = true WHERE id = $1",
1773 )
1774 .bind(seller_id)
1775 .execute(&h.db)
1776 .await
1777 .unwrap();
1778
1779 h.client.post_form("/logout", "").await;
1780 h.login("selfsubseller", "pass1234").await;
1781
1782 let resp = h
1783 .client
1784 .post_form("/api/projects", "slug=selfsub&title=Self+Sub")
1785 .await;
1786 let project: Value = resp.json();
1787 let project_id = project["id"].as_str().unwrap().to_string();
1788 h.client
1789 .put_json(
1790 &format!("/api/projects/{project_id}"),
1791 r#"{"is_public": true}"#,
1792 )
1793 .await;
1794
1795 sqlx::query(
1796 r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1797 VALUES ($1::uuid, 'Self', 999, true, 'prod_self', 'price_self')",
1798 )
1799 .bind(&project_id)
1800 .execute(&h.db)
1801 .await
1802 .unwrap();
1803
1804 let tier_id: String =
1805 sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid")
1806 .bind(&project_id)
1807 .fetch_one(&h.db)
1808 .await
1809 .unwrap();
1810
1811 // Seller tries to subscribe to own project
1812 let resp = h
1813 .client
1814 .post_form(&format!("/stripe/subscribe/{tier_id}"), "")
1815 .await;
1816 assert_eq!(
1817 resp.status, 400,
1818 "Self-subscription should be rejected: {} {}",
1819 resp.status, resp.text
1820 );
1821 }
1822
1823 #[tokio::test]
1824 async fn subscription_checkout_inactive_tier_rejected() {
1825 let mut h = TestHarness::with_mocks().await;
1826
1827 let seller_id = h
1828 .signup("inactseller", "inactseller@test.com", "pass1234")
1829 .await;
1830 h.grant_creator(seller_id).await;
1831 sqlx::query(
1832 "UPDATE users SET stripe_account_id = 'acct_inact', stripe_charges_enabled = true WHERE id = $1",
1833 )
1834 .bind(seller_id)
1835 .execute(&h.db)
1836 .await
1837 .unwrap();
1838
1839 h.client.post_form("/logout", "").await;
1840 h.login("inactseller", "pass1234").await;
1841
1842 let resp = h
1843 .client
1844 .post_form("/api/projects", "slug=inactproj&title=Inactive")
1845 .await;
1846 let project: Value = resp.json();
1847 let project_id = project["id"].as_str().unwrap().to_string();
1848 h.client
1849 .put_json(
1850 &format!("/api/projects/{project_id}"),
1851 r#"{"is_public": true}"#,
1852 )
1853 .await;
1854
1855 // Create an INACTIVE tier
1856 sqlx::query(
1857 r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1858 VALUES ($1::uuid, 'Archived', 999, false, 'prod_arch', 'price_arch')",
1859 )
1860 .bind(&project_id)
1861 .execute(&h.db)
1862 .await
1863 .unwrap();
1864
1865 let tier_id: String =
1866 sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid")
1867 .bind(&project_id)
1868 .fetch_one(&h.db)
1869 .await
1870 .unwrap();
1871
1872 h.client.post_form("/logout", "").await;
1873 let _sub_id = h.signup("inactsub", "inactsub@test.com", "pass1234").await;
1874
1875 let resp = h
1876 .client
1877 .post_form(&format!("/stripe/subscribe/{tier_id}"), "")
1878 .await;
1879 assert_eq!(
1880 resp.status, 400,
1881 "Inactive tier should be rejected: {} {}",
1882 resp.status, resp.text
1883 );
1884 }
1885
1886 // Creator tier checkout (requires config)
1887
1888 #[tokio::test]
1889 async fn creator_tier_checkout_not_configured_rejected() {
1890 let mut h = TestHarness::with_mocks().await;
1891 let _user_id = h.signup("tierbuy", "tierbuy@test.com", "pass1234").await;
1892
1893 // Config has empty creator_tier_prices, so this should fail with "not configured"
1894 let resp = h
1895 .client
1896 .post_form("/stripe/creator-tier", "tier=small_files")
1897 .await;
1898 assert_eq!(
1899 resp.status, 400,
1900 "Creator tier checkout without config should fail: {} {}",
1901 resp.status, resp.text
1902 );
1903 }
1904
1905 // Subscription single-live-row invariant (ultra-fuzz Run 6, R6-Pay-N1)
1906
1907 /// Post a subscription `checkout.session.completed` webhook with explicit event
1908 /// and Stripe-subscription IDs so two calls aren't deduped as a replay.
1909 async fn post_subscription_webhook(
1910 h: &mut TestHarness,
1911 event_id: &str,
1912 session_id: &str,
1913 stripe_sub_id: &str,
1914 subscriber_id: &str,
1915 project_id: &str,
1916 tier_id: &str,
1917 ) -> crate::harness::client::TestResponse {
1918 let object = serde_json::json!({
1919 "id": session_id,
1920 "subscription": stripe_sub_id,
1921 "customer": "cus_n1_invariant",
1922 "metadata": {
1923 "checkout_type": "subscription",
1924 "subscriber_id": subscriber_id,
1925 "project_id": project_id,
1926 "tier_id": tier_id,
1927 }
1928 });
1929 let payload = serde_json::json!({
1930 "id": event_id,
1931 "type": "checkout.session.completed",
1932 "data": {"object": object},
1933 })
1934 .to_string();
1935 let signature = crate::harness::stripe::sign_webhook_payload(
1936 &payload,
1937 crate::harness::stripe::TEST_WEBHOOK_SECRET,
1938 );
1939 h.client
1940 .request_with_headers(
1941 "POST",
1942 "/stripe/webhook",
1943 Some(&payload),
1944 &[
1945 ("stripe-signature", &signature),
1946 ("content-type", "application/json"),
1947 ],
1948 )
1949 .await
1950 }
1951
1952 /// A resubscribe over a lingering `past_due` row must leave exactly one live
1953 /// (active) subscription: the stale row is canceled by `create_subscription`'s
1954 /// pre-insert cleanup, not left to coexist with the new active row.
1955 #[tokio::test]
1956 async fn subscription_resubscribe_after_past_due_leaves_one_live_row() {
1957 let mut h = TestHarness::with_mocks().await;
1958
1959 let seller_id = h.signup("n1seller", "n1seller@test.com", "pass1234").await;
1960 h.grant_creator(seller_id).await;
1961 sqlx::query(
1962 "UPDATE users SET stripe_account_id = 'acct_n1', stripe_charges_enabled = true WHERE id = $1",
1963 )
1964 .bind(seller_id)
1965 .execute(&h.db)
1966 .await
1967 .unwrap();
1968
1969 h.client.post_form("/logout", "").await;
1970 h.login("n1seller", "pass1234").await;
1971 let resp = h
1972 .client
1973 .post_form("/api/projects", "slug=n1proj&title=N1+Project")
1974 .await;
1975 let project: Value = resp.json();
1976 let project_id = project["id"].as_str().unwrap().to_string();
1977 h.client
1978 .put_json(
1979 &format!("/api/projects/{project_id}"),
1980 r#"{"is_public": true}"#,
1981 )
1982 .await;
1983 sqlx::query(
1984 r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1985 VALUES ($1::uuid, 'Gold', 999, true, 'prod_n1', 'price_n1')",
1986 )
1987 .bind(&project_id)
1988 .execute(&h.db)
1989 .await
1990 .unwrap();
1991 let tier_id: String =
1992 sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid")
1993 .bind(&project_id)
1994 .fetch_one(&h.db)
1995 .await
1996 .unwrap();
1997
1998 h.client.post_form("/logout", "").await;
1999 let subscriber_id = h.signup("n1fan", "n1fan@test.com", "pass1234").await;
2000 let subscriber_str = subscriber_id.to_string();
2001
2002 // First subscription → one active row.
2003 let r1 = post_subscription_webhook(
2004 &mut h,
2005 "evt_n1_1",
2006 "cs_n1_1",
2007 "sub_n1_1",
2008 &subscriber_str,
2009 &project_id,
2010 &tier_id,
2011 )
2012 .await;
2013 assert_eq!(
2014 r1.status, 200,
2015 "first sub webhook: {} {}",
2016 r1.status, r1.text
2017 );
2018
2019 // Stripe dunning leaves the row past_due (the gate ignores it, so the user
2020 // appears unsubscribed and can resubscribe).
2021 sqlx::query(
2022 "UPDATE subscriptions SET status = 'past_due' WHERE stripe_subscription_id = 'sub_n1_1'",
2023 )
2024 .execute(&h.db)
2025 .await
2026 .unwrap();
2027
2028 // Resubscribe: a brand-new Stripe subscription for the same (subscriber, project).
2029 let r2 = post_subscription_webhook(
2030 &mut h,
2031 "evt_n1_2",
2032 "cs_n1_2",
2033 "sub_n1_2",
2034 &subscriber_str,
2035 &project_id,
2036 &tier_id,
2037 )
2038 .await;
2039 assert_eq!(
2040 r2.status, 200,
2041 "second sub webhook: {} {}",
2042 r2.status, r2.text
2043 );
2044
2045 let active_count: i64 = sqlx::query_scalar(
2046 "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2::uuid AND status = 'active'",
2047 )
2048 .bind(subscriber_id)
2049 .bind(&project_id)
2050 .fetch_one(&h.db)
2051 .await
2052 .unwrap();
2053 assert_eq!(
2054 active_count, 1,
2055 "exactly one active subscription after resubscribe"
2056 );
2057
2058 let old_status: String = sqlx::query_scalar(
2059 "SELECT status FROM subscriptions WHERE stripe_subscription_id = 'sub_n1_1'",
2060 )
2061 .fetch_one(&h.db)
2062 .await
2063 .unwrap();
2064 assert_eq!(
2065 old_status, "canceled",
2066 "stale past_due row should be canceled"
2067 );
2068
2069 let total: i64 = sqlx::query_scalar(
2070 "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2::uuid",
2071 )
2072 .bind(subscriber_id)
2073 .bind(&project_id)
2074 .fetch_one(&h.db)
2075 .await
2076 .unwrap();
2077 assert_eq!(total, 2, "old (canceled) + new (active)");
2078 }
2079