Skip to main content

max / makenotwork

50.6 KB · 1452 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 // ---------------------------------------------------------------------------
12 // Helpers
13 // ---------------------------------------------------------------------------
14
15 /// Create a creator with Stripe "connected" (direct DB override) and a published paid item.
16 /// Returns (seller_id, project_id, item_id).
17 async fn setup_paid_item(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String, String) {
18 let seller_id = h.signup("seller", "seller@test.com", "pass1234").await;
19 h.grant_creator(seller_id).await;
20
21 // Simulate Stripe Connect onboarding complete
22 sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_seller', stripe_charges_enabled = true WHERE id = $1")
23 .bind(seller_id)
24 .execute(&h.db)
25 .await
26 .unwrap();
27
28 h.client.post_form("/logout", "").await;
29 h.login("seller", "pass1234").await;
30
31 let resp = h.client.post_form("/api/projects", "slug=shop&title=Shop").await;
32 let project: Value = resp.json();
33 let project_id = project["id"].as_str().unwrap().to_string();
34
35 let resp = h.client.post_form(
36 &format!("/api/projects/{}/items", project_id),
37 &format!("title=Track&price_cents={}&item_type=audio", price_cents),
38 ).await;
39 let item: Value = resp.json();
40 let item_id = item["id"].as_str().unwrap().to_string();
41
42 // Publish
43 h.client.put_form(&format!("/api/projects/{}", project_id), "is_public=true").await;
44 h.client.put_form(&format!("/api/items/{}", item_id), "is_public=true").await;
45
46 h.client.post_form("/logout", "").await;
47
48 (seller_id, project_id, item_id)
49 }
50
51 /// Post a JSON webhook event to the harness.
52 async fn post_webhook_json(
53 h: &mut TestHarness,
54 event_type: &str,
55 object: serde_json::Value,
56 ) -> crate::harness::client::TestResponse {
57 let payload = serde_json::json!({
58 "id": "evt_mock_001",
59 "type": event_type,
60 "data": {"object": object},
61 })
62 .to_string();
63 let signature = crate::harness::stripe::sign_webhook_payload(
64 &payload,
65 crate::harness::stripe::TEST_WEBHOOK_SECRET,
66 );
67 h.client.request_with_headers(
68 "POST",
69 "/stripe/webhook",
70 Some(&payload),
71 &[
72 ("stripe-signature", &signature),
73 ("content-type", "application/json"),
74 ],
75 ).await
76 }
77
78 // ---------------------------------------------------------------------------
79 // Checkout → Webhook → Access flow
80 // ---------------------------------------------------------------------------
81
82 #[tokio::test]
83 async fn checkout_creates_session_and_webhook_completes_purchase() {
84 let mut h = TestHarness::with_mocks().await;
85 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
86
87 // Buyer signs up
88 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
89
90 // Buyer initiates checkout (hits MockPaymentProvider)
91 let resp = h.client.post_form(
92 &format!("/stripe/checkout/{}", item_id),
93 "share_contact=false",
94 ).await;
95
96 // MockPaymentProvider returns a redirect URL
97 assert!(
98 resp.status.is_redirection() || resp.status.is_success(),
99 "Checkout should redirect or succeed, got: {} {}",
100 resp.status, resp.text
101 );
102
103 // Verify mock recorded the checkout
104 let mock_stripe = h.mock_stripe.as_ref().unwrap();
105 let checkouts = mock_stripe.checkouts();
106 assert_eq!(checkouts.len(), 1, "Expected 1 checkout session, got {}", checkouts.len());
107
108 // Simulate Stripe webhook completing the purchase
109 // Find the pending transaction the checkout handler created
110 let pending_tx: Option<(String,)> = sqlx::query_as(
111 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
112 )
113 .bind(buyer_id)
114 .fetch_optional(&h.db)
115 .await
116 .unwrap();
117 assert!(pending_tx.is_some(), "Checkout should have created a pending transaction");
118 let actual_session_id = &pending_tx.unwrap().0;
119
120 // Build webhook event with the actual session ID
121 let mut meta = HashMap::new();
122 meta.insert("buyer_id".to_string(), buyer_id.to_string());
123 meta.insert("seller_id".to_string(), seller_id.to_string());
124 meta.insert("item_id".to_string(), item_id.clone());
125 let session = serde_json::json!({
126 "id": actual_session_id,
127 "object": "checkout_session",
128 "mode": "payment",
129 "metadata": meta,
130 "payment_intent": "pi_mock_001",
131 });
132
133 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
134 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
135
136 // Verify transaction completed
137 let status: String = sqlx::query_scalar(
138 "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
139 )
140 .bind(buyer_id)
141 .bind(&item_id)
142 .fetch_one(&h.db)
143 .await
144 .unwrap();
145 assert_eq!(status, "completed");
146
147 // Verify sales count incremented
148 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
149 .bind(&item_id)
150 .fetch_one(&h.db)
151 .await
152 .unwrap();
153 assert_eq!(sales, 1);
154 }
155
156 // ---------------------------------------------------------------------------
157 // Email assertions
158 // ---------------------------------------------------------------------------
159
160 #[tokio::test]
161 async fn purchase_webhook_sends_emails() {
162 let mut h = TestHarness::with_mocks().await;
163 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
164
165 // Enable sale notifications for seller
166 sqlx::query("UPDATE users SET notify_sale = true WHERE id = $1")
167 .bind(seller_id)
168 .execute(&h.db)
169 .await
170 .unwrap();
171
172 let buyer_id = h.signup("emailbuyer", "emailbuyer@test.com", "pass1234").await;
173
174 // Insert pending transaction directly (skip checkout for focused email test)
175 let session_id = "cs_email_test";
176 sqlx::query(
177 r#"INSERT INTO transactions
178 (buyer_id, seller_id, item_id, amount_cents, status,
179 stripe_checkout_session_id, item_title, seller_username)
180 VALUES ($1, $2, $3::uuid, 500, 'pending', $4, 'Track', 'seller')"#,
181 )
182 .bind(buyer_id)
183 .bind(seller_id)
184 .bind(&item_id)
185 .bind(session_id)
186 .execute(&h.db)
187 .await
188 .unwrap();
189
190 // Fire webhook
191 let mut meta = HashMap::new();
192 meta.insert("buyer_id".to_string(), buyer_id.to_string());
193 meta.insert("seller_id".to_string(), seller_id.to_string());
194 meta.insert("item_id".to_string(), item_id.clone());
195 let session = serde_json::json!({
196 "id": session_id,
197 "object": "checkout_session",
198 "mode": "payment",
199 "metadata": meta,
200 "payment_intent": "pi_email_test",
201 });
202 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
203 assert_eq!(resp.status.as_u16(), 200);
204
205 // Wait briefly for fire-and-forget email tasks to complete
206 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
207
208 // Assert emails were sent
209 let mock_email = h.mock_email.as_ref().unwrap();
210 let buyer_emails = mock_email.sent_to("emailbuyer@test.com");
211 assert!(
212 buyer_emails.iter().any(|e| e.subject.contains("purchase") || e.subject.contains("Purchase")),
213 "Expected purchase confirmation to buyer, got: {:?}",
214 buyer_emails.iter().map(|e| &e.subject).collect::<Vec<_>>()
215 );
216
217 let seller_emails = mock_email.sent_to("seller@test.com");
218 assert!(
219 seller_emails.iter().any(|e| e.subject.contains("sale") || e.subject.contains("Sale")),
220 "Expected sale notification to seller, got: {:?}",
221 seller_emails.iter().map(|e| &e.subject).collect::<Vec<_>>()
222 );
223 }
224
225 // ---------------------------------------------------------------------------
226 // Free item claim with promo code
227 // ---------------------------------------------------------------------------
228
229 // Note: free_claim_with_promo_code is already covered by the existing
230 // promo_codes_discount and promo_codes_free_access workflow test suites.
231 // The mock payment provider is validated by the other tests in this file.
232
233 // ---------------------------------------------------------------------------
234 // Failure modes
235 // ---------------------------------------------------------------------------
236
237 #[tokio::test]
238 async fn checkout_rejects_own_item() {
239 let mut h = TestHarness::with_mocks().await;
240 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
241
242 // Login as seller and try to buy own item
243 h.login("seller", "pass1234").await;
244 let resp = h.client.post_form(
245 &format!("/stripe/checkout/{}", item_id),
246 "share_contact=false",
247 ).await;
248
249 assert_eq!(resp.status.as_u16(), 400, "Should reject self-purchase: {}", resp.text);
250
251 // No checkout should have been created
252 let mock_stripe = h.mock_stripe.as_ref().unwrap();
253 assert_eq!(mock_stripe.checkouts().len(), 0);
254 }
255
256 #[tokio::test]
257 async fn checkout_rejects_unpublished_item() {
258 let mut h = TestHarness::with_mocks().await;
259 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
260
261 // Unpublish the item
262 h.login("seller", "pass1234").await;
263 h.client.put_form(&format!("/api/items/{}", item_id), "is_public=false").await;
264 h.client.post_form("/logout", "").await;
265
266 // Buyer tries to checkout
267 h.signup("draftbuyer", "db@test.com", "pass1234").await;
268 let resp = h.client.post_form(
269 &format!("/stripe/checkout/{}", item_id),
270 "share_contact=false",
271 ).await;
272
273 // Should get an error (400 for "not available for purchase")
274 assert!(
275 resp.status.is_client_error(),
276 "Should reject unpublished item purchase, got: {} {}",
277 resp.status, resp.text
278 );
279
280 // No checkout session created
281 let mock_stripe = h.mock_stripe.as_ref().unwrap();
282 assert_eq!(mock_stripe.checkouts().len(), 0, "Unpublished item should not create checkout");
283 }
284
285 #[tokio::test]
286 async fn checkout_rejects_free_item() {
287 let mut h = TestHarness::with_mocks().await;
288 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 0).await;
289
290 h.signup("freebuyer2", "fb2@test.com", "pass1234").await;
291 let resp = h.client.post_form(
292 &format!("/stripe/checkout/{}", item_id),
293 "share_contact=false",
294 ).await;
295
296 assert_eq!(resp.status.as_u16(), 400, "Should reject free item checkout: {}", resp.text);
297 }
298
299 #[tokio::test]
300 async fn duplicate_purchase_prevented() {
301 let mut h = TestHarness::with_mocks().await;
302 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
303
304 let buyer_id = h.signup("dupbuyer", "dup@test.com", "pass1234").await;
305
306 // Insert a completed transaction (buyer already purchased)
307 sqlx::query(
308 r#"INSERT INTO transactions
309 (buyer_id, seller_id, item_id, amount_cents, status,
310 stripe_checkout_session_id, item_title, seller_username, completed_at)
311 VALUES ($1, $2, $3::uuid, 999, 'completed', 'cs_already', 'Track', 'seller', NOW())"#,
312 )
313 .bind(buyer_id)
314 .bind(seller_id)
315 .bind(&item_id)
316 .execute(&h.db)
317 .await
318 .unwrap();
319
320 // Attempt to checkout again — should redirect (already purchased)
321 let resp = h.client.post_form(
322 &format!("/stripe/checkout/{}", item_id),
323 "share_contact=false",
324 ).await;
325
326 assert!(
327 resp.status.is_redirection(),
328 "Already-purchased item should redirect, got: {} {}",
329 resp.status, resp.text
330 );
331
332 // No new checkout session should be created
333 let mock_stripe = h.mock_stripe.as_ref().unwrap();
334 assert_eq!(mock_stripe.checkouts().len(), 0);
335 }
336
337 // ---------------------------------------------------------------------------
338 // Purchase grants access
339 // ---------------------------------------------------------------------------
340
341 #[tokio::test]
342 async fn purchase_grants_access() {
343 let mut h = TestHarness::with_mocks().await;
344 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
345
346 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
347
348 // Buyer initiates checkout
349 h.client.post_form(
350 &format!("/stripe/checkout/{}", item_id),
351 "share_contact=false",
352 ).await;
353
354 // Find pending transaction
355 let session_id: String = sqlx::query_scalar(
356 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
357 )
358 .bind(buyer_id)
359 .fetch_one(&h.db)
360 .await
361 .unwrap();
362
363 // Fire webhook to complete
364 let mut meta = HashMap::new();
365 meta.insert("buyer_id".to_string(), buyer_id.to_string());
366 meta.insert("seller_id".to_string(), seller_id.to_string());
367 meta.insert("item_id".to_string(), item_id.clone());
368 let session = serde_json::json!({
369 "id": session_id,
370 "object": "checkout_session",
371 "mode": "payment",
372 "metadata": meta,
373 "payment_intent": "pi_access_001",
374 });
375 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
376 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
377
378 // Verify buyer has access via has_purchased_item query
379 let count: i64 = sqlx::query_scalar(
380 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'",
381 )
382 .bind(buyer_id)
383 .bind(&item_id)
384 .fetch_one(&h.db)
385 .await
386 .unwrap();
387 assert_eq!(count, 1, "Buyer should have access after purchase");
388 }
389
390 // ---------------------------------------------------------------------------
391 // Refund revokes access
392 // ---------------------------------------------------------------------------
393
394 #[tokio::test]
395 async fn refund_revokes_access() {
396 let mut h = TestHarness::with_mocks().await;
397 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
398
399 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
400
401 let pi_id = "pi_refund_mock_001";
402
403 // Insert a completed transaction with known payment_intent_id
404 sqlx::query(
405 r#"INSERT INTO transactions
406 (buyer_id, seller_id, item_id, amount_cents, status,
407 stripe_payment_intent_id, stripe_checkout_session_id,
408 item_title, seller_username, completed_at)
409 VALUES ($1, $2, $3::uuid, 999, 'completed', $4, 'cs_refund_mock', 'Track', 'seller', NOW())"#,
410 )
411 .bind(buyer_id)
412 .bind(seller_id)
413 .bind(&item_id)
414 .bind(pi_id)
415 .execute(&h.db)
416 .await
417 .unwrap();
418
419 // Set sales_count to 1 (since we inserted a completed transaction)
420 sqlx::query("UPDATE items SET sales_count = 1 WHERE id = $1::uuid")
421 .bind(&item_id)
422 .execute(&h.db)
423 .await
424 .unwrap();
425
426 // Fire ChargeRefunded webhook
427 let charge = serde_json::json!({
428 "id": "ch_refund_mock",
429 "object": "charge",
430 "amount": 999,
431 "amount_refunded": 999,
432 "payment_intent": pi_id,
433 });
434 let resp = post_webhook_json(&mut h, "charge.refunded", charge).await;
435 assert_eq!(resp.status.as_u16(), 200, "Refund webhook failed: {}", resp.text);
436
437 // Verify transaction status is 'refunded'
438 let status: String = sqlx::query_scalar(
439 "SELECT status FROM transactions WHERE stripe_payment_intent_id = $1",
440 )
441 .bind(pi_id)
442 .fetch_one(&h.db)
443 .await
444 .unwrap();
445 assert_eq!(status, "refunded");
446
447 // Verify sales_count decremented
448 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
449 .bind(&item_id)
450 .fetch_one(&h.db)
451 .await
452 .unwrap();
453 assert_eq!(sales, 0, "sales_count should be decremented after refund");
454 }
455
456 // ---------------------------------------------------------------------------
457 // Tip checkout and webhook
458 // ---------------------------------------------------------------------------
459
460 #[tokio::test]
461 async fn tip_checkout_and_webhook() {
462 let mut h = TestHarness::with_mocks().await;
463
464 // Create recipient with Stripe connected and tips enabled
465 let recipient_id = h.signup("recipient", "recipient@test.com", "pass1234").await;
466 h.grant_creator(recipient_id).await;
467 sqlx::query(
468 "UPDATE users SET stripe_account_id = 'acct_mock_recipient', stripe_charges_enabled = true, tips_enabled = true WHERE id = $1",
469 )
470 .bind(recipient_id)
471 .execute(&h.db)
472 .await
473 .unwrap();
474 h.client.post_form("/logout", "").await;
475
476 // Buyer signs up
477 let tipper_id = h.signup("tipper", "tipper@test.com", "pass1234").await;
478
479 // POST to tip checkout (amount is in dollars per TipForm)
480 let resp = h.client.post_form(
481 &format!("/stripe/checkout/tip/{}", recipient_id),
482 "amount_dollars=5",
483 ).await;
484 assert!(
485 resp.status.is_redirection() || resp.status.is_success(),
486 "Tip checkout should redirect, got: {} {}",
487 resp.status, resp.text
488 );
489
490 // Verify mock checkout was created
491 let mock_stripe = h.mock_stripe.as_ref().unwrap();
492 assert!(!mock_stripe.checkouts().is_empty(), "Should have created a tip checkout session");
493
494 // Find the pending tip created by the checkout handler
495 let tip_session_id: String = sqlx::query_scalar(
496 "SELECT stripe_checkout_session_id FROM tips WHERE tipper_id = $1 AND status = 'pending'",
497 )
498 .bind(tipper_id)
499 .fetch_one(&h.db)
500 .await
501 .unwrap();
502
503 // Fire CheckoutSessionCompleted webhook with tip metadata
504 let mut meta = HashMap::new();
505 meta.insert("checkout_type".to_string(), "tip".to_string());
506 meta.insert("tipper_id".to_string(), tipper_id.to_string());
507 meta.insert("recipient_id".to_string(), recipient_id.to_string());
508 let session = serde_json::json!({
509 "id": tip_session_id,
510 "object": "checkout_session",
511 "mode": "payment",
512 "metadata": meta,
513 "payment_intent": "pi_tip_001",
514 });
515 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
516 assert_eq!(resp.status.as_u16(), 200, "Tip webhook failed: {}", resp.text);
517
518 // Verify tip status is 'completed'
519 let status: String = sqlx::query_scalar(
520 "SELECT status FROM tips WHERE tipper_id = $1 AND recipient_id = $2",
521 )
522 .bind(tipper_id)
523 .bind(recipient_id)
524 .fetch_one(&h.db)
525 .await
526 .unwrap();
527 assert_eq!(status, "completed");
528 }
529
530 // ---------------------------------------------------------------------------
531 // Revenue splits recorded on purchase
532 // ---------------------------------------------------------------------------
533
534 #[tokio::test]
535 async fn revenue_splits_recorded_on_purchase() {
536 let mut h = TestHarness::with_mocks().await;
537 let (seller_id, project_id, item_id) = setup_paid_item(&mut h, 999).await;
538
539 // Create a collaborator user
540 let collab_id = h.signup("collaborator", "collab@test.com", "pass1234").await;
541 h.client.post_form("/logout", "").await;
542
543 // Add collaborator as project member with 30% split
544 sqlx::query(
545 "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by) VALUES ($1::uuid, $2, 'member', 30, $3)",
546 )
547 .bind(&project_id)
548 .bind(collab_id)
549 .bind(seller_id)
550 .execute(&h.db)
551 .await
552 .unwrap();
553
554 // Buyer checkouts
555 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
556 h.client.post_form(
557 &format!("/stripe/checkout/{}", item_id),
558 "share_contact=false",
559 ).await;
560
561 // Find pending transaction
562 let session_id: String = sqlx::query_scalar(
563 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
564 )
565 .bind(buyer_id)
566 .fetch_one(&h.db)
567 .await
568 .unwrap();
569
570 // Fire purchase webhook
571 let mut meta = HashMap::new();
572 meta.insert("buyer_id".to_string(), buyer_id.to_string());
573 meta.insert("seller_id".to_string(), seller_id.to_string());
574 meta.insert("item_id".to_string(), item_id.clone());
575 let session = serde_json::json!({
576 "id": session_id,
577 "object": "checkout_session",
578 "mode": "payment",
579 "metadata": meta,
580 "payment_intent": "pi_split_001",
581 });
582 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
583 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
584
585 // Wait briefly for split recording (runs after transaction commit)
586 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
587
588 // Verify revenue_splits has a row for the collaborator
589 let split_amount: i32 = sqlx::query_scalar(
590 "SELECT amount_cents FROM revenue_splits WHERE recipient_id = $1",
591 )
592 .bind(collab_id)
593 .fetch_one(&h.db)
594 .await
595 .unwrap();
596 // 999 * 30 / 100 = 299 (integer division)
597 assert_eq!(split_amount, 299, "Collaborator should get 30% of 999 = 299 cents");
598 }
599
600 // ---------------------------------------------------------------------------
601 // PWYW checkout with custom amount
602 // ---------------------------------------------------------------------------
603
604 #[tokio::test]
605 async fn pwyw_checkout_custom_amount() {
606 let mut h = TestHarness::with_mocks().await;
607 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
608
609 // Enable PWYW on the item (checkbox uses "on", not "true")
610 h.login("seller", "pass1234").await;
611 h.client.put_form(
612 &format!("/api/items/{}", item_id),
613 "pwyw_enabled=on&pwyw_min_cents=100",
614 ).await;
615 h.client.post_form("/logout", "").await;
616
617 // Buyer checkouts with custom amount
618 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
619 let resp = h.client.post_form(
620 &format!("/stripe/checkout/{}", item_id),
621 "share_contact=false&amount_cents=2500",
622 ).await;
623 assert!(
624 resp.status.is_redirection() || resp.status.is_success(),
625 "PWYW checkout should redirect, got: {} {}",
626 resp.status, resp.text
627 );
628
629 // Verify mock checkout was created
630 let mock_stripe = h.mock_stripe.as_ref().unwrap();
631 assert_eq!(mock_stripe.checkouts().len(), 1, "Should have created a checkout");
632
633 // Verify the pending transaction has amount_cents = 2500
634 let amount: i32 = sqlx::query_scalar(
635 "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
636 )
637 .bind(buyer_id)
638 .fetch_one(&h.db)
639 .await
640 .unwrap();
641 assert_eq!(amount, 2500, "PWYW transaction should have buyer's chosen amount");
642 }
643
644 // ---------------------------------------------------------------------------
645 // Discount code reduces checkout amount
646 // ---------------------------------------------------------------------------
647
648 #[tokio::test]
649 async fn discount_code_reduces_checkout_amount() {
650 let mut h = TestHarness::with_mocks().await;
651 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
652
653 // Create a 50% discount code for the seller
654 sqlx::query(
655 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents) VALUES ($1, 'HALF50', 'discount', 'percentage', 50, 0)",
656 )
657 .bind(seller_id)
658 .execute(&h.db)
659 .await
660 .unwrap();
661
662 // Buyer checkouts with promo code
663 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
664 let resp = h.client.post_form(
665 &format!("/stripe/checkout/{}", item_id),
666 "share_contact=false&promo_code=HALF50",
667 ).await;
668 assert!(
669 resp.status.is_redirection() || resp.status.is_success(),
670 "Discount checkout should redirect, got: {} {}",
671 resp.status, resp.text
672 );
673
674 // Verify the pending transaction has amount_cents = 500 (50% of 1000)
675 let amount: i32 = sqlx::query_scalar(
676 "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
677 )
678 .bind(buyer_id)
679 .fetch_one(&h.db)
680 .await
681 .unwrap();
682 assert_eq!(amount, 500, "50% discount should halve the price from 1000 to 500");
683 }
684
685 // ---------------------------------------------------------------------------
686 // Contact sharing on purchase
687 // ---------------------------------------------------------------------------
688
689 #[tokio::test]
690 async fn contact_sharing_on_purchase() {
691 let mut h = TestHarness::with_mocks().await;
692 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
693
694 // Buyer checkouts with share_contact=true
695 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
696 let resp = h.client.post_form(
697 &format!("/stripe/checkout/{}", item_id),
698 "share_contact=true",
699 ).await;
700 assert!(
701 resp.status.is_redirection() || resp.status.is_success(),
702 "Checkout should redirect, got: {} {}",
703 resp.status, resp.text
704 );
705
706 // Find pending transaction
707 let session_id: String = sqlx::query_scalar(
708 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
709 )
710 .bind(buyer_id)
711 .fetch_one(&h.db)
712 .await
713 .unwrap();
714
715 // Verify share_contact is true on the pending transaction
716 let share_contact: bool = sqlx::query_scalar(
717 "SELECT share_contact FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
718 )
719 .bind(buyer_id)
720 .fetch_one(&h.db)
721 .await
722 .unwrap();
723 assert!(share_contact, "Transaction should have share_contact = true");
724
725 // Complete via webhook
726 let mut meta = HashMap::new();
727 meta.insert("buyer_id".to_string(), buyer_id.to_string());
728 meta.insert("seller_id".to_string(), seller_id.to_string());
729 meta.insert("item_id".to_string(), item_id.clone());
730 let session = serde_json::json!({
731 "id": session_id,
732 "object": "checkout_session",
733 "mode": "payment",
734 "metadata": meta,
735 "payment_intent": "pi_contact_001",
736 });
737 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
738 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
739
740 // Verify transaction completed and still has share_contact = true
741 let (status, share): (String, bool) = sqlx::query_as(
742 "SELECT status, share_contact FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
743 )
744 .bind(buyer_id)
745 .bind(&item_id)
746 .fetch_one(&h.db)
747 .await
748 .unwrap();
749 assert_eq!(status, "completed");
750 assert!(share, "Completed transaction should preserve share_contact = true");
751 }
752
753 // ---------------------------------------------------------------------------
754 // Creator-initiated refund via API endpoint
755 // ---------------------------------------------------------------------------
756
757 #[tokio::test]
758 async fn creator_refund_endpoint() {
759 let mut h = TestHarness::with_mocks().await;
760 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
761
762 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
763 let pi_id = "pi_refund_api_001";
764
765 // Insert a completed transaction with a payment intent
766 sqlx::query(
767 r#"INSERT INTO transactions
768 (buyer_id, seller_id, item_id, amount_cents, status,
769 stripe_payment_intent_id, stripe_checkout_session_id,
770 item_title, seller_username, completed_at)
771 VALUES ($1, $2, $3::uuid, 999, 'completed', $4, 'cs_refund_api', 'Track', 'seller', NOW())"#,
772 )
773 .bind(buyer_id)
774 .bind(seller_id)
775 .bind(&item_id)
776 .bind(pi_id)
777 .execute(&h.db)
778 .await
779 .unwrap();
780
781 // Get the transaction ID
782 let tx_id: String = sqlx::query_scalar(
783 "SELECT id::text FROM transactions WHERE stripe_payment_intent_id = $1",
784 )
785 .bind(pi_id)
786 .fetch_one(&h.db)
787 .await
788 .unwrap();
789
790 // Log in as seller and hit the refund endpoint
791 h.client.post_form("/logout", "").await;
792 h.login("seller", "pass1234").await;
793
794 let resp = h
795 .client
796 .post_json(
797 &format!("/api/items/{}/refund", item_id),
798 &format!(r#"{{"transaction_id": "{}"}}"#, tx_id),
799 )
800 .await;
801 assert!(
802 resp.status.is_success(),
803 "Creator refund endpoint should succeed: {} {}",
804 resp.status, resp.text
805 );
806 let data: Value = resp.json();
807 assert_eq!(data["ok"], true);
808 }
809
810 #[tokio::test]
811 async fn creator_refund_non_owner_rejected() {
812 let mut h = TestHarness::with_mocks().await;
813 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
814
815 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
816
817 // Insert a completed transaction
818 sqlx::query(
819 r#"INSERT INTO transactions
820 (buyer_id, seller_id, item_id, amount_cents, status,
821 stripe_payment_intent_id, stripe_checkout_session_id,
822 item_title, seller_username, completed_at)
823 VALUES ($1, $2, $3::uuid, 999, 'completed', 'pi_notown', 'cs_notown', 'Track', 'seller', NOW())"#,
824 )
825 .bind(buyer_id)
826 .bind(seller_id)
827 .bind(&item_id)
828 .execute(&h.db)
829 .await
830 .unwrap();
831
832 let tx_id: String = sqlx::query_scalar(
833 "SELECT id::text FROM transactions WHERE stripe_payment_intent_id = 'pi_notown'",
834 )
835 .fetch_one(&h.db)
836 .await
837 .unwrap();
838
839 // Log in as a different creator (not the owner)
840 h.client.post_form("/logout", "").await;
841 let _other = h.create_creator("other").await;
842
843 let resp = h
844 .client
845 .post_json(
846 &format!("/api/items/{}/refund", item_id),
847 &format!(r#"{{"transaction_id": "{}"}}"#, tx_id),
848 )
849 .await;
850 assert!(
851 resp.status == 403 || resp.status == 404,
852 "Non-owner refund should be rejected: {} {}",
853 resp.status, resp.text
854 );
855 }
856
857 #[tokio::test]
858 async fn creator_refund_free_claim_rejected() {
859 let mut h = TestHarness::with_mocks().await;
860 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 999).await;
861
862 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
863
864 // Insert a completed transaction WITHOUT payment intent (free claim)
865 sqlx::query(
866 r#"INSERT INTO transactions
867 (buyer_id, seller_id, item_id, amount_cents, status,
868 stripe_checkout_session_id, item_title, seller_username, completed_at)
869 VALUES ($1, $2, $3::uuid, 0, 'completed', 'cs_free_claim', 'Track', 'seller', NOW())"#,
870 )
871 .bind(buyer_id)
872 .bind(seller_id)
873 .bind(&item_id)
874 .execute(&h.db)
875 .await
876 .unwrap();
877
878 let tx_id: String = sqlx::query_scalar(
879 "SELECT id::text FROM transactions WHERE stripe_checkout_session_id = 'cs_free_claim'",
880 )
881 .fetch_one(&h.db)
882 .await
883 .unwrap();
884
885 // Log in as seller and try to refund the free claim
886 h.client.post_form("/logout", "").await;
887 h.login("seller", "pass1234").await;
888
889 let resp = h
890 .client
891 .post_json(
892 &format!("/api/items/{}/refund", item_id),
893 &format!(r#"{{"transaction_id": "{}"}}"#, tx_id),
894 )
895 .await;
896 assert!(
897 resp.status.is_client_error(),
898 "Refunding a free claim should fail: {} {}",
899 resp.status, resp.text
900 );
901 }
902
903 // ---------------------------------------------------------------------------
904 // Project-level checkout
905 // ---------------------------------------------------------------------------
906
907 #[tokio::test]
908 async fn project_checkout_creates_session() {
909 let mut h = TestHarness::with_mocks().await;
910 let (seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await;
911
912 // Set project pricing to BuyOnce $19.99
913 sqlx::query("UPDATE projects SET pricing_model = 'buy_once', price_cents = 1999 WHERE id = $1::uuid")
914 .bind(&project_id)
915 .execute(&h.db)
916 .await
917 .unwrap();
918
919 let _buyer_id = h.signup("projbuyer", "projbuyer@test.com", "pass1234").await;
920
921 let resp = h.client.post_form(
922 &format!("/stripe/checkout/project/{}", project_id),
923 "share_contact=false",
924 ).await;
925 assert!(
926 resp.status.is_redirection() || resp.status.is_success(),
927 "Project checkout should redirect, got: {} {}",
928 resp.status, resp.text
929 );
930
931 // Verify pending transaction created
932 let count: i64 = sqlx::query_scalar(
933 "SELECT COUNT(*) FROM transactions WHERE seller_id = $1 AND project_id = $2::uuid AND status = 'pending'",
934 )
935 .bind(seller_id)
936 .bind(&project_id)
937 .fetch_one(&h.db)
938 .await
939 .unwrap();
940 assert_eq!(count, 1, "Should have 1 pending project transaction");
941 }
942
943 #[tokio::test]
944 async fn project_checkout_free_project_rejected() {
945 let mut h = TestHarness::with_mocks().await;
946 let (_seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await;
947
948 // Project pricing defaults to Free
949 let _buyer_id = h.signup("freeproj", "freeproj@test.com", "pass1234").await;
950
951 let resp = h.client.post_form(
952 &format!("/stripe/checkout/project/{}", project_id),
953 "share_contact=false",
954 ).await;
955 assert!(
956 resp.status.is_client_error(),
957 "Free project checkout should be rejected: {} {}",
958 resp.status, resp.text
959 );
960 }
961
962 #[tokio::test]
963 async fn project_checkout_self_purchase_rejected() {
964 let mut h = TestHarness::with_mocks().await;
965 let (_seller_id, project_id, _item_id) = setup_paid_item(&mut h, 999).await;
966
967 // Set project pricing
968 sqlx::query("UPDATE projects SET pricing_model = 'buy_once', price_cents = 1999 WHERE id = $1::uuid")
969 .bind(&project_id)
970 .execute(&h.db)
971 .await
972 .unwrap();
973
974 // Log in as seller and try to buy own project
975 h.login("seller", "pass1234").await;
976
977 let resp = h.client.post_form(
978 &format!("/stripe/checkout/project/{}", project_id),
979 "share_contact=false",
980 ).await;
981 assert!(
982 resp.status.is_client_error(),
983 "Self-purchase of project should be rejected: {} {}",
984 resp.status, resp.text
985 );
986 }
987
988 // ---------------------------------------------------------------------------
989 // Cart checkout via Stripe
990 // ---------------------------------------------------------------------------
991
992 #[tokio::test]
993 async fn cart_checkout_single_seller() {
994 let mut h = TestHarness::with_mocks().await;
995 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
996
997 let buyer_id = h.signup("cartchk", "cartchk@test.com", "pass1234").await;
998
999 // Add item to cart
1000 h.client.post_form(&format!("/api/cart/{}", item_id), "").await;
1001
1002 // Checkout cart for this seller
1003 let resp = h.client.post_form(
1004 "/stripe/checkout/cart",
1005 &format!("seller_id={}&share_contact=false", seller_id),
1006 ).await;
1007 assert!(
1008 resp.status.is_redirection() || resp.status.is_success(),
1009 "Cart checkout should redirect, got: {} {}",
1010 resp.status, resp.text
1011 );
1012
1013 // Verify mock checkout was created
1014 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1015 assert!(!mock_stripe.checkouts().is_empty(), "Should have created a checkout session");
1016
1017 // Verify pending transaction
1018 let count: i64 = sqlx::query_scalar(
1019 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
1020 )
1021 .bind(buyer_id)
1022 .fetch_one(&h.db)
1023 .await
1024 .unwrap();
1025 assert!(count >= 1, "Should have at least 1 pending transaction");
1026 }
1027
1028 #[tokio::test]
1029 async fn cart_checkout_empty_cart_rejected() {
1030 let mut h = TestHarness::with_mocks().await;
1031 let (seller_id, _project_id, _item_id) = setup_paid_item(&mut h, 500).await;
1032
1033 let _buyer_id = h.signup("emptycart", "emptycart@test.com", "pass1234").await;
1034
1035 // Don't add anything to cart
1036 let resp = h.client.post_form(
1037 "/stripe/checkout/cart",
1038 &format!("seller_id={}&share_contact=false", seller_id),
1039 ).await;
1040 assert!(
1041 resp.status.is_client_error(),
1042 "Empty cart checkout should be rejected: {} {}",
1043 resp.status, resp.text
1044 );
1045 }
1046
1047 #[tokio::test]
1048 async fn cart_checkout_self_purchase_rejected() {
1049 let mut h = TestHarness::with_mocks().await;
1050 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1051
1052 // Create a second user who adds the item to cart
1053 let _buyer_id = h.signup("cartself", "cartself@test.com", "pass1234").await;
1054 h.client.post_form(&format!("/api/cart/{}", item_id), "").await;
1055
1056 // Try to checkout with seller_id = self
1057 h.client.post_form("/logout", "").await;
1058 h.login("seller", "pass1234").await;
1059
1060 // Seller adds own item to cart via DB directly (API blocks it, so simulate)
1061 sqlx::query("INSERT INTO cart_items (user_id, item_id) VALUES ($1, $2::uuid) ON CONFLICT DO NOTHING")
1062 .bind(seller_id)
1063 .bind(&item_id)
1064 .execute(&h.db)
1065 .await
1066 .unwrap();
1067
1068 let resp = h.client.post_form(
1069 "/stripe/checkout/cart",
1070 &format!("seller_id={}&share_contact=false", seller_id),
1071 ).await;
1072 assert!(
1073 resp.status.is_client_error(),
1074 "Self-purchase via cart should be rejected: {} {}",
1075 resp.status, resp.text
1076 );
1077 }
1078
1079 #[tokio::test]
1080 async fn cart_checkout_free_items_claimed_immediately() {
1081 let mut h = TestHarness::with_mocks().await;
1082 let (seller_id, project_id, _item_id) = setup_paid_item(&mut h, 0).await;
1083
1084 // Create a free item
1085 h.login("seller", "pass1234").await;
1086 let resp = h.client.post_form(
1087 &format!("/api/projects/{}/items", project_id),
1088 "title=Free+Track&item_type=digital&price_cents=0",
1089 ).await;
1090 assert!(resp.status.is_success());
1091 let free_item: Value = resp.json();
1092 let free_item_id = free_item["id"].as_str().unwrap().to_string();
1093 h.client.put_form(&format!("/api/items/{}", free_item_id), "is_public=true").await;
1094 h.client.post_form("/logout", "").await;
1095
1096 let buyer_id = h.signup("freecart", "freecart@test.com", "pass1234").await;
1097
1098 // Add free item to cart
1099 h.client.post_form(&format!("/api/cart/{}", free_item_id), "").await;
1100
1101 // Cart checkout — free items should be claimed immediately, no Stripe session
1102 let resp = h.client.post_form(
1103 "/stripe/checkout/cart",
1104 &format!("seller_id={}&share_contact=false", seller_id),
1105 ).await;
1106 // Either redirect back (all free, no Stripe needed) or success
1107 assert!(
1108 !resp.status.is_server_error(),
1109 "Free cart checkout failed: {} {}",
1110 resp.status, resp.text
1111 );
1112
1113 // Verify free item was claimed (completed transaction exists)
1114 let count: i64 = sqlx::query_scalar(
1115 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'",
1116 )
1117 .bind(buyer_id)
1118 .bind(&free_item_id)
1119 .fetch_one(&h.db)
1120 .await
1121 .unwrap();
1122 assert_eq!(count, 1, "Free item should be claimed immediately");
1123
1124 // Verify item removed from cart
1125 let cart_count: i64 = sqlx::query_scalar(
1126 "SELECT COUNT(*) FROM cart_items WHERE user_id = $1 AND item_id = $2::uuid",
1127 )
1128 .bind(buyer_id)
1129 .bind(&free_item_id)
1130 .fetch_one(&h.db)
1131 .await
1132 .unwrap();
1133 assert_eq!(cart_count, 0, "Free item should be removed from cart after claim");
1134 }
1135
1136 /// A promo applied at cart checkout discounts the pending transaction and
1137 /// reserves exactly one promo use. Exercises the cart core's promo path, which
1138 /// the single-item promo suite (`promo_codes_checkout`) does not cover.
1139 #[tokio::test]
1140 async fn cart_checkout_promo_discount_applied() {
1141 let mut h = TestHarness::with_mocks().await;
1142 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1143
1144 // Seller is logged in after setup; create a 20%-off code.
1145 h.login("seller", "pass1234").await;
1146 let resp = h.client.post_form(
1147 "/api/promo-codes",
1148 "code=CART20&code_purpose=discount&discount_type=percentage&discount_value=20",
1149 ).await;
1150 assert!(resp.status.is_success(), "create promo failed: {} {}", resp.status, resp.text);
1151 h.client.post_form("/logout", "").await;
1152
1153 let buyer_id = h.signup("cartpromo", "cartpromo@test.com", "pass1234").await;
1154 h.client.post_form(&format!("/api/cart/{}", item_id), "").await;
1155
1156 let resp = h.client.post_form(
1157 "/stripe/checkout/cart",
1158 &format!("seller_id={}&share_contact=false&promo_code=CART20", seller_id),
1159 ).await;
1160 assert!(
1161 resp.status.is_redirection() || resp.status.is_success(),
1162 "promo cart checkout should proceed: {} {}", resp.status, resp.text
1163 );
1164 // 20% off $5.00 = $4.00 pending.
1165 let amount: i32 = sqlx::query_scalar(
1166 "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
1167 )
1168 .bind(buyer_id)
1169 .fetch_one(&h.db)
1170 .await
1171 .unwrap();
1172 assert_eq!(amount, 400, "pending amount should be the 20%-discounted price");
1173
1174 let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CART20'")
1175 .fetch_one(&h.db)
1176 .await
1177 .unwrap();
1178 assert_eq!(use_count, 1, "a completed cart checkout reserves exactly one promo use");
1179 }
1180
1181 /// A promo that drops the cart total below the Stripe minimum is rejected and
1182 /// must NOT burn a promo use. Pins the cart core's "reserve only after the
1183 /// min-charge gate" ordering (the gate runs before reservation).
1184 #[tokio::test]
1185 async fn cart_checkout_promo_sub_minimum_not_burned() {
1186 let mut h = TestHarness::with_mocks().await;
1187 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 100).await;
1188
1189 h.login("seller", "pass1234").await;
1190 // $0.70 off $1.00 -> 30¢, below the 50¢ Stripe minimum.
1191 let resp = h.client.post_form(
1192 "/api/promo-codes",
1193 "code=CARTTINY&code_purpose=discount&discount_type=fixed&discount_value=70",
1194 ).await;
1195 assert!(resp.status.is_success(), "create promo failed: {} {}", resp.status, resp.text);
1196 h.client.post_form("/logout", "").await;
1197
1198 let buyer_id = h.signup("carttiny", "carttiny@test.com", "pass1234").await;
1199 h.client.post_form(&format!("/api/cart/{}", item_id), "").await;
1200
1201 let resp = h.client.post_form(
1202 "/stripe/checkout/cart",
1203 &format!("seller_id={}&share_contact=false&promo_code=CARTTINY", seller_id),
1204 ).await;
1205 assert_eq!(resp.status.as_u16(), 400, "sub-minimum cart total must be rejected: {} {}", resp.status, resp.text);
1206
1207 let pending: i64 = sqlx::query_scalar(
1208 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
1209 )
1210 .bind(buyer_id)
1211 .fetch_one(&h.db)
1212 .await
1213 .unwrap();
1214 assert_eq!(pending, 0, "rejected sub-minimum cart checkout must not create a pending row");
1215
1216 let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTTINY'")
1217 .fetch_one(&h.db)
1218 .await
1219 .unwrap();
1220 assert_eq!(use_count, 0, "promo must not be reserved when the cart is rejected pre-reservation");
1221 }
1222
1223 /// Checkout-all across two sellers chains through `drain_to_paid`: it should
1224 /// reach a paid seller and return a Stripe URL, exercising the shared core via
1225 /// the cross-seller entry point.
1226 #[tokio::test]
1227 async fn cart_checkout_all_cross_seller_chain() {
1228 let mut h = TestHarness::with_mocks().await;
1229 let (_seller_a, _proj_a, item_a) = setup_paid_item(&mut h, 500).await;
1230
1231 // Second seller with their own paid item.
1232 let seller_b = h.signup("sellerb", "sellerb@test.com", "pass1234").await;
1233 h.grant_creator(seller_b).await;
1234 sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_b', stripe_charges_enabled = true WHERE id = $1")
1235 .bind(seller_b)
1236 .execute(&h.db)
1237 .await
1238 .unwrap();
1239 h.login("sellerb", "pass1234").await;
1240 let resp = h.client.post_form("/api/projects", "slug=shopb&title=ShopB").await;
1241 let proj_b: Value = resp.json();
1242 let proj_b_id = proj_b["id"].as_str().unwrap().to_string();
1243 let resp = h.client.post_form(
1244 &format!("/api/projects/{}/items", proj_b_id),
1245 "title=TrackB&price_cents=700&item_type=audio",
1246 ).await;
1247 let item_b: Value = resp.json();
1248 let item_b_id = item_b["id"].as_str().unwrap().to_string();
1249 h.client.put_form(&format!("/api/projects/{}", proj_b_id), "is_public=true").await;
1250 h.client.put_form(&format!("/api/items/{}", item_b_id), "is_public=true").await;
1251 h.client.post_form("/logout", "").await;
1252
1253 let _buyer_id = h.signup("cartall", "cartall@test.com", "pass1234").await;
1254 h.client.post_form(&format!("/api/cart/{}", item_a), "").await;
1255 h.client.post_form(&format!("/api/cart/{}", item_b_id), "").await;
1256
1257 let resp = h.client.post_form("/stripe/checkout/cart/all", "share_contact=false").await;
1258 assert!(
1259 resp.status.is_redirection() || resp.status.is_success(),
1260 "checkout-all should reach a paid seller: {} {}", resp.status, resp.text
1261 );
1262
1263 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1264 assert!(!mock_stripe.checkouts().is_empty(), "chain should create at least one checkout session");
1265 }
1266
1267 // ---------------------------------------------------------------------------
1268 // Subscription checkout
1269 // ---------------------------------------------------------------------------
1270
1271 #[tokio::test]
1272 async fn subscription_checkout_creates_session() {
1273 let mut h = TestHarness::with_mocks().await;
1274
1275 // Create a seller with Stripe connected
1276 let seller_id = h.signup("subseller", "subseller@test.com", "pass1234").await;
1277 h.grant_creator(seller_id).await;
1278 sqlx::query(
1279 "UPDATE users SET stripe_account_id = 'acct_mock_sub', stripe_charges_enabled = true WHERE id = $1",
1280 )
1281 .bind(seller_id)
1282 .execute(&h.db)
1283 .await
1284 .unwrap();
1285
1286 // Create a project
1287 h.client.post_form("/logout", "").await;
1288 h.login("subseller", "pass1234").await;
1289 let resp = h.client.post_form("/api/projects", "slug=subproj&title=Sub+Project").await;
1290 let project: Value = resp.json();
1291 let project_id = project["id"].as_str().unwrap().to_string();
1292 h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#).await;
1293
1294 // Create a subscription tier with fake Stripe IDs
1295 sqlx::query(
1296 r#"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1297 VALUES ($1::uuid, 'Gold', 999, true, 'prod_mock_gold', 'price_mock_gold')"#,
1298 )
1299 .bind(&project_id)
1300 .execute(&h.db)
1301 .await
1302 .unwrap();
1303
1304 let tier_id: String = sqlx::query_scalar(
1305 "SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid",
1306 )
1307 .bind(&project_id)
1308 .fetch_one(&h.db)
1309 .await
1310 .unwrap();
1311
1312 // Log out seller, sign up subscriber
1313 h.client.post_form("/logout", "").await;
1314 let _subscriber_id = h.signup("subscriber", "subscriber@test.com", "pass1234").await;
1315
1316 let resp = h.client.post_form(
1317 &format!("/stripe/subscribe/{}", tier_id),
1318 "",
1319 ).await;
1320 assert!(
1321 resp.status.is_redirection() || resp.status.is_success(),
1322 "Subscription checkout should redirect, got: {} {}",
1323 resp.status, resp.text
1324 );
1325
1326 let mock_stripe = h.mock_stripe.as_ref().unwrap();
1327 assert!(!mock_stripe.checkouts().is_empty(), "Should have created a subscription checkout");
1328 }
1329
1330 #[tokio::test]
1331 async fn subscription_checkout_self_subscribe_rejected() {
1332 let mut h = TestHarness::with_mocks().await;
1333
1334 let seller_id = h.signup("selfsubseller", "selfsubseller@test.com", "pass1234").await;
1335 h.grant_creator(seller_id).await;
1336 sqlx::query(
1337 "UPDATE users SET stripe_account_id = 'acct_selfsub', stripe_charges_enabled = true WHERE id = $1",
1338 )
1339 .bind(seller_id)
1340 .execute(&h.db)
1341 .await
1342 .unwrap();
1343
1344 h.client.post_form("/logout", "").await;
1345 h.login("selfsubseller", "pass1234").await;
1346
1347 let resp = h.client.post_form("/api/projects", "slug=selfsub&title=Self+Sub").await;
1348 let project: Value = resp.json();
1349 let project_id = project["id"].as_str().unwrap().to_string();
1350 h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#).await;
1351
1352 sqlx::query(
1353 r#"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1354 VALUES ($1::uuid, 'Self', 999, true, 'prod_self', 'price_self')"#,
1355 )
1356 .bind(&project_id)
1357 .execute(&h.db)
1358 .await
1359 .unwrap();
1360
1361 let tier_id: String = sqlx::query_scalar(
1362 "SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid",
1363 )
1364 .bind(&project_id)
1365 .fetch_one(&h.db)
1366 .await
1367 .unwrap();
1368
1369 // Seller tries to subscribe to own project
1370 let resp = h.client.post_form(
1371 &format!("/stripe/subscribe/{}", tier_id),
1372 "",
1373 ).await;
1374 assert!(
1375 resp.status.is_client_error(),
1376 "Self-subscription should be rejected: {} {}",
1377 resp.status, resp.text
1378 );
1379 }
1380
1381 #[tokio::test]
1382 async fn subscription_checkout_inactive_tier_rejected() {
1383 let mut h = TestHarness::with_mocks().await;
1384
1385 let seller_id = h.signup("inactseller", "inactseller@test.com", "pass1234").await;
1386 h.grant_creator(seller_id).await;
1387 sqlx::query(
1388 "UPDATE users SET stripe_account_id = 'acct_inact', stripe_charges_enabled = true WHERE id = $1",
1389 )
1390 .bind(seller_id)
1391 .execute(&h.db)
1392 .await
1393 .unwrap();
1394
1395 h.client.post_form("/logout", "").await;
1396 h.login("inactseller", "pass1234").await;
1397
1398 let resp = h.client.post_form("/api/projects", "slug=inactproj&title=Inactive").await;
1399 let project: Value = resp.json();
1400 let project_id = project["id"].as_str().unwrap().to_string();
1401 h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#).await;
1402
1403 // Create an INACTIVE tier
1404 sqlx::query(
1405 r#"INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
1406 VALUES ($1::uuid, 'Archived', 999, false, 'prod_arch', 'price_arch')"#,
1407 )
1408 .bind(&project_id)
1409 .execute(&h.db)
1410 .await
1411 .unwrap();
1412
1413 let tier_id: String = sqlx::query_scalar(
1414 "SELECT id::text FROM subscription_tiers WHERE project_id = $1::uuid",
1415 )
1416 .bind(&project_id)
1417 .fetch_one(&h.db)
1418 .await
1419 .unwrap();
1420
1421 h.client.post_form("/logout", "").await;
1422 let _sub_id = h.signup("inactsub", "inactsub@test.com", "pass1234").await;
1423
1424 let resp = h.client.post_form(
1425 &format!("/stripe/subscribe/{}", tier_id),
1426 "",
1427 ).await;
1428 assert!(
1429 resp.status.is_client_error(),
1430 "Inactive tier should be rejected: {} {}",
1431 resp.status, resp.text
1432 );
1433 }
1434
1435 // ---------------------------------------------------------------------------
1436 // Creator tier checkout (requires config)
1437 // ---------------------------------------------------------------------------
1438
1439 #[tokio::test]
1440 async fn creator_tier_checkout_not_configured_rejected() {
1441 let mut h = TestHarness::with_mocks().await;
1442 let _user_id = h.signup("tierbuy", "tierbuy@test.com", "pass1234").await;
1443
1444 // Config has empty creator_tier_prices, so this should fail with "not configured"
1445 let resp = h.client.post_form("/stripe/creator-tier", "tier=small_files").await;
1446 assert!(
1447 resp.status.is_client_error(),
1448 "Creator tier checkout without config should fail: {} {}",
1449 resp.status, resp.text
1450 );
1451 }
1452