Skip to main content

max / makenotwork

35.2 KB · 1063 lines History Blame Raw
1 //! Stripe webhook workflow tests — purchase, refund, account update,
2 //! invalid signature, subscription lifecycle.
3
4 use crate::harness::stripe::{sign_webhook_payload, TEST_WEBHOOK_SECRET, TEST_WEBHOOK_SECRET_V2};
5 use crate::harness::TestHarness;
6 use makenotwork::db::UserId;
7 use serde_json::Value;
8 use std::collections::HashMap;
9
10 /// Build a JSON event with the given type and object, sign it, and POST to /stripe/webhook.
11 async fn post_event_json(
12 h: &mut TestHarness,
13 event_type: &str,
14 object: serde_json::Value,
15 ) -> crate::harness::client::TestResponse {
16 post_event_json_with_id(h, "evt_test_000", event_type, object).await
17 }
18
19 async fn post_event_json_with_id(
20 h: &mut TestHarness,
21 event_id: &str,
22 event_type: &str,
23 object: serde_json::Value,
24 ) -> crate::harness::client::TestResponse {
25 let payload = serde_json::json!({
26 "id": event_id,
27 "type": event_type,
28 "data": {"object": object},
29 })
30 .to_string();
31 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET);
32
33 h.client
34 .request_with_headers(
35 "POST",
36 "/stripe/webhook",
37 Some(&payload),
38 &[
39 ("stripe-signature", &signature),
40 ("content-type", "application/json"),
41 ],
42 )
43 .await
44 }
45
46 // ---------------------------------------------------------------------------
47 // Tests
48 // ---------------------------------------------------------------------------
49
50 #[tokio::test]
51 async fn webhook_invalid_signature() {
52 let mut h = TestHarness::with_stripe().await;
53
54 let payload = r#"{"id":"evt_bad","type":"account.updated","data":{"object":{}}}"#;
55 let bad_sig = "t=0,v1=00000000000000000000000000000000";
56
57 let resp = h
58 .client
59 .request_with_headers(
60 "POST",
61 "/stripe/webhook",
62 Some(payload),
63 &[
64 ("stripe-signature", bad_sig),
65 ("content-type", "application/json"),
66 ],
67 )
68 .await;
69 assert_eq!(
70 resp.status.as_u16(),
71 400,
72 "Expected 400 for bad signature, got: {}",
73 resp.status
74 );
75 }
76
77 #[tokio::test]
78 async fn webhook_account_updated() {
79 let mut h = TestHarness::with_stripe().await;
80
81 // Create a user with a known stripe_account_id
82 let user_id = h.signup("stripecreator", "sc@test.com", "password123").await;
83 let acct_id = "acct_test_wh_123";
84 sqlx::query("UPDATE users SET stripe_account_id = $1 WHERE id = $2")
85 .bind(acct_id)
86 .bind(user_id)
87 .execute(&h.db)
88 .await
89 .unwrap();
90
91 // Build account object with valid id prefix
92 let account = serde_json::json!({
93 "id": acct_id,
94 "object": "account",
95 "charges_enabled": true,
96 "payouts_enabled": true,
97 "details_submitted": true,
98 });
99
100 let resp = post_event_json(&mut h, "account.updated", account).await;
101 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
102
103 // Verify DB was updated
104 let (charges, payouts, onboarding): (bool, bool, bool) = sqlx::query_as(
105 "SELECT stripe_charges_enabled, stripe_payouts_enabled, stripe_onboarding_complete FROM users WHERE id = $1",
106 )
107 .bind(user_id)
108 .fetch_one(&h.db)
109 .await
110 .unwrap();
111
112 assert!(charges, "charges_enabled should be true");
113 assert!(payouts, "payouts_enabled should be true");
114 assert!(onboarding, "onboarding_complete should be true");
115 }
116
117 #[tokio::test]
118 async fn webhook_purchase_completed() {
119 let mut h = TestHarness::with_stripe().await;
120
121 // Create buyer + seller
122 let buyer_id = h.signup("buyer", "buyer@test.com", "password123").await;
123 h.client.post_form("/logout", "").await;
124 let seller_id = h.signup("seller", "seller@test.com", "password123").await;
125 h.grant_creator(seller_id).await;
126 h.client.post_form("/logout", "").await;
127 h.login("seller", "password123").await;
128
129 // Create project + item
130 let resp = h
131 .client
132 .post_form("/api/projects", "slug=stripeproj&title=Stripe+Project")
133 .await;
134 let project: Value = resp.json();
135 let project_id = project["id"].as_str().unwrap().to_string();
136 let resp = h
137 .client
138 .post_form(
139 &format!("/api/projects/{}/items", project_id),
140 "title=Paid+Track&price_cents=999&item_type=audio",
141 )
142 .await;
143 let item: Value = resp.json();
144 let item_id = item["id"].as_str().unwrap().to_string();
145
146 // Insert a pending transaction via direct SQL
147 let session_id = "cs_test_purchase_123";
148 sqlx::query(
149 r#"INSERT INTO transactions
150 (buyer_id, seller_id, item_id, amount_cents, status,
151 stripe_checkout_session_id, item_title, seller_username)
152 VALUES ($1, $2, $3::uuid, 999, 'pending', $4, 'Paid Track', 'seller')"#,
153 )
154 .bind(buyer_id)
155 .bind(seller_id)
156 .bind(&item_id)
157 .bind(session_id)
158 .execute(&h.db)
159 .await
160 .unwrap();
161
162 // Build checkout session with valid IDs
163 let mut meta = HashMap::new();
164 meta.insert("buyer_id".to_string(), buyer_id.to_string());
165 meta.insert("seller_id".to_string(), seller_id.to_string());
166 meta.insert("item_id".to_string(), item_id.clone());
167 let session = serde_json::json!({
168 "id": session_id,
169 "object": "checkout_session",
170 "mode": "payment",
171 "metadata": meta,
172 "payment_intent": "pi_test_purchase_123",
173 });
174
175 let resp = post_event_json(&mut h, "checkout.session.completed", session).await;
176 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
177
178 // Verify transaction was completed
179 let status: String = sqlx::query_scalar(
180 "SELECT status FROM transactions WHERE stripe_checkout_session_id = $1",
181 )
182 .bind(session_id)
183 .fetch_one(&h.db)
184 .await
185 .unwrap();
186 assert_eq!(status, "completed");
187
188 // Verify sales_count was incremented
189 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
190 .bind(&item_id)
191 .fetch_one(&h.db)
192 .await
193 .unwrap();
194 assert_eq!(sales, 1);
195 }
196
197 #[tokio::test]
198 async fn webhook_charge_refunded() {
199 let mut h = TestHarness::with_stripe().await;
200
201 // Create buyer + seller + item
202 let buyer_id = h.signup("rbuyer", "rb@test.com", "password123").await;
203 h.client.post_form("/logout", "").await;
204 let seller_id = h.signup("rseller", "rs@test.com", "password123").await;
205 h.grant_creator(seller_id).await;
206 h.client.post_form("/logout", "").await;
207 h.login("rseller", "password123").await;
208
209 let resp = h
210 .client
211 .post_form("/api/projects", "slug=refundproj&title=Refund+Project")
212 .await;
213 let project: Value = resp.json();
214 let project_id = project["id"].as_str().unwrap().to_string();
215 let resp = h
216 .client
217 .post_form(
218 &format!("/api/projects/{}/items", project_id),
219 "title=Refund+Track&price_cents=500&item_type=audio",
220 )
221 .await;
222 let item: Value = resp.json();
223 let item_id = item["id"].as_str().unwrap().to_string();
224
225 // Set sales_count to 1 and insert a completed transaction
226 let pi_id = "pi_test_refund_123";
227 sqlx::query("UPDATE items SET sales_count = 1 WHERE id = $1::uuid")
228 .bind(&item_id)
229 .execute(&h.db)
230 .await
231 .unwrap();
232 sqlx::query(
233 r#"INSERT INTO transactions
234 (buyer_id, seller_id, item_id, amount_cents, status,
235 stripe_payment_intent_id, stripe_checkout_session_id,
236 item_title, seller_username, completed_at)
237 VALUES ($1, $2, $3::uuid, 500, 'completed', $4, 'cs_refund', 'Refund Track', 'rseller', NOW())"#,
238 )
239 .bind(buyer_id)
240 .bind(seller_id)
241 .bind(&item_id)
242 .bind(pi_id)
243 .execute(&h.db)
244 .await
245 .unwrap();
246
247 // Build charge with valid id and payment_intent
248 let charge = serde_json::json!({
249 "id": "ch_test_refund",
250 "object": "charge",
251 "amount": 500,
252 "amount_refunded": 500,
253 "payment_intent": "pi_test_refund_123",
254 });
255
256 let resp = post_event_json(&mut h, "charge.refunded", charge).await;
257 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
258
259 // Verify transaction was refunded
260 let status: String = sqlx::query_scalar(
261 "SELECT status FROM transactions WHERE stripe_payment_intent_id = $1",
262 )
263 .bind(pi_id)
264 .fetch_one(&h.db)
265 .await
266 .unwrap();
267 assert_eq!(status, "refunded");
268
269 // Verify sales_count was decremented
270 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
271 .bind(&item_id)
272 .fetch_one(&h.db)
273 .await
274 .unwrap();
275 assert_eq!(sales, 0);
276 }
277
278 #[tokio::test]
279 async fn webhook_subscription_deleted() {
280 let mut h = TestHarness::with_stripe().await;
281
282 // Create subscriber and creator with project + tier
283 let sub_user_id = h.signup("subscriber", "sub@test.com", "password123").await;
284 h.client.post_form("/logout", "").await;
285 let creator_id = h.signup("tiercreator", "tc@test.com", "password123").await;
286 h.grant_creator(creator_id).await;
287 h.client.post_form("/logout", "").await;
288 h.login("tiercreator", "password123").await;
289
290 let resp = h
291 .client
292 .post_form("/api/projects", "slug=subproj&title=Sub+Project")
293 .await;
294 let project: Value = resp.json();
295 let project_id = project["id"].as_str().unwrap().to_string();
296
297 // Create subscription tier via direct SQL
298 let tier_id = uuid::Uuid::new_v4();
299 sqlx::query(
300 r#"INSERT INTO subscription_tiers
301 (id, project_id, name, price_cents)
302 VALUES ($1, $2::uuid, 'Basic', 500)"#,
303 )
304 .bind(tier_id)
305 .bind(&project_id)
306 .execute(&h.db)
307 .await
308 .unwrap();
309
310 // Create subscription via direct SQL
311 let stripe_sub_id = "sub_test_delete_123";
312 sqlx::query(
313 r#"INSERT INTO subscriptions
314 (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id, status)
315 VALUES ($1, $2, $3::uuid, $4, 'cus_test', 'active')"#,
316 )
317 .bind(sub_user_id)
318 .bind(tier_id)
319 .bind(&project_id)
320 .bind(stripe_sub_id)
321 .execute(&h.db)
322 .await
323 .unwrap();
324
325 let sub = serde_json::json!({
326 "id": stripe_sub_id,
327 "object": "subscription",
328 "status": "canceled",
329 "cancel_at_period_end": false,
330 "items": {
331 "object": "list",
332 "data": [{
333 "id": "si_test_del",
334 "object": "subscription_item",
335 "subscription": stripe_sub_id,
336 "current_period_start": 1700000000,
337 "current_period_end": 1702592000,
338 "metadata": {},
339 }],
340 },
341 });
342
343 let resp = post_event_json(&mut h, "customer.subscription.deleted", sub).await;
344 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
345
346 // Verify subscription was canceled
347 let status: String = sqlx::query_scalar(
348 "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1",
349 )
350 .bind(stripe_sub_id)
351 .fetch_one(&h.db)
352 .await
353 .unwrap();
354 assert_eq!(status, "canceled");
355 }
356
357 // ---------------------------------------------------------------------------
358 // Shared fixture for subscription webhook tests
359 // ---------------------------------------------------------------------------
360
361 struct SubscriptionFixture {
362 #[allow(dead_code)]
363 creator_id: UserId,
364 subscriber_id: UserId,
365 project_id: String,
366 tier_id: uuid::Uuid,
367 }
368
369 /// Creates a creator (with project + tier) and a subscriber user.
370 /// Leaves the harness logged out.
371 async fn setup_subscription_fixture(h: &mut TestHarness) -> SubscriptionFixture {
372 let subscriber_id = h.signup("subuser", "subuser@test.com", "password123").await;
373 h.client.post_form("/logout", "").await;
374 let creator_id = h.signup("creator", "creator@test.com", "password123").await;
375 h.grant_creator(creator_id).await;
376 h.client.post_form("/logout", "").await;
377 h.login("creator", "password123").await;
378
379 let resp = h
380 .client
381 .post_form("/api/projects", "slug=subfix&title=Sub+Fixture")
382 .await;
383 let project: Value = resp.json();
384 let project_id = project["id"].as_str().unwrap().to_string();
385
386 let tier_id = uuid::Uuid::new_v4();
387 sqlx::query(
388 r#"INSERT INTO subscription_tiers (id, project_id, name, price_cents)
389 VALUES ($1, $2::uuid, 'Pro', 1000)"#,
390 )
391 .bind(tier_id)
392 .bind(&project_id)
393 .execute(&h.db)
394 .await
395 .unwrap();
396
397 h.client.post_form("/logout", "").await;
398
399 SubscriptionFixture {
400 creator_id,
401 subscriber_id,
402 project_id,
403 tier_id,
404 }
405 }
406
407 /// Insert an active subscription row into the DB. Returns the stripe subscription ID.
408 async fn insert_active_subscription(
409 h: &TestHarness,
410 fix: &SubscriptionFixture,
411 stripe_sub_id: &str,
412 ) {
413 sqlx::query(
414 r#"INSERT INTO subscriptions
415 (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id, status)
416 VALUES ($1, $2, $3::uuid, $4, 'cus_test_fixture', 'active')"#,
417 )
418 .bind(fix.subscriber_id)
419 .bind(fix.tier_id)
420 .bind(&fix.project_id)
421 .bind(stripe_sub_id)
422 .execute(&h.db)
423 .await
424 .unwrap();
425 }
426
427 // ---------------------------------------------------------------------------
428 // New tests
429 // ---------------------------------------------------------------------------
430
431 #[tokio::test]
432 async fn webhook_subscription_checkout_completed() {
433 let mut h = TestHarness::with_stripe().await;
434 let fix = setup_subscription_fixture(&mut h).await;
435
436 let stripe_sub_id = "sub_test_checkout_001";
437 let stripe_customer_id = "cus_test_checkout_001";
438
439 // Build checkout session with subscription metadata
440 let mut meta = HashMap::new();
441 meta.insert("checkout_type".to_string(), "subscription".to_string());
442 meta.insert("subscriber_id".to_string(), fix.subscriber_id.to_string());
443 meta.insert("project_id".to_string(), fix.project_id.clone());
444 meta.insert("tier_id".to_string(), fix.tier_id.to_string());
445 let session = serde_json::json!({
446 "id": "cs_test_sub_checkout_001",
447 "object": "checkout_session",
448 "mode": "subscription",
449 "metadata": meta,
450 "subscription": stripe_sub_id,
451 "customer": stripe_customer_id,
452 });
453
454 let resp = post_event_json(&mut h, "checkout.session.completed", session).await;
455 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
456
457 // Verify subscription row was created
458 let (status, sub_stripe_id, sub_customer_id): (String, String, String) = sqlx::query_as(
459 "SELECT status, stripe_subscription_id, stripe_customer_id FROM subscriptions WHERE stripe_subscription_id = $1",
460 )
461 .bind(stripe_sub_id)
462 .fetch_one(&h.db)
463 .await
464 .unwrap();
465
466 assert_eq!(status, "active");
467 assert_eq!(sub_stripe_id, stripe_sub_id);
468 assert_eq!(sub_customer_id, stripe_customer_id);
469 }
470
471 #[tokio::test]
472 async fn webhook_subscription_checkout_completed_idempotent() {
473 let mut h = TestHarness::with_stripe().await;
474 let fix = setup_subscription_fixture(&mut h).await;
475
476 let stripe_sub_id = "sub_test_checkout_idem";
477 let stripe_customer_id = "cus_test_checkout_idem";
478
479 let build_session = || {
480 let mut meta = HashMap::new();
481 meta.insert("checkout_type".to_string(), "subscription".to_string());
482 meta.insert("subscriber_id".to_string(), fix.subscriber_id.to_string());
483 meta.insert("project_id".to_string(), fix.project_id.clone());
484 meta.insert("tier_id".to_string(), fix.tier_id.to_string());
485 serde_json::json!({
486 "id": "cs_test_sub_idem",
487 "object": "checkout_session",
488 "mode": "subscription",
489 "metadata": meta,
490 "subscription": stripe_sub_id,
491 "customer": stripe_customer_id,
492 })
493 };
494
495 // First event
496 let resp = post_event_json(&mut h, "checkout.session.completed", build_session()).await;
497 assert_eq!(resp.status.as_u16(), 200, "First webhook failed: {}", resp.text);
498
499 // Second event (duplicate) — use a different event ID
500 let resp = post_event_json_with_id(&mut h, "evt_test_001", "checkout.session.completed", build_session()).await;
501 assert_eq!(resp.status.as_u16(), 200, "Duplicate webhook should succeed: {}", resp.text);
502
503 // Verify still only one subscription row
504 let count: i64 = sqlx::query_scalar(
505 "SELECT COUNT(*) FROM subscriptions WHERE stripe_subscription_id = $1",
506 )
507 .bind(stripe_sub_id)
508 .fetch_one(&h.db)
509 .await
510 .unwrap();
511 assert_eq!(count, 1, "Should have exactly one subscription row");
512 }
513
514 #[tokio::test]
515 async fn webhook_subscription_updated() {
516 let mut h = TestHarness::with_stripe().await;
517 let fix = setup_subscription_fixture(&mut h).await;
518
519 let stripe_sub_id = "sub_test_updated_001";
520 insert_active_subscription(&h, &fix, stripe_sub_id).await;
521
522 let sub = serde_json::json!({
523 "id": stripe_sub_id,
524 "object": "subscription",
525 "status": "past_due",
526 "cancel_at_period_end": false,
527 "items": {
528 "object": "list",
529 "data": [{
530 "id": "si_test_upd",
531 "object": "subscription_item",
532 "subscription": stripe_sub_id,
533 "current_period_start": 1702592000,
534 "current_period_end": 1705184000,
535 "metadata": {},
536 }],
537 },
538 });
539
540 let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await;
541 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
542
543 // Verify status changed
544 let status: String = sqlx::query_scalar(
545 "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1",
546 )
547 .bind(stripe_sub_id)
548 .fetch_one(&h.db)
549 .await
550 .unwrap();
551 assert_eq!(status, "past_due");
552
553 // Verify period was updated
554 let (period_start, period_end): (Option<chrono::DateTime<chrono::Utc>>, Option<chrono::DateTime<chrono::Utc>>) = sqlx::query_as(
555 "SELECT current_period_start, current_period_end FROM subscriptions WHERE stripe_subscription_id = $1",
556 )
557 .bind(stripe_sub_id)
558 .fetch_one(&h.db)
559 .await
560 .unwrap();
561 assert!(period_start.is_some(), "period_start should be set");
562 assert!(period_end.is_some(), "period_end should be set");
563 assert_eq!(period_start.unwrap().timestamp(), 1702592000);
564 assert_eq!(period_end.unwrap().timestamp(), 1705184000);
565 }
566
567 #[tokio::test]
568 async fn webhook_invoice_payment_succeeded() {
569 let mut h = TestHarness::with_stripe().await;
570 let fix = setup_subscription_fixture(&mut h).await;
571
572 let stripe_sub_id = "sub_test_inv_success";
573 insert_active_subscription(&h, &fix, stripe_sub_id).await;
574
575 let invoice = serde_json::json!({
576 "id": "in_test_success_001",
577 "object": "invoice",
578 "subscription": stripe_sub_id,
579 "period_start": 1702592000,
580 "period_end": 1705184000,
581 "billing_reason": "subscription_cycle",
582 "livemode": false,
583 });
584
585 let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await;
586 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
587
588 // Verify subscription period was updated
589 let (period_start, period_end): (Option<chrono::DateTime<chrono::Utc>>, Option<chrono::DateTime<chrono::Utc>>) = sqlx::query_as(
590 "SELECT current_period_start, current_period_end FROM subscriptions WHERE stripe_subscription_id = $1",
591 )
592 .bind(stripe_sub_id)
593 .fetch_one(&h.db)
594 .await
595 .unwrap();
596 assert!(period_start.is_some(), "period_start should be set");
597 assert!(period_end.is_some(), "period_end should be set");
598 assert_eq!(period_start.unwrap().timestamp(), 1702592000);
599 assert_eq!(period_end.unwrap().timestamp(), 1705184000);
600 }
601
602 #[tokio::test]
603 async fn webhook_invoice_payment_failed() {
604 let mut h = TestHarness::with_stripe().await;
605 let fix = setup_subscription_fixture(&mut h).await;
606
607 let stripe_sub_id = "sub_test_inv_failed";
608 insert_active_subscription(&h, &fix, stripe_sub_id).await;
609
610 let invoice = serde_json::json!({
611 "id": "in_test_failed_001",
612 "object": "invoice",
613 "subscription": stripe_sub_id,
614 "period_start": 1700000000,
615 "period_end": 1702592000,
616 "livemode": false,
617 });
618
619 let resp = post_event_json(&mut h, "invoice.payment_failed", invoice).await;
620 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
621
622 // Verify status changed to past_due
623 let status: String = sqlx::query_scalar(
624 "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1",
625 )
626 .bind(stripe_sub_id)
627 .fetch_one(&h.db)
628 .await
629 .unwrap();
630 assert_eq!(status, "past_due");
631 }
632
633 #[tokio::test]
634 async fn webhook_account_updated_partial() {
635 let mut h = TestHarness::with_stripe().await;
636
637 let user_id = h.signup("partialcreator", "pc@test.com", "password123").await;
638 let acct_id = "acct_test_partial_123";
639 sqlx::query("UPDATE users SET stripe_account_id = $1 WHERE id = $2")
640 .bind(acct_id)
641 .bind(user_id)
642 .execute(&h.db)
643 .await
644 .unwrap();
645
646 // Only details_submitted is true; charges and payouts still false
647 let account = serde_json::json!({
648 "id": acct_id,
649 "object": "account",
650 "charges_enabled": false,
651 "payouts_enabled": false,
652 "details_submitted": true,
653 });
654
655 let resp = post_event_json(&mut h, "account.updated", account).await;
656 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
657
658 let (charges, payouts, onboarding): (bool, bool, bool) = sqlx::query_as(
659 "SELECT stripe_charges_enabled, stripe_payouts_enabled, stripe_onboarding_complete FROM users WHERE id = $1",
660 )
661 .bind(user_id)
662 .fetch_one(&h.db)
663 .await
664 .unwrap();
665
666 assert!(!charges, "charges_enabled should be false");
667 assert!(!payouts, "payouts_enabled should be false");
668 assert!(onboarding, "onboarding_complete should be true");
669 }
670
671 #[tokio::test]
672 async fn webhook_account_updated_unknown_account() {
673 let mut h = TestHarness::with_stripe().await;
674
675 // No user has this stripe_account_id
676 let account = serde_json::json!({
677 "id": "acct_nonexistent",
678 "object": "account",
679 "charges_enabled": true,
680 "payouts_enabled": true,
681 "details_submitted": true,
682 });
683
684 let resp = post_event_json(&mut h, "account.updated", account).await;
685 assert_eq!(
686 resp.status.as_u16(),
687 200,
688 "Unknown account should still return 200: {}",
689 resp.text
690 );
691
692 // Verify no users were affected
693 let count: i64 = sqlx::query_scalar(
694 "SELECT COUNT(*) FROM users WHERE stripe_account_id = 'acct_nonexistent'",
695 )
696 .fetch_one(&h.db)
697 .await
698 .unwrap();
699 assert_eq!(count, 0);
700 }
701
702 #[tokio::test]
703 async fn webhook_purchase_completed_idempotent() {
704 let mut h = TestHarness::with_stripe().await;
705
706 // Create buyer + seller
707 let buyer_id = h.signup("idembuyer", "ib@test.com", "password123").await;
708 h.client.post_form("/logout", "").await;
709 let seller_id = h.signup("idemseller", "is@test.com", "password123").await;
710 h.grant_creator(seller_id).await;
711 h.client.post_form("/logout", "").await;
712 h.login("idemseller", "password123").await;
713
714 // Create project + item
715 let resp = h
716 .client
717 .post_form("/api/projects", "slug=idemproj&title=Idem+Project")
718 .await;
719 let project: Value = resp.json();
720 let project_id = project["id"].as_str().unwrap().to_string();
721 let resp = h
722 .client
723 .post_form(
724 &format!("/api/projects/{}/items", project_id),
725 "title=Idem+Track&price_cents=500&item_type=audio",
726 )
727 .await;
728 let item: Value = resp.json();
729 let item_id = item["id"].as_str().unwrap().to_string();
730
731 // Insert a pending transaction
732 let session_id = "cs_test_idem_001";
733 sqlx::query(
734 r#"INSERT INTO transactions
735 (buyer_id, seller_id, item_id, amount_cents, status,
736 stripe_checkout_session_id, item_title, seller_username)
737 VALUES ($1, $2, $3::uuid, 500, 'pending', $4, 'Idem Track', 'idemseller')"#,
738 )
739 .bind(buyer_id)
740 .bind(seller_id)
741 .bind(&item_id)
742 .bind(session_id)
743 .execute(&h.db)
744 .await
745 .unwrap();
746
747 let build_session = || {
748 let mut meta = HashMap::new();
749 meta.insert("buyer_id".to_string(), buyer_id.to_string());
750 meta.insert("seller_id".to_string(), seller_id.to_string());
751 meta.insert("item_id".to_string(), item_id.clone());
752 serde_json::json!({
753 "id": session_id,
754 "object": "checkout_session",
755 "mode": "payment",
756 "metadata": meta,
757 "payment_intent": "pi_test_idem_001",
758 })
759 };
760
761 // First event
762 let resp = post_event_json(&mut h, "checkout.session.completed", build_session()).await;
763 assert_eq!(resp.status.as_u16(), 200, "First webhook failed: {}", resp.text);
764
765 // Second event (duplicate)
766 let resp = post_event_json_with_id(&mut h, "evt_test_002", "checkout.session.completed", build_session()).await;
767 assert_eq!(resp.status.as_u16(), 200, "Duplicate webhook should succeed: {}", resp.text);
768
769 // Verify still one completed transaction
770 let count: i64 = sqlx::query_scalar(
771 "SELECT COUNT(*) FROM transactions WHERE stripe_checkout_session_id = $1 AND status = 'completed'",
772 )
773 .bind(session_id)
774 .fetch_one(&h.db)
775 .await
776 .unwrap();
777 assert_eq!(count, 1, "Should have exactly one completed transaction");
778
779 // Verify sales_count is still 1 (not incremented twice)
780 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
781 .bind(&item_id)
782 .fetch_one(&h.db)
783 .await
784 .unwrap();
785 assert_eq!(sales, 1, "sales_count should be 1, not 2");
786 }
787
788 // ---------------------------------------------------------------------------
789 // v2 thin event tests
790 // ---------------------------------------------------------------------------
791
792 /// Helper to POST a raw JSON payload to /stripe/webhook/v2 with a given signature.
793 async fn post_v2_raw(
794 h: &mut TestHarness,
795 payload: &str,
796 signature: &str,
797 ) -> crate::harness::client::TestResponse {
798 h.client
799 .request_with_headers(
800 "POST",
801 "/stripe/webhook/v2",
802 Some(payload),
803 &[
804 ("stripe-signature", signature),
805 ("content-type", "application/json"),
806 ],
807 )
808 .await
809 }
810
811 #[tokio::test]
812 async fn webhook_v2_invalid_signature() {
813 let mut h = TestHarness::with_stripe().await;
814
815 let payload = r#"{"id":"evt_v2_bad","type":"v2.core.account.updated","related_object":{"id":"acct_123","type":"account"}}"#;
816 let bad_sig = "t=0,v1=00000000000000000000000000000000";
817
818 let resp = post_v2_raw(&mut h, payload, bad_sig).await;
819 assert_eq!(
820 resp.status.as_u16(),
821 400,
822 "Expected 400 for bad v2 signature, got: {}",
823 resp.status
824 );
825 }
826
827 #[tokio::test]
828 async fn webhook_v2_account_event_accepted() {
829 let mut h = TestHarness::with_mocks().await;
830
831 // Uses mock Stripe so fetch_account returns success
832 let payload = serde_json::json!({
833 "id": "evt_v2_acct_001",
834 "type": "v2.core.account.updated",
835 "related_object": {
836 "id": "acct_test_v2_123",
837 "type": "account"
838 }
839 })
840 .to_string();
841
842 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET_V2);
843 let resp = post_v2_raw(&mut h, &payload, &signature).await;
844
845 assert_eq!(
846 resp.status.as_u16(),
847 200,
848 "v2 account event should return 200 even if API fetch fails: {}",
849 resp.text
850 );
851 }
852
853 #[tokio::test]
854 async fn webhook_v2_unknown_event_type_returns_200() {
855 let mut h = TestHarness::with_stripe().await;
856
857 let payload = serde_json::json!({
858 "id": "evt_v2_unknown_001",
859 "type": "v2.billing.meter.no_meter_found",
860 "related_object": {
861 "id": "mtr_123",
862 "type": "billing.meter"
863 }
864 })
865 .to_string();
866
867 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET_V2);
868 let resp = post_v2_raw(&mut h, &payload, &signature).await;
869
870 assert_eq!(
871 resp.status.as_u16(),
872 200,
873 "Unknown v2 event type should return 200: {}",
874 resp.text
875 );
876 }
877
878 // ---------------------------------------------------------------------------
879 // Fan+ subscription webhook lifecycle
880 //
881 // Pins the earlier cascade branches in handle_subscription_updated /
882 // handle_subscription_deleted / handle_invoice_payment_succeeded /
883 // handle_invoice_payment_failed — previously only the generic creator-sub
884 // fallback path was exercised.
885 // ---------------------------------------------------------------------------
886
887 fn make_subscription(stripe_sub_id: &str, status: &str) -> serde_json::Value {
888 serde_json::json!({
889 "id": stripe_sub_id,
890 "object": "subscription",
891 "status": status,
892 "cancel_at_period_end": false,
893 "items": {
894 "object": "list",
895 "data": [{
896 "id": "si_fan_plus_test",
897 "object": "subscription_item",
898 "subscription": stripe_sub_id,
899 "current_period_start": 1700000000_i64,
900 "current_period_end": 1702592000_i64,
901 "metadata": {},
902 }],
903 },
904 })
905 }
906
907 #[tokio::test]
908 async fn webhook_subscription_updated_fan_plus_path() {
909 let mut h = TestHarness::with_stripe().await;
910 let user_id = h.signup("fpupdate", "fpupdate@test.com", "password123").await;
911
912 let stripe_sub_id = "sub_fp_update_1";
913 sqlx::query(
914 r#"INSERT INTO fan_plus_subscriptions
915 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
916 VALUES ($1, $2, 'cus_fp_update', 'active', NOW() + interval '30 days')"#,
917 )
918 .bind(user_id)
919 .bind(stripe_sub_id)
920 .execute(&h.db)
921 .await
922 .unwrap();
923
924 let sub = make_subscription(stripe_sub_id, "past_due");
925 let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await;
926 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
927
928 // fan_plus row reflects the new status — pins that the fan_plus branch
929 // ran and reached `update_fan_plus_status`, not the generic fallback.
930 let status: String = sqlx::query_scalar(
931 "SELECT status::text FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
932 )
933 .bind(stripe_sub_id)
934 .fetch_one(&h.db)
935 .await
936 .unwrap();
937 assert_eq!(status, "past_due");
938 }
939
940 #[tokio::test]
941 async fn webhook_subscription_deleted_fan_plus_path() {
942 let mut h = TestHarness::with_stripe().await;
943 let user_id = h.signup("fpdelete", "fpdelete@test.com", "password123").await;
944
945 let stripe_sub_id = "sub_fp_delete_1";
946 sqlx::query(
947 r#"INSERT INTO fan_plus_subscriptions
948 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
949 VALUES ($1, $2, 'cus_fp_delete', 'active', NOW() + interval '30 days')"#,
950 )
951 .bind(user_id)
952 .bind(stripe_sub_id)
953 .execute(&h.db)
954 .await
955 .unwrap();
956
957 let sub = make_subscription(stripe_sub_id, "canceled");
958 let resp = post_event_json(&mut h, "customer.subscription.deleted", sub).await;
959 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
960
961 // Pins that `cancel_fan_plus` ran. We don't pin the exact column the
962 // cancellation writes to (status vs separate canceled_at); just that
963 // a downstream lookup classifies this user as NOT active.
964 let active: bool = sqlx::query_scalar(
965 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions \
966 WHERE user_id = $1 AND status = 'active' AND canceled_at IS NULL)",
967 )
968 .bind(user_id)
969 .fetch_one(&h.db)
970 .await
971 .unwrap();
972 assert!(!active, "Fan+ subscription must not be active after cancellation");
973 }
974
975 fn make_invoice(stripe_sub_id: &str, billing_reason: &str) -> serde_json::Value {
976 serde_json::json!({
977 "id": "in_test_fp",
978 "object": "invoice",
979 "subscription": stripe_sub_id,
980 "billing_reason": billing_reason,
981 "period_start": 1700000000_i64,
982 "period_end": 1702592000_i64,
983 "currency": "usd",
984 "livemode": false,
985 })
986 }
987
988 #[tokio::test]
989 async fn webhook_invoice_payment_succeeded_updates_fan_plus_period() {
990 let mut h = TestHarness::with_stripe().await;
991 let user_id = h.signup("fpinvoice", "fpinvoice@test.com", "password123").await;
992
993 let stripe_sub_id = "sub_fp_invoice_1";
994 sqlx::query(
995 r#"INSERT INTO fan_plus_subscriptions
996 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
997 VALUES ($1, $2, 'cus_fp_invoice', 'active', NOW())"#,
998 )
999 .bind(user_id)
1000 .bind(stripe_sub_id)
1001 .execute(&h.db)
1002 .await
1003 .unwrap();
1004
1005 // billing_reason != "subscription_cycle" → not a renewal, just updates period.
1006 let invoice = make_invoice(stripe_sub_id, "subscription_create");
1007 let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await;
1008 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1009
1010 // Period_end should now match the invoice's period_end (2024-12-14T22:13:20Z = 1702592000).
1011 let period_end: chrono::DateTime<chrono::Utc> = sqlx::query_scalar(
1012 "SELECT current_period_end FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
1013 )
1014 .bind(stripe_sub_id)
1015 .fetch_one(&h.db)
1016 .await
1017 .unwrap();
1018 assert_eq!(period_end.timestamp(), 1702592000);
1019 }
1020
1021 #[tokio::test]
1022 async fn webhook_invoice_payment_failed_sets_fan_plus_past_due() {
1023 let mut h = TestHarness::with_stripe().await;
1024 let user_id = h.signup("fpfail", "fpfail@test.com", "password123").await;
1025
1026 let stripe_sub_id = "sub_fp_fail_1";
1027 sqlx::query(
1028 r#"INSERT INTO fan_plus_subscriptions
1029 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
1030 VALUES ($1, $2, 'cus_fp_fail', 'active', NOW() + interval '30 days')"#,
1031 )
1032 .bind(user_id)
1033 .bind(stripe_sub_id)
1034 .execute(&h.db)
1035 .await
1036 .unwrap();
1037
1038 let invoice = make_invoice(stripe_sub_id, "subscription_cycle");
1039 let resp = post_event_json(&mut h, "invoice.payment_failed", invoice).await;
1040 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1041
1042 let status: String = sqlx::query_scalar(
1043 "SELECT status::text FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
1044 )
1045 .bind(stripe_sub_id)
1046 .fetch_one(&h.db)
1047 .await
1048 .unwrap();
1049 assert_eq!(status, "past_due", "Fan+ must be flipped to past_due on payment failure");
1050 }
1051
1052 #[tokio::test]
1053 async fn webhook_subscription_updated_unknown_id_returns_200() {
1054 // Pins the fall-through: an event for a stripe_sub_id that has no fan_plus,
1055 // creator_tier, or app_sync row should still return 200 (no-op).
1056 let mut h = TestHarness::with_stripe().await;
1057 let sub = make_subscription("sub_does_not_exist", "active");
1058 let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await;
1059 // Generic path runs `update_subscription_status` which finds nothing —
1060 // current behavior is to return 200 (idempotent / unknown-sub tolerance).
1061 assert_eq!(resp.status.as_u16(), 200, "Unknown sub_id should not error: {}", resp.text);
1062 }
1063