Skip to main content

max / makenotwork

66.9 KB · 2199 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 #[tokio::test]
1118 async fn creator_refund_already_claimed_rejected() {
1119 // The claim (`completed -> refunding`) is what stops a rapid double-submit
1120 // consuming another cart line's refundable balance (Pay-S1, Run 9). The db
1121 // layer's own tests cover the primitive; this one covers the route still
1122 // asking for the claim, which is the guard an extraction can silently drop.
1123 let mut h = TestHarness::with_mocks().await;
1124 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
1125
1126 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
1127
1128 // A transaction already claimed by an in-flight refund.
1129 sqlx::query(
1130 r"INSERT INTO transactions
1131 (buyer_id, seller_id, item_id, amount_cents, status,
1132 stripe_payment_intent_id, stripe_checkout_session_id,
1133 item_title, seller_username, completed_at)
1134 VALUES ($1, $2, $3::uuid, 999, 'refunding', 'pi_claimed', 'cs_claimed', 'Track', 'seller', NOW())",
1135 )
1136 .bind(buyer_id)
1137 .bind(seller_id)
1138 .bind(&item_id)
1139 .execute(&h.db)
1140 .await
1141 .unwrap();
1142
1143 let tx_id: String = sqlx::query_scalar(
1144 "SELECT id::text FROM transactions WHERE stripe_payment_intent_id = 'pi_claimed'",
1145 )
1146 .fetch_one(&h.db)
1147 .await
1148 .unwrap();
1149
1150 h.client.post_form("/logout", "").await;
1151 h.login("seller", "pass1234").await;
1152
1153 let resp = h
1154 .client
1155 .post_json(
1156 &format!("/api/items/{item_id}/refund"),
1157 &format!(r#"{{"transaction_id": "{tx_id}"}}"#),
1158 )
1159 .await;
1160 assert_eq!(
1161 resp.status, 400,
1162 "Refunding an already-claimed transaction should fail: {} {}",
1163 resp.status, resp.text
1164 );
1165 }
1166
1167 // Project-level checkout
1168
1169 #[tokio::test]
1170 async fn project_checkout_creates_session() {
1171 let mut h = TestHarness::with_mocks().await;
1172 let (seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await;
1173
1174 // Set project pricing to BuyOnce $19.99
1175 sqlx::query(
1176 "UPDATE projects SET pricing_model = 'buy_once', price_cents = 1999 WHERE id = $1::uuid",
1177 )
1178 .bind(&project_id)
1179 .execute(&h.db)
1180 .await
1181 .unwrap();
1182
1183 let _buyer_id = h
1184 .signup("projbuyer", "projbuyer@test.com", "pass1234")
1185 .await;
1186
1187 let resp = h
1188 .client
1189 .post_form(
1190 &format!("/stripe/checkout/project/{project_id}"),
1191 "share_contact=false",
1192 )
1193 .await;
1194 assert_eq!(
1195 resp.status, 303,
1196 "Project checkout should redirect, got: {} {}",
1197 resp.status, resp.text
1198 );
1199
1200 // Verify pending transaction created
1201 let count: i64 = sqlx::query_scalar(
1202 "SELECT COUNT(*) FROM transactions WHERE seller_id = $1 AND project_id = $2::uuid AND status = 'pending'",
1203 )
1204 .bind(seller_id)
1205 .bind(&project_id)
1206 .fetch_one(&h.db)
1207 .await
1208 .unwrap();
1209 assert_eq!(count, 1, "Should have 1 pending project transaction");
1210 }
1211
1212 #[tokio::test]
1213 async fn project_checkout_free_project_rejected() {
1214 let mut h = TestHarness::with_mocks().await;
1215 let (_seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await;
1216
1217 // Project pricing defaults to Free
1218 let _buyer_id = h.signup("freeproj", "freeproj@test.com", "pass1234").await;
1219
1220 let resp = h
1221 .client
1222 .post_form(
1223 &format!("/stripe/checkout/project/{project_id}"),
1224 "share_contact=false",
1225 )
1226 .await;
1227 assert_eq!(
1228 resp.status, 400,
1229 "Free project checkout should be rejected: {} {}",
1230 resp.status, resp.text
1231 );
1232 }
1233
1234 #[tokio::test]
1235 async fn project_checkout_self_purchase_rejected() {
1236 let mut h = TestHarness::with_mocks().await;
1237 let (_seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await;
1238
1239 // Set project pricing
1240 sqlx::query(
1241 "UPDATE projects SET pricing_model = 'buy_once', price_cents = 1999 WHERE id = $1::uuid",
1242 )
1243 .bind(&project_id)
1244 .execute(&h.db)
1245 .await
1246 .unwrap();
1247
1248 // Log in as seller and try to buy own project
1249 h.login("seller", "pass1234").await;
1250
1251 let resp = h
1252 .client
1253 .post_form(
1254 &format!("/stripe/checkout/project/{project_id}"),
1255 "share_contact=false",
1256 )
1257 .await;
1258 assert_eq!(
1259 resp.status, 400,
1260 "Self-purchase of project should be rejected: {} {}",
1261 resp.status, resp.text
1262 );
1263 }
1264
1265 // Cart checkout via Stripe
1266
1267 #[tokio::test]
1268 async fn cart_checkout_single_seller() {
1269 let mut h = TestHarness::with_mocks().await;
1270 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1271
1272 let buyer_id = h.signup("cartchk", "cartchk@test.com", "pass1234").await;
1273
1274 // Add item to cart
1275 h.client
1276 .post_form(&format!("/api/cart/{item_id}"), "")
1277 .await;
1278
1279 // Checkout cart for this seller
1280 let resp = h
1281 .client
1282 .post_form(
1283 "/stripe/checkout/cart",
1284 &format!("seller_id={seller_id}&share_contact=false"),
1285 )
1286 .await;
1287 assert_eq!(
1288 resp.status, 303,
1289 "Cart checkout should redirect, got: {} {}",
1290 resp.status, resp.text
1291 );
1292
1293 // Verify mock checkout was created
1294 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1295 assert!(
1296 !mock_stripe.checkouts().is_empty(),
1297 "Should have created a checkout session"
1298 );
1299
1300 // Verify pending transaction: one row, at the item's price.
1301 let (count, total): (i64, i64) = sqlx::query_as(
1302 "SELECT COUNT(*), COALESCE(SUM(amount_cents), 0) FROM transactions \
1303 WHERE buyer_id = $1 AND status = 'pending'",
1304 )
1305 .bind(buyer_id)
1306 .fetch_one(&h.db)
1307 .await
1308 .unwrap();
1309 assert_eq!(count, 1, "one pending transaction for a one-item cart");
1310 assert_eq!(total, 500, "pending amount must be the item's price");
1311 }
1312
1313 /// Two paid items from the same seller in one cart produce one pending
1314 /// transaction per item, and their amounts sum to the cart total. Pins the
1315 /// cart core's per-line insert against a single-row shortcut.
1316 #[tokio::test]
1317 async fn cart_checkout_two_items_same_seller_sums_to_cart_total() {
1318 let mut h = TestHarness::with_mocks().await;
1319 let (seller_id, project_id, item_a) = setup_paid_item(&mut h, 500).await;
1320
1321 // Second paid item under the same seller and project.
1322 h.login("seller", "pass1234").await;
1323 let resp = h
1324 .client
1325 .post_form(
1326 &format!("/api/projects/{project_id}/items"),
1327 "title=Second+Track&price_cents=250&item_type=audio",
1328 )
1329 .await;
1330 assert_eq!(resp.status, 200, "create second item failed: {}", resp.text);
1331 let second: Value = resp.json();
1332 let item_b = second["id"].as_str().unwrap().to_string();
1333 h.client
1334 .put_form(&format!("/api/items/{item_b}"), "is_public=true")
1335 .await;
1336 h.client.post_form("/logout", "").await;
1337
1338 let buyer_id = h.signup("cartsum", "cartsum@test.com", "pass1234").await;
1339 h.client.post_form(&format!("/api/cart/{item_a}"), "").await;
1340 h.client.post_form(&format!("/api/cart/{item_b}"), "").await;
1341
1342 let resp = h
1343 .client
1344 .post_form(
1345 "/stripe/checkout/cart",
1346 &format!("seller_id={seller_id}&share_contact=false"),
1347 )
1348 .await;
1349 assert_eq!(
1350 resp.status, 303,
1351 "two-item cart checkout should redirect: {} {}",
1352 resp.status, resp.text
1353 );
1354
1355 let (count, total): (i64, i64) = sqlx::query_as(
1356 "SELECT COUNT(*), COALESCE(SUM(amount_cents), 0) FROM transactions \
1357 WHERE buyer_id = $1 AND status = 'pending'",
1358 )
1359 .bind(buyer_id)
1360 .fetch_one(&h.db)
1361 .await
1362 .unwrap();
1363 assert_eq!(count, 2, "one pending row per paid cart line");
1364 assert_eq!(
1365 total, 750,
1366 "pending amounts must sum to the two items' combined price"
1367 );
1368
1369 // Both rows belong to one Stripe session: the cart is charged once.
1370 let sessions: i64 = sqlx::query_scalar(
1371 "SELECT COUNT(DISTINCT stripe_checkout_session_id) FROM transactions \
1372 WHERE buyer_id = $1 AND status = 'pending'",
1373 )
1374 .bind(buyer_id)
1375 .fetch_one(&h.db)
1376 .await
1377 .unwrap();
1378 assert_eq!(sessions, 1, "a cart checkout is one Stripe session");
1379 }
1380
1381 #[tokio::test]
1382 async fn cart_checkout_empty_cart_rejected() {
1383 let mut h = TestHarness::with_mocks().await;
1384 let (seller_id, _project_id, _item_id) = setup_paid_item(&mut h, 500).await;
1385
1386 let _buyer_id = h
1387 .signup("emptycart", "emptycart@test.com", "pass1234")
1388 .await;
1389
1390 // Don't add anything to cart
1391 let resp = h
1392 .client
1393 .post_form(
1394 "/stripe/checkout/cart",
1395 &format!("seller_id={seller_id}&share_contact=false"),
1396 )
1397 .await;
1398 assert_eq!(
1399 resp.status, 400,
1400 "Empty cart checkout should be rejected: {} {}",
1401 resp.status, resp.text
1402 );
1403 }
1404
1405 #[tokio::test]
1406 async fn cart_checkout_self_purchase_rejected() {
1407 let mut h = TestHarness::with_mocks().await;
1408 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1409
1410 // Create a second user who adds the item to cart
1411 let _buyer_id = h.signup("cartself", "cartself@test.com", "pass1234").await;
1412 h.client
1413 .post_form(&format!("/api/cart/{item_id}"), "")
1414 .await;
1415
1416 // Try to checkout with seller_id = self
1417 h.client.post_form("/logout", "").await;
1418 h.login("seller", "pass1234").await;
1419
1420 // Seller adds own item to cart via DB directly (API blocks it, so simulate)
1421 sqlx::query(
1422 "INSERT INTO cart_items (user_id, item_id) VALUES ($1, $2::uuid) ON CONFLICT DO NOTHING",
1423 )
1424 .bind(seller_id)
1425 .bind(&item_id)
1426 .execute(&h.db)
1427 .await
1428 .unwrap();
1429
1430 let resp = h
1431 .client
1432 .post_form(
1433 "/stripe/checkout/cart",
1434 &format!("seller_id={seller_id}&share_contact=false"),
1435 )
1436 .await;
1437 assert_eq!(
1438 resp.status, 400,
1439 "Self-purchase via cart should be rejected: {} {}",
1440 resp.status, resp.text
1441 );
1442 }
1443
1444 #[tokio::test]
1445 async fn cart_checkout_free_items_claimed_immediately() {
1446 let mut h = TestHarness::with_mocks().await;
1447 let (seller_id, project_id, _item_id) = setup_paid_item(&mut h, 0).await;
1448
1449 // Create a free item
1450 h.login("seller", "pass1234").await;
1451 let resp = h
1452 .client
1453 .post_form(
1454 &format!("/api/projects/{project_id}/items"),
1455 "title=Free+Track&item_type=digital&price_cents=0",
1456 )
1457 .await;
1458 assert_eq!(resp.status, 200, "{}", resp.text);
1459 let free_item: Value = resp.json();
1460 let free_item_id = free_item["id"].as_str().unwrap().to_string();
1461 h.client
1462 .put_form(&format!("/api/items/{free_item_id}"), "is_public=true")
1463 .await;
1464 h.client.post_form("/logout", "").await;
1465
1466 let buyer_id = h.signup("freecart", "freecart@test.com", "pass1234").await;
1467
1468 // Add free item to cart
1469 h.client
1470 .post_form(&format!("/api/cart/{free_item_id}"), "")
1471 .await;
1472
1473 // Cart checkout, free items should be claimed immediately, no Stripe session
1474 let resp = h
1475 .client
1476 .post_form(
1477 "/stripe/checkout/cart",
1478 &format!("seller_id={seller_id}&share_contact=false"),
1479 )
1480 .await;
1481 // Either redirect back (all free, no Stripe needed) or success
1482 assert!(
1483 !resp.status.is_server_error(),
1484 "Free cart checkout failed: {} {}",
1485 resp.status,
1486 resp.text
1487 );
1488
1489 // Verify free item was claimed (completed transaction exists)
1490 let count: i64 = sqlx::query_scalar(
1491 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'",
1492 )
1493 .bind(buyer_id)
1494 .bind(&free_item_id)
1495 .fetch_one(&h.db)
1496 .await
1497 .unwrap();
1498 assert_eq!(count, 1, "Free item should be claimed immediately");
1499
1500 // Verify item removed from cart
1501 let cart_count: i64 = sqlx::query_scalar(
1502 "SELECT COUNT(*) FROM cart_items WHERE user_id = $1 AND item_id = $2::uuid",
1503 )
1504 .bind(buyer_id)
1505 .bind(&free_item_id)
1506 .fetch_one(&h.db)
1507 .await
1508 .unwrap();
1509 assert_eq!(
1510 cart_count, 0,
1511 "Free item should be removed from cart after claim"
1512 );
1513 }
1514
1515 /// A promo applied at cart checkout discounts the pending transaction and
1516 /// reserves exactly one promo use. Exercises the cart core's promo path, which
1517 /// the single-item promo suite (`promo_codes_checkout`) does not cover.
1518 #[tokio::test]
1519 async fn cart_checkout_promo_discount_applied() {
1520 let mut h = TestHarness::with_mocks().await;
1521 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1522
1523 // Seller is logged in after setup; create a 20%-off code.
1524 h.login("seller", "pass1234").await;
1525 let resp = h
1526 .client
1527 .post_form(
1528 "/api/promo-codes",
1529 "code=CART20&code_purpose=discount&discount_type=percentage&discount_value=20",
1530 )
1531 .await;
1532 assert_eq!(
1533 resp.status, 200,
1534 "create promo failed: {} {}",
1535 resp.status, resp.text
1536 );
1537 h.client.post_form("/logout", "").await;
1538
1539 let buyer_id = h
1540 .signup("cartpromo", "cartpromo@test.com", "pass1234")
1541 .await;
1542 h.client
1543 .post_form(&format!("/api/cart/{item_id}"), "")
1544 .await;
1545
1546 let resp = h
1547 .client
1548 .post_form(
1549 "/stripe/checkout/cart",
1550 &format!("seller_id={seller_id}&share_contact=false&promo_code=CART20"),
1551 )
1552 .await;
1553 assert_eq!(
1554 resp.status, 303,
1555 "promo cart checkout should proceed: {} {}",
1556 resp.status, resp.text
1557 );
1558 // 20% off $5.00 = $4.00 pending.
1559 let amount: i32 = sqlx::query_scalar(
1560 "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
1561 )
1562 .bind(buyer_id)
1563 .fetch_one(&h.db)
1564 .await
1565 .unwrap();
1566 assert_eq!(
1567 amount, 400,
1568 "pending amount should be the 20%-discounted price"
1569 );
1570
1571 let use_count: i32 =
1572 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CART20'")
1573 .fetch_one(&h.db)
1574 .await
1575 .unwrap();
1576 assert_eq!(
1577 use_count, 1,
1578 "a completed cart checkout reserves exactly one promo use"
1579 );
1580 }
1581
1582 /// An ALL-FREE cart (a 100%-off promo zeroes every line) must still consume
1583 /// exactly one promo use, and a max_uses-limited code must be exhausted after
1584 /// it. Regression for the audit finding where the free-cart early-return fired
1585 /// before the promo reservation, letting a limited/100%-off code be redeemed
1586 /// unlimited times via cart checkout.
1587 #[tokio::test]
1588 async fn cart_checkout_all_free_promo_consumes_one_use() {
1589 let mut h = TestHarness::with_mocks().await;
1590 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1591
1592 // 100%-off, single-use code (direct SQL, the API form doesn't take max_uses).
1593 sqlx::query(
1594 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
1595 VALUES ($1, 'CARTFREE', 'discount', 'percentage', 100, 0, 1)",
1596 )
1597 .bind(seller_id)
1598 .execute(&h.db)
1599 .await
1600 .unwrap();
1601 h.client.post_form("/logout", "").await;
1602
1603 // First buyer: all-free cart checkout claims the item and burns the one use.
1604 let buyer1 = h
1605 .signup("cartfree1", "cartfree1@test.com", "pass1234")
1606 .await;
1607 h.client
1608 .post_form(&format!("/api/cart/{item_id}"), "")
1609 .await;
1610 let resp = h
1611 .client
1612 .post_form(
1613 "/stripe/checkout/cart",
1614 &format!("seller_id={seller_id}&share_contact=false&promo_code=CARTFREE"),
1615 )
1616 .await;
1617 assert_eq!(
1618 resp.status, 303,
1619 "all-free cart checkout should succeed: {} {}",
1620 resp.status, resp.text
1621 );
1622 // Free claim recorded (a $0 completed transaction), no Stripe session.
1623 let amount: i32 =
1624 sqlx::query_scalar("SELECT amount_cents FROM transactions WHERE buyer_id = $1")
1625 .bind(buyer1)
1626 .fetch_one(&h.db)
1627 .await
1628 .unwrap();
1629 assert_eq!(amount, 0, "all-free cart should record a $0 claim");
1630
1631 let use_count: i32 =
1632 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'")
1633 .fetch_one(&h.db)
1634 .await
1635 .unwrap();
1636 assert_eq!(
1637 use_count, 1,
1638 "an all-free cart checkout must consume exactly one promo use"
1639 );
1640
1641 // Second buyer: the code is now exhausted; the all-free cart must be rejected
1642 // and the use_count must not move past its max.
1643 h.client.post_form("/logout", "").await;
1644 let _buyer2 = h
1645 .signup("cartfree2", "cartfree2@test.com", "pass1234")
1646 .await;
1647 h.client
1648 .post_form(&format!("/api/cart/{item_id}"), "")
1649 .await;
1650 let resp = h
1651 .client
1652 .post_form(
1653 "/stripe/checkout/cart",
1654 &format!("seller_id={seller_id}&share_contact=false&promo_code=CARTFREE"),
1655 )
1656 .await;
1657 assert_eq!(
1658 resp.status.as_u16(),
1659 400,
1660 "exhausted code on an all-free cart should be rejected: {}",
1661 resp.text
1662 );
1663 let use_count: i32 =
1664 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'")
1665 .fetch_one(&h.db)
1666 .await
1667 .unwrap();
1668 assert_eq!(use_count, 1, "exhausted code must not exceed its max_uses");
1669
1670 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1671 assert_eq!(
1672 mock_stripe.checkouts().len(),
1673 0,
1674 "no Stripe session for an all-free cart"
1675 );
1676 }
1677
1678 /// A promo that drops the cart total below the Stripe minimum is rejected and
1679 /// must NOT burn a promo use. Pins the cart core's "reserve only after the
1680 /// min-charge gate" ordering (the gate runs before reservation).
1681 #[tokio::test]
1682 async fn cart_checkout_promo_sub_minimum_not_burned() {
1683 let mut h = TestHarness::with_mocks().await;
1684 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 100).await;
1685
1686 h.login("seller", "pass1234").await;
1687 // $0.70 off $1.00 -> 30¢, below the 50¢ Stripe minimum.
1688 let resp = h
1689 .client
1690 .post_form(
1691 "/api/promo-codes",
1692 "code=CARTTINY&code_purpose=discount&discount_type=fixed&discount_value=70",
1693 )
1694 .await;
1695 assert_eq!(
1696 resp.status, 200,
1697 "create promo failed: {} {}",
1698 resp.status, resp.text
1699 );
1700 h.client.post_form("/logout", "").await;
1701
1702 let buyer_id = h.signup("carttiny", "carttiny@test.com", "pass1234").await;
1703 h.client
1704 .post_form(&format!("/api/cart/{item_id}"), "")
1705 .await;
1706
1707 let resp = h
1708 .client
1709 .post_form(
1710 "/stripe/checkout/cart",
1711 &format!("seller_id={seller_id}&share_contact=false&promo_code=CARTTINY"),
1712 )
1713 .await;
1714 assert_eq!(
1715 resp.status.as_u16(),
1716 400,
1717 "sub-minimum cart total must be rejected: {} {}",
1718 resp.status,
1719 resp.text
1720 );
1721
1722 let pending: i64 = sqlx::query_scalar(
1723 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
1724 )
1725 .bind(buyer_id)
1726 .fetch_one(&h.db)
1727 .await
1728 .unwrap();
1729 assert_eq!(
1730 pending, 0,
1731 "rejected sub-minimum cart checkout must not create a pending row"
1732 );
1733
1734 let use_count: i32 =
1735 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTTINY'")
1736 .fetch_one(&h.db)
1737 .await
1738 .unwrap();
1739 assert_eq!(
1740 use_count, 0,
1741 "promo must not be reserved when the cart is rejected pre-reservation"
1742 );
1743 }
1744
1745 /// Checkout-all across two sellers chains through `drain_to_paid`: it should
1746 /// reach a paid seller and return a Stripe URL, exercising the shared core via
1747 /// the cross-seller entry point.
1748 #[tokio::test]
1749 async fn cart_checkout_all_cross_seller_chain() {
1750 let mut h = TestHarness::with_mocks().await;
1751 let (_seller_a, _proj_a, item_a) = setup_paid_item(&mut h, 500).await;
1752
1753 // Second seller with their own paid item.
1754 let seller_b = h.signup("sellerb", "sellerb@test.com", "pass1234").await;
1755 h.grant_creator(seller_b).await;
1756 sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_b', stripe_charges_enabled = true WHERE id = $1")
1757 .bind(seller_b)
1758 .execute(&h.db)
1759 .await
1760 .unwrap();
1761 h.login("sellerb", "pass1234").await;
1762 let resp = h
1763 .client
1764 .post_form("/api/projects", "slug=shopb&title=ShopB")
1765 .await;
1766 let proj_b: Value = resp.json();
1767 let proj_b_id = proj_b["id"].as_str().unwrap().to_string();
1768 let resp = h
1769 .client
1770 .post_form(
1771 &format!("/api/projects/{proj_b_id}/items"),
1772 "title=TrackB&price_cents=700&item_type=audio",
1773 )
1774 .await;
1775 let item_b: Value = resp.json();
1776 let item_b_id = item_b["id"].as_str().unwrap().to_string();
1777 h.client
1778 .put_form(&format!("/api/projects/{proj_b_id}"), "is_public=true")
1779 .await;
1780 h.client
1781 .put_form(&format!("/api/items/{item_b_id}"), "is_public=true")
1782 .await;
1783 h.client.post_form("/logout", "").await;
1784
1785 let _buyer_id = h.signup("cartall", "cartall@test.com", "pass1234").await;
1786 h.client.post_form(&format!("/api/cart/{item_a}"), "").await;
1787 h.client
1788 .post_form(&format!("/api/cart/{item_b_id}"), "")
1789 .await;
1790
1791 let resp = h
1792 .client
1793 .post_form("/stripe/checkout/cart/all", "share_contact=false")
1794 .await;
1795 assert_eq!(
1796 resp.status, 303,
1797 "checkout-all should reach a paid seller: {} {}",
1798 resp.status, resp.text
1799 );
1800
1801 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1802 assert!(
1803 !mock_stripe.checkouts().is_empty(),
1804 "chain should create at least one checkout session"
1805 );
1806 }
1807
1808 // Subscription checkout
1809
1810 #[tokio::test]
1811 async fn subscription_checkout_creates_session() {
1812 let mut h = TestHarness::with_mocks().await;
1813
1814 // Create a seller with Stripe connected
1815 let seller_id = h
1816 .signup("subseller", "subseller@test.com", "pass1234")
1817 .await;
1818 h.grant_creator(seller_id).await;
1819 sqlx::query(
1820 "UPDATE users SET stripe_account_id = 'acct_mock_sub', stripe_charges_enabled = true WHERE id = $1",
1821 )
1822 .bind(seller_id)
1823 .execute(&h.db)
1824 .await
1825 .unwrap();
1826
1827 // Create a project
1828 h.client.post_form("/logout", "").await;
1829 h.login("subseller", "pass1234").await;
1830 let resp = h
1831 .client
1832 .post_form("/api/projects", "slug=subproj&title=Sub+Project")
1833 .await;
1834 let project: Value = resp.json();
1835 let project_id = project["id"].as_str().unwrap().to_string();
1836 h.client
1837 .put_json(
1838 &format!("/api/projects/{project_id}"),
1839 r#"{"is_public": true}"#,
1840 )
1841 .await;
1842
1843 // Create a subscription tier with fake Stripe IDs
1844 sqlx::query(
1845 r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1846 VALUES ($1::uuid, 'Gold', 999, true, 'prod_mock_gold', 'price_mock_gold')",
1847 )
1848 .bind(&project_id)
1849 .execute(&h.db)
1850 .await
1851 .unwrap();
1852
1853 let tier_id: String =
1854 sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid")
1855 .bind(&project_id)
1856 .fetch_one(&h.db)
1857 .await
1858 .unwrap();
1859
1860 // Log out seller, sign up subscriber
1861 h.client.post_form("/logout", "").await;
1862 let _subscriber_id = h
1863 .signup("subscriber", "subscriber@test.com", "pass1234")
1864 .await;
1865
1866 let resp = h
1867 .client
1868 .post_form(&format!("/stripe/subscribe/{tier_id}"), "")
1869 .await;
1870 assert_eq!(
1871 resp.status, 303,
1872 "Subscription checkout should redirect, got: {} {}",
1873 resp.status, resp.text
1874 );
1875
1876 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1877 assert!(
1878 !mock_stripe.checkouts().is_empty(),
1879 "Should have created a subscription checkout"
1880 );
1881 }
1882
1883 #[tokio::test]
1884 async fn subscription_checkout_self_subscribe_rejected() {
1885 let mut h = TestHarness::with_mocks().await;
1886
1887 let seller_id = h
1888 .signup("selfsubseller", "selfsubseller@test.com", "pass1234")
1889 .await;
1890 h.grant_creator(seller_id).await;
1891 sqlx::query(
1892 "UPDATE users SET stripe_account_id = 'acct_selfsub', stripe_charges_enabled = true WHERE id = $1",
1893 )
1894 .bind(seller_id)
1895 .execute(&h.db)
1896 .await
1897 .unwrap();
1898
1899 h.client.post_form("/logout", "").await;
1900 h.login("selfsubseller", "pass1234").await;
1901
1902 let resp = h
1903 .client
1904 .post_form("/api/projects", "slug=selfsub&title=Self+Sub")
1905 .await;
1906 let project: Value = resp.json();
1907 let project_id = project["id"].as_str().unwrap().to_string();
1908 h.client
1909 .put_json(
1910 &format!("/api/projects/{project_id}"),
1911 r#"{"is_public": true}"#,
1912 )
1913 .await;
1914
1915 sqlx::query(
1916 r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1917 VALUES ($1::uuid, 'Self', 999, true, 'prod_self', 'price_self')",
1918 )
1919 .bind(&project_id)
1920 .execute(&h.db)
1921 .await
1922 .unwrap();
1923
1924 let tier_id: String =
1925 sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid")
1926 .bind(&project_id)
1927 .fetch_one(&h.db)
1928 .await
1929 .unwrap();
1930
1931 // Seller tries to subscribe to own project
1932 let resp = h
1933 .client
1934 .post_form(&format!("/stripe/subscribe/{tier_id}"), "")
1935 .await;
1936 assert_eq!(
1937 resp.status, 400,
1938 "Self-subscription should be rejected: {} {}",
1939 resp.status, resp.text
1940 );
1941 }
1942
1943 #[tokio::test]
1944 async fn subscription_checkout_inactive_tier_rejected() {
1945 let mut h = TestHarness::with_mocks().await;
1946
1947 let seller_id = h
1948 .signup("inactseller", "inactseller@test.com", "pass1234")
1949 .await;
1950 h.grant_creator(seller_id).await;
1951 sqlx::query(
1952 "UPDATE users SET stripe_account_id = 'acct_inact', stripe_charges_enabled = true WHERE id = $1",
1953 )
1954 .bind(seller_id)
1955 .execute(&h.db)
1956 .await
1957 .unwrap();
1958
1959 h.client.post_form("/logout", "").await;
1960 h.login("inactseller", "pass1234").await;
1961
1962 let resp = h
1963 .client
1964 .post_form("/api/projects", "slug=inactproj&title=Inactive")
1965 .await;
1966 let project: Value = resp.json();
1967 let project_id = project["id"].as_str().unwrap().to_string();
1968 h.client
1969 .put_json(
1970 &format!("/api/projects/{project_id}"),
1971 r#"{"is_public": true}"#,
1972 )
1973 .await;
1974
1975 // Create an INACTIVE tier
1976 sqlx::query(
1977 r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1978 VALUES ($1::uuid, 'Archived', 999, false, 'prod_arch', 'price_arch')",
1979 )
1980 .bind(&project_id)
1981 .execute(&h.db)
1982 .await
1983 .unwrap();
1984
1985 let tier_id: String =
1986 sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid")
1987 .bind(&project_id)
1988 .fetch_one(&h.db)
1989 .await
1990 .unwrap();
1991
1992 h.client.post_form("/logout", "").await;
1993 let _sub_id = h.signup("inactsub", "inactsub@test.com", "pass1234").await;
1994
1995 let resp = h
1996 .client
1997 .post_form(&format!("/stripe/subscribe/{tier_id}"), "")
1998 .await;
1999 assert_eq!(
2000 resp.status, 400,
2001 "Inactive tier should be rejected: {} {}",
2002 resp.status, resp.text
2003 );
2004 }
2005
2006 // Creator tier checkout (requires config)
2007
2008 #[tokio::test]
2009 async fn creator_tier_checkout_not_configured_rejected() {
2010 let mut h = TestHarness::with_mocks().await;
2011 let _user_id = h.signup("tierbuy", "tierbuy@test.com", "pass1234").await;
2012
2013 // Config has empty creator_tier_prices, so this should fail with "not configured"
2014 let resp = h
2015 .client
2016 .post_form("/stripe/creator-tier", "tier=small_files")
2017 .await;
2018 assert_eq!(
2019 resp.status, 400,
2020 "Creator tier checkout without config should fail: {} {}",
2021 resp.status, resp.text
2022 );
2023 }
2024
2025 // Subscription single-live-row invariant (ultra-fuzz Run 6, R6-Pay-N1)
2026
2027 /// Post a subscription `checkout.session.completed` webhook with explicit event
2028 /// and Stripe-subscription IDs so two calls aren't deduped as a replay.
2029 async fn post_subscription_webhook(
2030 h: &mut TestHarness,
2031 event_id: &str,
2032 session_id: &str,
2033 stripe_sub_id: &str,
2034 subscriber_id: &str,
2035 project_id: &str,
2036 tier_id: &str,
2037 ) -> crate::harness::client::TestResponse {
2038 let object = serde_json::json!({
2039 "id": session_id,
2040 "subscription": stripe_sub_id,
2041 "customer": "cus_n1_invariant",
2042 "metadata": {
2043 "checkout_type": "subscription",
2044 "subscriber_id": subscriber_id,
2045 "project_id": project_id,
2046 "tier_id": tier_id,
2047 }
2048 });
2049 let payload = serde_json::json!({
2050 "id": event_id,
2051 "type": "checkout.session.completed",
2052 "data": {"object": object},
2053 })
2054 .to_string();
2055 let signature = crate::harness::stripe::sign_webhook_payload(
2056 &payload,
2057 crate::harness::stripe::TEST_WEBHOOK_SECRET,
2058 );
2059 h.client
2060 .request_with_headers(
2061 "POST",
2062 "/stripe/webhook",
2063 Some(&payload),
2064 &[
2065 ("stripe-signature", &signature),
2066 ("content-type", "application/json"),
2067 ],
2068 )
2069 .await
2070 }
2071
2072 /// A resubscribe over a lingering `past_due` row must leave exactly one live
2073 /// (active) subscription: the stale row is canceled by `create_subscription`'s
2074 /// pre-insert cleanup, not left to coexist with the new active row.
2075 #[tokio::test]
2076 async fn subscription_resubscribe_after_past_due_leaves_one_live_row() {
2077 let mut h = TestHarness::with_mocks().await;
2078
2079 let seller_id = h.signup("n1seller", "n1seller@test.com", "pass1234").await;
2080 h.grant_creator(seller_id).await;
2081 sqlx::query(
2082 "UPDATE users SET stripe_account_id = 'acct_n1', stripe_charges_enabled = true WHERE id = $1",
2083 )
2084 .bind(seller_id)
2085 .execute(&h.db)
2086 .await
2087 .unwrap();
2088
2089 h.client.post_form("/logout", "").await;
2090 h.login("n1seller", "pass1234").await;
2091 let resp = h
2092 .client
2093 .post_form("/api/projects", "slug=n1proj&title=N1+Project")
2094 .await;
2095 let project: Value = resp.json();
2096 let project_id = project["id"].as_str().unwrap().to_string();
2097 h.client
2098 .put_json(
2099 &format!("/api/projects/{project_id}"),
2100 r#"{"is_public": true}"#,
2101 )
2102 .await;
2103 sqlx::query(
2104 r"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
2105 VALUES ($1::uuid, 'Gold', 999, true, 'prod_n1', 'price_n1')",
2106 )
2107 .bind(&project_id)
2108 .execute(&h.db)
2109 .await
2110 .unwrap();
2111 let tier_id: String =
2112 sqlx::query_scalar("SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid")
2113 .bind(&project_id)
2114 .fetch_one(&h.db)
2115 .await
2116 .unwrap();
2117
2118 h.client.post_form("/logout", "").await;
2119 let subscriber_id = h.signup("n1fan", "n1fan@test.com", "pass1234").await;
2120 let subscriber_str = subscriber_id.to_string();
2121
2122 // First subscription → one active row.
2123 let r1 = post_subscription_webhook(
2124 &mut h,
2125 "evt_n1_1",
2126 "cs_n1_1",
2127 "sub_n1_1",
2128 &subscriber_str,
2129 &project_id,
2130 &tier_id,
2131 )
2132 .await;
2133 assert_eq!(
2134 r1.status, 200,
2135 "first sub webhook: {} {}",
2136 r1.status, r1.text
2137 );
2138
2139 // Stripe dunning leaves the row past_due (the gate ignores it, so the user
2140 // appears unsubscribed and can resubscribe).
2141 sqlx::query(
2142 "UPDATE subscriptions SET status = 'past_due' WHERE stripe_subscription_id = 'sub_n1_1'",
2143 )
2144 .execute(&h.db)
2145 .await
2146 .unwrap();
2147
2148 // Resubscribe: a brand-new Stripe subscription for the same (subscriber, project).
2149 let r2 = post_subscription_webhook(
2150 &mut h,
2151 "evt_n1_2",
2152 "cs_n1_2",
2153 "sub_n1_2",
2154 &subscriber_str,
2155 &project_id,
2156 &tier_id,
2157 )
2158 .await;
2159 assert_eq!(
2160 r2.status, 200,
2161 "second sub webhook: {} {}",
2162 r2.status, r2.text
2163 );
2164
2165 let active_count: i64 = sqlx::query_scalar(
2166 "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2::uuid AND status = 'active'",
2167 )
2168 .bind(subscriber_id)
2169 .bind(&project_id)
2170 .fetch_one(&h.db)
2171 .await
2172 .unwrap();
2173 assert_eq!(
2174 active_count, 1,
2175 "exactly one active subscription after resubscribe"
2176 );
2177
2178 let old_status: String = sqlx::query_scalar(
2179 "SELECT status FROM subscriptions WHERE stripe_subscription_id = 'sub_n1_1'",
2180 )
2181 .fetch_one(&h.db)
2182 .await
2183 .unwrap();
2184 assert_eq!(
2185 old_status, "canceled",
2186 "stale past_due row should be canceled"
2187 );
2188
2189 let total: i64 = sqlx::query_scalar(
2190 "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2::uuid",
2191 )
2192 .bind(subscriber_id)
2193 .bind(&project_id)
2194 .fetch_one(&h.db)
2195 .await
2196 .unwrap();
2197 assert_eq!(total, 2, "old (canceled) + new (active)");
2198 }
2199