Skip to main content

max / makenotwork

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