Skip to main content

max / makenotwork

66.2 KB · 1796 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 /// Insert an active creator-tier subscription row and sync the denormalized
428 /// `users.creator_tier` column to match. Returns nothing; the caller owns the ids.
429 async fn insert_active_creator_sub(
430 h: &TestHarness,
431 user_id: UserId,
432 stripe_sub_id: &str,
433 tier: &str,
434 ) {
435 sqlx::query(
436 r#"INSERT INTO creator_subscriptions
437 (user_id, stripe_subscription_id, stripe_customer_id, tier, status)
438 VALUES ($1, $2, 'cus_ct_fixture', $3, 'active')"#,
439 )
440 .bind(user_id)
441 .bind(stripe_sub_id)
442 .bind(tier)
443 .execute(&h.db)
444 .await
445 .unwrap();
446 sqlx::query("UPDATE users SET creator_tier = $2 WHERE id = $1")
447 .bind(user_id)
448 .bind(tier)
449 .execute(&h.db)
450 .await
451 .unwrap();
452 }
453
454 // ---------------------------------------------------------------------------
455 // New tests
456 // ---------------------------------------------------------------------------
457
458 #[tokio::test]
459 async fn webhook_subscription_checkout_completed() {
460 let mut h = TestHarness::with_stripe().await;
461 let fix = setup_subscription_fixture(&mut h).await;
462
463 let stripe_sub_id = "sub_test_checkout_001";
464 let stripe_customer_id = "cus_test_checkout_001";
465
466 // Build checkout session with subscription metadata
467 let mut meta = HashMap::new();
468 meta.insert("checkout_type".to_string(), "subscription".to_string());
469 meta.insert("subscriber_id".to_string(), fix.subscriber_id.to_string());
470 meta.insert("project_id".to_string(), fix.project_id.clone());
471 meta.insert("tier_id".to_string(), fix.tier_id.to_string());
472 let session = serde_json::json!({
473 "id": "cs_test_sub_checkout_001",
474 "object": "checkout_session",
475 "mode": "subscription",
476 "metadata": meta,
477 "subscription": stripe_sub_id,
478 "customer": stripe_customer_id,
479 });
480
481 let resp = post_event_json(&mut h, "checkout.session.completed", session).await;
482 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
483
484 // Verify subscription row was created
485 let (status, sub_stripe_id, sub_customer_id): (String, String, String) = sqlx::query_as(
486 "SELECT status, stripe_subscription_id, stripe_customer_id FROM subscriptions WHERE stripe_subscription_id = $1",
487 )
488 .bind(stripe_sub_id)
489 .fetch_one(&h.db)
490 .await
491 .unwrap();
492
493 assert_eq!(status, "active");
494 assert_eq!(sub_stripe_id, stripe_sub_id);
495 assert_eq!(sub_customer_id, stripe_customer_id);
496 }
497
498 #[tokio::test]
499 async fn webhook_subscription_checkout_completed_idempotent() {
500 let mut h = TestHarness::with_stripe().await;
501 let fix = setup_subscription_fixture(&mut h).await;
502
503 let stripe_sub_id = "sub_test_checkout_idem";
504 let stripe_customer_id = "cus_test_checkout_idem";
505
506 let build_session = || {
507 let mut meta = HashMap::new();
508 meta.insert("checkout_type".to_string(), "subscription".to_string());
509 meta.insert("subscriber_id".to_string(), fix.subscriber_id.to_string());
510 meta.insert("project_id".to_string(), fix.project_id.clone());
511 meta.insert("tier_id".to_string(), fix.tier_id.to_string());
512 serde_json::json!({
513 "id": "cs_test_sub_idem",
514 "object": "checkout_session",
515 "mode": "subscription",
516 "metadata": meta,
517 "subscription": stripe_sub_id,
518 "customer": stripe_customer_id,
519 })
520 };
521
522 // First event
523 let resp = post_event_json(&mut h, "checkout.session.completed", build_session()).await;
524 assert_eq!(resp.status.as_u16(), 200, "First webhook failed: {}", resp.text);
525
526 // Second event (duplicate) — use a different event ID
527 let resp = post_event_json_with_id(&mut h, "evt_test_001", "checkout.session.completed", build_session()).await;
528 assert_eq!(resp.status.as_u16(), 200, "Duplicate webhook should succeed: {}", resp.text);
529
530 // Verify still only one subscription row
531 let count: i64 = sqlx::query_scalar(
532 "SELECT COUNT(*) FROM subscriptions WHERE stripe_subscription_id = $1",
533 )
534 .bind(stripe_sub_id)
535 .fetch_one(&h.db)
536 .await
537 .unwrap();
538 assert_eq!(count, 1, "Should have exactly one subscription row");
539 }
540
541 #[tokio::test]
542 async fn webhook_subscription_updated() {
543 let mut h = TestHarness::with_stripe().await;
544 let fix = setup_subscription_fixture(&mut h).await;
545
546 let stripe_sub_id = "sub_test_updated_001";
547 insert_active_subscription(&h, &fix, stripe_sub_id).await;
548
549 let sub = serde_json::json!({
550 "id": stripe_sub_id,
551 "object": "subscription",
552 "status": "past_due",
553 "cancel_at_period_end": false,
554 "items": {
555 "object": "list",
556 "data": [{
557 "id": "si_test_upd",
558 "object": "subscription_item",
559 "subscription": stripe_sub_id,
560 "current_period_start": 1702592000,
561 "current_period_end": 1705184000,
562 "metadata": {},
563 }],
564 },
565 });
566
567 let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await;
568 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
569
570 // Verify status changed
571 let status: String = sqlx::query_scalar(
572 "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1",
573 )
574 .bind(stripe_sub_id)
575 .fetch_one(&h.db)
576 .await
577 .unwrap();
578 assert_eq!(status, "past_due");
579
580 // Verify period was updated
581 let (period_start, period_end): (Option<chrono::DateTime<chrono::Utc>>, Option<chrono::DateTime<chrono::Utc>>) = sqlx::query_as(
582 "SELECT current_period_start, current_period_end FROM subscriptions WHERE stripe_subscription_id = $1",
583 )
584 .bind(stripe_sub_id)
585 .fetch_one(&h.db)
586 .await
587 .unwrap();
588 assert!(period_start.is_some(), "period_start should be set");
589 assert!(period_end.is_some(), "period_end should be set");
590 assert_eq!(period_start.unwrap().timestamp(), 1702592000);
591 assert_eq!(period_end.unwrap().timestamp(), 1705184000);
592 }
593
594 #[tokio::test]
595 async fn webhook_invoice_payment_succeeded() {
596 let mut h = TestHarness::with_stripe().await;
597 let fix = setup_subscription_fixture(&mut h).await;
598
599 let stripe_sub_id = "sub_test_inv_success";
600 insert_active_subscription(&h, &fix, stripe_sub_id).await;
601
602 let invoice = serde_json::json!({
603 "id": "in_test_success_001",
604 "object": "invoice",
605 "subscription": stripe_sub_id,
606 "period_start": 1702592000,
607 "period_end": 1705184000,
608 "billing_reason": "subscription_cycle",
609 "livemode": false,
610 });
611
612 let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await;
613 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
614
615 // Verify subscription period was updated
616 let (period_start, period_end): (Option<chrono::DateTime<chrono::Utc>>, Option<chrono::DateTime<chrono::Utc>>) = sqlx::query_as(
617 "SELECT current_period_start, current_period_end FROM subscriptions WHERE stripe_subscription_id = $1",
618 )
619 .bind(stripe_sub_id)
620 .fetch_one(&h.db)
621 .await
622 .unwrap();
623 assert!(period_start.is_some(), "period_start should be set");
624 assert!(period_end.is_some(), "period_end should be set");
625 assert_eq!(period_start.unwrap().timestamp(), 1702592000);
626 assert_eq!(period_end.unwrap().timestamp(), 1705184000);
627 }
628
629 #[tokio::test]
630 async fn webhook_invoice_payment_failed() {
631 let mut h = TestHarness::with_stripe().await;
632 let fix = setup_subscription_fixture(&mut h).await;
633
634 let stripe_sub_id = "sub_test_inv_failed";
635 insert_active_subscription(&h, &fix, stripe_sub_id).await;
636
637 let invoice = serde_json::json!({
638 "id": "in_test_failed_001",
639 "object": "invoice",
640 "subscription": stripe_sub_id,
641 "period_start": 1700000000,
642 "period_end": 1702592000,
643 "livemode": false,
644 });
645
646 let resp = post_event_json(&mut h, "invoice.payment_failed", invoice).await;
647 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
648
649 // Verify status changed to past_due
650 let status: String = sqlx::query_scalar(
651 "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1",
652 )
653 .bind(stripe_sub_id)
654 .fetch_one(&h.db)
655 .await
656 .unwrap();
657 assert_eq!(status, "past_due");
658 }
659
660 #[tokio::test]
661 async fn webhook_account_updated_partial() {
662 let mut h = TestHarness::with_stripe().await;
663
664 let user_id = h.signup("partialcreator", "pc@test.com", "password123").await;
665 let acct_id = "acct_test_partial_123";
666 sqlx::query("UPDATE users SET stripe_account_id = $1 WHERE id = $2")
667 .bind(acct_id)
668 .bind(user_id)
669 .execute(&h.db)
670 .await
671 .unwrap();
672
673 // Only details_submitted is true; charges and payouts still false
674 let account = serde_json::json!({
675 "id": acct_id,
676 "object": "account",
677 "charges_enabled": false,
678 "payouts_enabled": false,
679 "details_submitted": true,
680 });
681
682 let resp = post_event_json(&mut h, "account.updated", account).await;
683 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
684
685 let (charges, payouts, onboarding): (bool, bool, bool) = sqlx::query_as(
686 "SELECT stripe_charges_enabled, stripe_payouts_enabled, stripe_onboarding_complete FROM users WHERE id = $1",
687 )
688 .bind(user_id)
689 .fetch_one(&h.db)
690 .await
691 .unwrap();
692
693 assert!(!charges, "charges_enabled should be false");
694 assert!(!payouts, "payouts_enabled should be false");
695 assert!(onboarding, "onboarding_complete should be true");
696 }
697
698 #[tokio::test]
699 async fn webhook_account_updated_unknown_account() {
700 let mut h = TestHarness::with_stripe().await;
701
702 // No user has this stripe_account_id
703 let account = serde_json::json!({
704 "id": "acct_nonexistent",
705 "object": "account",
706 "charges_enabled": true,
707 "payouts_enabled": true,
708 "details_submitted": true,
709 });
710
711 let resp = post_event_json(&mut h, "account.updated", account).await;
712 assert_eq!(
713 resp.status.as_u16(),
714 200,
715 "Unknown account should still return 200: {}",
716 resp.text
717 );
718
719 // Verify no users were affected
720 let count: i64 = sqlx::query_scalar(
721 "SELECT COUNT(*) FROM users WHERE stripe_account_id = 'acct_nonexistent'",
722 )
723 .fetch_one(&h.db)
724 .await
725 .unwrap();
726 assert_eq!(count, 0);
727 }
728
729 #[tokio::test]
730 async fn webhook_purchase_completed_idempotent() {
731 let mut h = TestHarness::with_stripe().await;
732
733 // Create buyer + seller
734 let buyer_id = h.signup("idembuyer", "ib@test.com", "password123").await;
735 h.client.post_form("/logout", "").await;
736 let seller_id = h.signup("idemseller", "is@test.com", "password123").await;
737 h.grant_creator(seller_id).await;
738 h.client.post_form("/logout", "").await;
739 h.login("idemseller", "password123").await;
740
741 // Create project + item
742 let resp = h
743 .client
744 .post_form("/api/projects", "slug=idemproj&title=Idem+Project")
745 .await;
746 let project: Value = resp.json();
747 let project_id = project["id"].as_str().unwrap().to_string();
748 let resp = h
749 .client
750 .post_form(
751 &format!("/api/projects/{}/items", project_id),
752 "title=Idem+Track&price_cents=500&item_type=audio",
753 )
754 .await;
755 let item: Value = resp.json();
756 let item_id = item["id"].as_str().unwrap().to_string();
757
758 // Insert a pending transaction
759 let session_id = "cs_test_idem_001";
760 sqlx::query(
761 r#"INSERT INTO transactions
762 (buyer_id, seller_id, item_id, amount_cents, status,
763 stripe_checkout_session_id, item_title, seller_username)
764 VALUES ($1, $2, $3::uuid, 500, 'pending', $4, 'Idem Track', 'idemseller')"#,
765 )
766 .bind(buyer_id)
767 .bind(seller_id)
768 .bind(&item_id)
769 .bind(session_id)
770 .execute(&h.db)
771 .await
772 .unwrap();
773
774 let build_session = || {
775 let mut meta = HashMap::new();
776 meta.insert("buyer_id".to_string(), buyer_id.to_string());
777 meta.insert("seller_id".to_string(), seller_id.to_string());
778 meta.insert("item_id".to_string(), item_id.clone());
779 serde_json::json!({
780 "id": session_id,
781 "object": "checkout_session",
782 "mode": "payment",
783 "metadata": meta,
784 "payment_intent": "pi_test_idem_001",
785 })
786 };
787
788 // First event
789 let resp = post_event_json(&mut h, "checkout.session.completed", build_session()).await;
790 assert_eq!(resp.status.as_u16(), 200, "First webhook failed: {}", resp.text);
791
792 // Second event (duplicate)
793 let resp = post_event_json_with_id(&mut h, "evt_test_002", "checkout.session.completed", build_session()).await;
794 assert_eq!(resp.status.as_u16(), 200, "Duplicate webhook should succeed: {}", resp.text);
795
796 // Verify still one completed transaction
797 let count: i64 = sqlx::query_scalar(
798 "SELECT COUNT(*) FROM transactions WHERE stripe_checkout_session_id = $1 AND status = 'completed'",
799 )
800 .bind(session_id)
801 .fetch_one(&h.db)
802 .await
803 .unwrap();
804 assert_eq!(count, 1, "Should have exactly one completed transaction");
805
806 // Verify sales_count is still 1 (not incremented twice)
807 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
808 .bind(&item_id)
809 .fetch_one(&h.db)
810 .await
811 .unwrap();
812 assert_eq!(sales, 1, "sales_count should be 1, not 2");
813 }
814
815 /// N1 regression: the dedup mark commits BEFORE the handler runs, so the
816 /// mark/unmark layering must be exact — a wrong move either double-processes a
817 /// redelivered event or silently short-circuits it forever. This pins the
818 /// event-ID dedup primitive directly (the `_idempotent` tests above use
819 /// distinct event IDs, so they only exercise the handler's SQL idempotency, not
820 /// this layer).
821 #[tokio::test]
822 async fn webhook_event_dedup_and_unmark_layering() {
823 use makenotwork::db::webhook_events;
824
825 let h = TestHarness::new().await;
826 let id = "evt_dedup_layer_001";
827
828 // First sighting claims the event.
829 assert!(
830 webhook_events::try_mark_event_processed(&h.db, id).await.unwrap(),
831 "first mark should claim the event"
832 );
833 // Redelivery of the same id is deduped — the handler must not run again.
834 assert!(
835 !webhook_events::try_mark_event_processed(&h.db, id).await.unwrap(),
836 "second mark of the same id must be deduped"
837 );
838 // Handler + retry-queue both failed → unmark so Stripe redelivery re-runs
839 // (otherwise it would short-circuit at the dedup check and 200 without work).
840 webhook_events::unmark_event_processed(&h.db, id).await.unwrap();
841 assert!(
842 webhook_events::try_mark_event_processed(&h.db, id).await.unwrap(),
843 "after unmark, the event must be claimable again so redelivery actually processes"
844 );
845 }
846
847 // ---------------------------------------------------------------------------
848 // v2 thin event tests
849 // ---------------------------------------------------------------------------
850
851 /// Helper to POST a raw JSON payload to /stripe/webhook/v2 with a given signature.
852 async fn post_v2_raw(
853 h: &mut TestHarness,
854 payload: &str,
855 signature: &str,
856 ) -> crate::harness::client::TestResponse {
857 h.client
858 .request_with_headers(
859 "POST",
860 "/stripe/webhook/v2",
861 Some(payload),
862 &[
863 ("stripe-signature", signature),
864 ("content-type", "application/json"),
865 ],
866 )
867 .await
868 }
869
870 #[tokio::test]
871 async fn webhook_v2_invalid_signature() {
872 let mut h = TestHarness::with_stripe().await;
873
874 let payload = r#"{"id":"evt_v2_bad","type":"v2.core.account.updated","related_object":{"id":"acct_123","type":"account"}}"#;
875 let bad_sig = "t=0,v1=00000000000000000000000000000000";
876
877 let resp = post_v2_raw(&mut h, payload, bad_sig).await;
878 assert_eq!(
879 resp.status.as_u16(),
880 400,
881 "Expected 400 for bad v2 signature, got: {}",
882 resp.status
883 );
884 }
885
886 #[tokio::test]
887 async fn webhook_v2_account_event_accepted() {
888 let mut h = TestHarness::with_mocks().await;
889
890 // Uses mock Stripe so fetch_account returns success
891 let payload = serde_json::json!({
892 "id": "evt_v2_acct_001",
893 "type": "v2.core.account.updated",
894 "related_object": {
895 "id": "acct_test_v2_123",
896 "type": "account"
897 }
898 })
899 .to_string();
900
901 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET_V2);
902 let resp = post_v2_raw(&mut h, &payload, &signature).await;
903
904 assert_eq!(
905 resp.status.as_u16(),
906 200,
907 "v2 account event should return 200 even if API fetch fails: {}",
908 resp.text
909 );
910 }
911
912 #[tokio::test]
913 async fn webhook_v2_unknown_event_type_returns_200() {
914 let mut h = TestHarness::with_stripe().await;
915
916 let payload = serde_json::json!({
917 "id": "evt_v2_unknown_001",
918 "type": "v2.billing.meter.no_meter_found",
919 "related_object": {
920 "id": "mtr_123",
921 "type": "billing.meter"
922 }
923 })
924 .to_string();
925
926 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET_V2);
927 let resp = post_v2_raw(&mut h, &payload, &signature).await;
928
929 assert_eq!(
930 resp.status.as_u16(),
931 200,
932 "Unknown v2 event type should return 200: {}",
933 resp.text
934 );
935 }
936
937 // ---------------------------------------------------------------------------
938 // Fan+ subscription webhook lifecycle
939 //
940 // Pins the earlier cascade branches in handle_subscription_updated /
941 // handle_subscription_deleted / handle_invoice_payment_succeeded /
942 // handle_invoice_payment_failed — previously only the generic creator-sub
943 // fallback path was exercised.
944 // ---------------------------------------------------------------------------
945
946 fn make_subscription(stripe_sub_id: &str, status: &str) -> serde_json::Value {
947 serde_json::json!({
948 "id": stripe_sub_id,
949 "object": "subscription",
950 "status": status,
951 "cancel_at_period_end": false,
952 "items": {
953 "object": "list",
954 "data": [{
955 "id": "si_fan_plus_test",
956 "object": "subscription_item",
957 "subscription": stripe_sub_id,
958 "current_period_start": 1700000000_i64,
959 "current_period_end": 1702592000_i64,
960 "metadata": {},
961 }],
962 },
963 })
964 }
965
966 #[tokio::test]
967 async fn webhook_subscription_updated_fan_plus_path() {
968 let mut h = TestHarness::with_stripe().await;
969 let user_id = h.signup("fpupdate", "fpupdate@test.com", "password123").await;
970
971 let stripe_sub_id = "sub_fp_update_1";
972 sqlx::query(
973 r#"INSERT INTO fan_plus_subscriptions
974 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
975 VALUES ($1, $2, 'cus_fp_update', 'active', NOW() + interval '30 days')"#,
976 )
977 .bind(user_id)
978 .bind(stripe_sub_id)
979 .execute(&h.db)
980 .await
981 .unwrap();
982
983 let sub = make_subscription(stripe_sub_id, "past_due");
984 let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await;
985 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
986
987 // fan_plus row reflects the new status — pins that the fan_plus branch
988 // ran and reached `update_fan_plus_status`, not the generic fallback.
989 let status: String = sqlx::query_scalar(
990 "SELECT status::text FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
991 )
992 .bind(stripe_sub_id)
993 .fetch_one(&h.db)
994 .await
995 .unwrap();
996 assert_eq!(status, "past_due");
997 }
998
999 #[tokio::test]
1000 async fn webhook_subscription_deleted_fan_plus_path() {
1001 let mut h = TestHarness::with_stripe().await;
1002 let user_id = h.signup("fpdelete", "fpdelete@test.com", "password123").await;
1003
1004 let stripe_sub_id = "sub_fp_delete_1";
1005 sqlx::query(
1006 r#"INSERT INTO fan_plus_subscriptions
1007 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
1008 VALUES ($1, $2, 'cus_fp_delete', 'active', NOW() + interval '30 days')"#,
1009 )
1010 .bind(user_id)
1011 .bind(stripe_sub_id)
1012 .execute(&h.db)
1013 .await
1014 .unwrap();
1015
1016 let sub = make_subscription(stripe_sub_id, "canceled");
1017 let resp = post_event_json(&mut h, "customer.subscription.deleted", sub).await;
1018 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1019
1020 // Pins that `cancel_fan_plus` ran. We don't pin the exact column the
1021 // cancellation writes to (status vs separate canceled_at); just that
1022 // a downstream lookup classifies this user as NOT active.
1023 let active: bool = sqlx::query_scalar(
1024 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions \
1025 WHERE user_id = $1 AND status = 'active' AND canceled_at IS NULL)",
1026 )
1027 .bind(user_id)
1028 .fetch_one(&h.db)
1029 .await
1030 .unwrap();
1031 assert!(!active, "Fan+ subscription must not be active after cancellation");
1032 }
1033
1034 fn make_invoice(stripe_sub_id: &str, billing_reason: &str) -> serde_json::Value {
1035 serde_json::json!({
1036 "id": "in_test_fp",
1037 "object": "invoice",
1038 "subscription": stripe_sub_id,
1039 "billing_reason": billing_reason,
1040 "period_start": 1700000000_i64,
1041 "period_end": 1702592000_i64,
1042 "currency": "usd",
1043 "livemode": false,
1044 })
1045 }
1046
1047 #[tokio::test]
1048 async fn webhook_invoice_payment_succeeded_updates_fan_plus_period() {
1049 let mut h = TestHarness::with_stripe().await;
1050 let user_id = h.signup("fpinvoice", "fpinvoice@test.com", "password123").await;
1051
1052 let stripe_sub_id = "sub_fp_invoice_1";
1053 sqlx::query(
1054 r#"INSERT INTO fan_plus_subscriptions
1055 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
1056 VALUES ($1, $2, 'cus_fp_invoice', 'active', NOW())"#,
1057 )
1058 .bind(user_id)
1059 .bind(stripe_sub_id)
1060 .execute(&h.db)
1061 .await
1062 .unwrap();
1063
1064 // billing_reason != "subscription_cycle" → not a renewal, just updates period.
1065 let invoice = make_invoice(stripe_sub_id, "subscription_create");
1066 let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await;
1067 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1068
1069 // Period_end should now match the invoice's period_end (2024-12-14T22:13:20Z = 1702592000).
1070 let period_end: chrono::DateTime<chrono::Utc> = sqlx::query_scalar(
1071 "SELECT current_period_end FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
1072 )
1073 .bind(stripe_sub_id)
1074 .fetch_one(&h.db)
1075 .await
1076 .unwrap();
1077 assert_eq!(period_end.timestamp(), 1702592000);
1078 }
1079
1080 #[tokio::test]
1081 async fn webhook_invoice_payment_failed_sets_fan_plus_past_due() {
1082 let mut h = TestHarness::with_stripe().await;
1083 let user_id = h.signup("fpfail", "fpfail@test.com", "password123").await;
1084
1085 let stripe_sub_id = "sub_fp_fail_1";
1086 sqlx::query(
1087 r#"INSERT INTO fan_plus_subscriptions
1088 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
1089 VALUES ($1, $2, 'cus_fp_fail', 'active', NOW() + interval '30 days')"#,
1090 )
1091 .bind(user_id)
1092 .bind(stripe_sub_id)
1093 .execute(&h.db)
1094 .await
1095 .unwrap();
1096
1097 let invoice = make_invoice(stripe_sub_id, "subscription_cycle");
1098 let resp = post_event_json(&mut h, "invoice.payment_failed", invoice).await;
1099 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1100
1101 let status: String = sqlx::query_scalar(
1102 "SELECT status::text FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
1103 )
1104 .bind(stripe_sub_id)
1105 .fetch_one(&h.db)
1106 .await
1107 .unwrap();
1108 assert_eq!(status, "past_due", "Fan+ must be flipped to past_due on payment failure");
1109 }
1110
1111 #[tokio::test]
1112 async fn webhook_subscription_updated_unknown_id_returns_200() {
1113 // Pins the fall-through: an event for a stripe_sub_id that has no fan_plus,
1114 // creator_tier, or app_sync row should still return 200 (no-op).
1115 let mut h = TestHarness::with_stripe().await;
1116 let sub = make_subscription("sub_does_not_exist", "active");
1117 let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await;
1118 // Generic path runs `update_subscription_status` which finds nothing —
1119 // current behavior is to return 200 (idempotent / unknown-sub tolerance).
1120 assert_eq!(resp.status.as_u16(), 200, "Unknown sub_id should not error: {}", resp.text);
1121 }
1122
1123 // ---------------------------------------------------------------------------
1124 // Creator-tier subscription webhook lifecycle (test-fuzz Phase 2.1)
1125 //
1126 // The platform's own revenue ($16-60/mo). Before this block, the creator_tier
1127 // branch in every billing/subscription handler was untested — only fan_plus and
1128 // the generic creator-sub fallback were exercised. These pin that the
1129 // creator_tier branch runs AND keeps the denormalized `users.creator_tier`
1130 // column in sync (sync_user_creator_tier sets it to the active sub's tier, or
1131 // NULL when no active sub remains).
1132 // ---------------------------------------------------------------------------
1133
1134 #[tokio::test]
1135 async fn webhook_creator_tier_checkout_completed() {
1136 let mut h = TestHarness::with_stripe().await;
1137 let user_id = h.signup("ctcheckout", "ctcheckout@test.com", "password123").await;
1138
1139 let stripe_sub_id = "sub_ct_checkout_1";
1140 let mut meta = HashMap::new();
1141 meta.insert("checkout_type".to_string(), "creator_tier".to_string());
1142 meta.insert("user_id".to_string(), user_id.to_string());
1143 meta.insert("tier".to_string(), "small_files".to_string());
1144 let session = serde_json::json!({
1145 "id": "cs_ct_checkout_1",
1146 "object": "checkout_session",
1147 "mode": "subscription",
1148 "metadata": meta,
1149 "subscription": stripe_sub_id,
1150 "customer": "cus_ct_checkout_1",
1151 });
1152
1153 let resp = post_event_json(&mut h, "checkout.session.completed", session).await;
1154 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1155
1156 // Subscription row created with the right tier + active status.
1157 let (status, tier): (String, String) = sqlx::query_as(
1158 "SELECT status, tier FROM creator_subscriptions WHERE stripe_subscription_id = $1",
1159 )
1160 .bind(stripe_sub_id)
1161 .fetch_one(&h.db)
1162 .await
1163 .unwrap();
1164 assert_eq!(status, "active");
1165 assert_eq!(tier, "small_files");
1166
1167 // Denormalized column synced on the user.
1168 let user_tier: Option<String> = sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = $1")
1169 .bind(user_id)
1170 .fetch_one(&h.db)
1171 .await
1172 .unwrap();
1173 assert_eq!(user_tier.as_deref(), Some("small_files"), "users.creator_tier must be synced");
1174 }
1175
1176 #[tokio::test]
1177 async fn webhook_invoice_payment_succeeded_updates_creator_tier_period() {
1178 let mut h = TestHarness::with_stripe().await;
1179 let user_id = h.signup("ctinvoice", "ctinvoice@test.com", "password123").await;
1180
1181 let stripe_sub_id = "sub_ct_invoice_1";
1182 insert_active_creator_sub(&h, user_id, stripe_sub_id, "big_files").await;
1183
1184 let invoice = make_invoice(stripe_sub_id, "subscription_cycle");
1185 let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await;
1186 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1187
1188 let (period_start, period_end): (
1189 Option<chrono::DateTime<chrono::Utc>>,
1190 Option<chrono::DateTime<chrono::Utc>>,
1191 ) = sqlx::query_as(
1192 "SELECT current_period_start, current_period_end FROM creator_subscriptions WHERE stripe_subscription_id = $1",
1193 )
1194 .bind(stripe_sub_id)
1195 .fetch_one(&h.db)
1196 .await
1197 .unwrap();
1198 assert_eq!(period_start.unwrap().timestamp(), 1700000000);
1199 assert_eq!(period_end.unwrap().timestamp(), 1702592000);
1200
1201 // Tier remains live after a successful renewal.
1202 let user_tier: Option<String> = sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = $1")
1203 .bind(user_id)
1204 .fetch_one(&h.db)
1205 .await
1206 .unwrap();
1207 assert_eq!(user_tier.as_deref(), Some("big_files"));
1208 }
1209
1210 #[tokio::test]
1211 async fn webhook_invoice_payment_failed_sets_creator_tier_past_due() {
1212 let mut h = TestHarness::with_stripe().await;
1213 let user_id = h.signup("ctfail", "ctfail@test.com", "password123").await;
1214
1215 let stripe_sub_id = "sub_ct_fail_1";
1216 insert_active_creator_sub(&h, user_id, stripe_sub_id, "everything").await;
1217
1218 let invoice = make_invoice(stripe_sub_id, "subscription_cycle");
1219 let resp = post_event_json(&mut h, "invoice.payment_failed", invoice).await;
1220 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1221
1222 let status: String = sqlx::query_scalar(
1223 "SELECT status FROM creator_subscriptions WHERE stripe_subscription_id = $1",
1224 )
1225 .bind(stripe_sub_id)
1226 .fetch_one(&h.db)
1227 .await
1228 .unwrap();
1229 assert_eq!(status, "past_due");
1230
1231 // A past_due creator sub is no longer active, so the denormalized tier is cleared —
1232 // this is the gate that downstream enforcement reads to start the grace clock.
1233 let user_tier: Option<String> = sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = $1")
1234 .bind(user_id)
1235 .fetch_one(&h.db)
1236 .await
1237 .unwrap();
1238 assert_eq!(user_tier, None, "users.creator_tier must clear when the sub goes past_due");
1239 }
1240
1241 #[tokio::test]
1242 async fn webhook_subscription_updated_creator_tier_path() {
1243 let mut h = TestHarness::with_stripe().await;
1244 let user_id = h.signup("ctupd", "ctupd@test.com", "password123").await;
1245
1246 let stripe_sub_id = "sub_ct_upd_1";
1247 insert_active_creator_sub(&h, user_id, stripe_sub_id, "small_files").await;
1248
1249 let sub = make_subscription(stripe_sub_id, "past_due");
1250 let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await;
1251 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1252
1253 let (status, period_end): (String, Option<chrono::DateTime<chrono::Utc>>) = sqlx::query_as(
1254 "SELECT status, current_period_end FROM creator_subscriptions WHERE stripe_subscription_id = $1",
1255 )
1256 .bind(stripe_sub_id)
1257 .fetch_one(&h.db)
1258 .await
1259 .unwrap();
1260 assert_eq!(status, "past_due");
1261 assert_eq!(period_end.unwrap().timestamp(), 1702592000, "period must be updated from the event");
1262
1263 let user_tier: Option<String> = sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = $1")
1264 .bind(user_id)
1265 .fetch_one(&h.db)
1266 .await
1267 .unwrap();
1268 assert_eq!(user_tier, None, "tier sync must run on the creator_tier update branch");
1269 }
1270
1271 #[tokio::test]
1272 async fn webhook_subscription_deleted_creator_tier_path() {
1273 let mut h = TestHarness::with_stripe().await;
1274 let user_id = h.signup("ctdel", "ctdel@test.com", "password123").await;
1275
1276 let stripe_sub_id = "sub_ct_del_1";
1277 insert_active_creator_sub(&h, user_id, stripe_sub_id, "everything").await;
1278
1279 let sub = make_subscription(stripe_sub_id, "canceled");
1280 let resp = post_event_json(&mut h, "customer.subscription.deleted", sub).await;
1281 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1282
1283 let status: String = sqlx::query_scalar(
1284 "SELECT status FROM creator_subscriptions WHERE stripe_subscription_id = $1",
1285 )
1286 .bind(stripe_sub_id)
1287 .fetch_one(&h.db)
1288 .await
1289 .unwrap();
1290 assert_eq!(status, "canceled");
1291
1292 let user_tier: Option<String> = sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = $1")
1293 .bind(user_id)
1294 .fetch_one(&h.db)
1295 .await
1296 .unwrap();
1297 assert_eq!(user_tier, None, "users.creator_tier must clear on cancellation");
1298 }
1299
1300 // ---------------------------------------------------------------------------
1301 // Fan+ renewal credit code (test-fuzz Phase 2.1)
1302 //
1303 // The earlier fan+ invoice test deliberately uses billing_reason=subscription_create
1304 // to avoid the renewal branch. THIS pins the renewal branch: a subscription_cycle
1305 // invoice generates the $5 single-use platform promo code that funds the Fan+
1306 // monthly credit. That code is a real DB write nothing previously exercised.
1307 // ---------------------------------------------------------------------------
1308
1309 #[tokio::test]
1310 async fn webhook_invoice_payment_succeeded_fan_plus_renewal_generates_credit() {
1311 let mut h = TestHarness::with_stripe().await;
1312 let user_id = h.signup("fpcredit", "fpcredit@test.com", "password123").await;
1313
1314 let stripe_sub_id = "sub_fp_credit_1";
1315 sqlx::query(
1316 r#"INSERT INTO fan_plus_subscriptions
1317 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
1318 VALUES ($1, $2, 'cus_fp_credit', 'active', NOW())"#,
1319 )
1320 .bind(user_id)
1321 .bind(stripe_sub_id)
1322 .execute(&h.db)
1323 .await
1324 .unwrap();
1325
1326 // subscription_cycle == a renewal, which triggers the credit-code path.
1327 let invoice = make_invoice(stripe_sub_id, "subscription_cycle");
1328 let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await;
1329 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1330
1331 // Exactly one $5 single-use fixed-discount promo code now exists for this user.
1332 let (count, discount_type, discount_value, max_uses): (i64, Option<String>, Option<i32>, Option<i32>) =
1333 sqlx::query_as(
1334 "SELECT COUNT(*), MIN(discount_type), MIN(discount_value), MIN(max_uses) \
1335 FROM promo_codes WHERE creator_id = $1 AND code_purpose = 'discount'",
1336 )
1337 .bind(user_id)
1338 .fetch_one(&h.db)
1339 .await
1340 .unwrap();
1341 assert_eq!(count, 1, "renewal must mint exactly one Fan+ credit code");
1342 assert_eq!(discount_type.as_deref(), Some("fixed"));
1343 assert_eq!(discount_value, Some(500), "$5 credit");
1344 assert_eq!(max_uses, Some(1), "single use");
1345 }
1346
1347 // ---------------------------------------------------------------------------
1348 // Generic creator-sub renewal email gating (test-fuzz Phase 2.1)
1349 //
1350 // The generic invoice.payment_succeeded test only asserts the period write. The
1351 // renewal-email side effect — sent on a renewal, suppressed on the first invoice
1352 // — was untested. These use with_mocks() to capture the email.
1353 // ---------------------------------------------------------------------------
1354
1355 #[tokio::test]
1356 async fn webhook_invoice_payment_succeeded_renewal_sends_email() {
1357 let mut h = TestHarness::with_mocks().await;
1358 let fix = setup_subscription_fixture(&mut h).await;
1359 let stripe_sub_id = "sub_renewal_email_1";
1360 insert_active_subscription(&h, &fix, stripe_sub_id).await;
1361
1362 let invoice = make_invoice(stripe_sub_id, "subscription_cycle");
1363 let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await;
1364 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1365
1366 // Fire-and-forget email task — give it a beat to land.
1367 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1368
1369 let mock_email = h.mock_email.as_ref().unwrap();
1370 let mails = mock_email.sent_to("subuser@test.com");
1371 assert!(
1372 mails.iter().any(|e| e.subject.contains("renewed")),
1373 "renewal must send a 'renewed' email, got: {:?}",
1374 mails.iter().map(|e| &e.subject).collect::<Vec<_>>()
1375 );
1376 }
1377
1378 #[tokio::test]
1379 async fn webhook_invoice_payment_succeeded_first_invoice_sends_no_renewal_email() {
1380 let mut h = TestHarness::with_mocks().await;
1381 let fix = setup_subscription_fixture(&mut h).await;
1382 let stripe_sub_id = "sub_first_invoice_1";
1383 insert_active_subscription(&h, &fix, stripe_sub_id).await;
1384
1385 // subscription_create is the FIRST invoice — not a renewal.
1386 let invoice = make_invoice(stripe_sub_id, "subscription_create");
1387 let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await;
1388 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1389
1390 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1391
1392 let mock_email = h.mock_email.as_ref().unwrap();
1393 let mails = mock_email.sent_to("subuser@test.com");
1394 assert!(
1395 !mails.iter().any(|e| e.subject.contains("renewed")),
1396 "first invoice must NOT send a renewal email, got: {:?}",
1397 mails.iter().map(|e| &e.subject).collect::<Vec<_>>()
1398 );
1399 }
1400
1401 // ---------------------------------------------------------------------------
1402 // charge.refunded edge paths (test-fuzz Phase 2.1)
1403 //
1404 // Only the full-refund-with-matching-transaction path was tested. The partial
1405 // refund (preserve access) and no-match (queue a pending refund) branches —
1406 // both money-critical — were not.
1407 // ---------------------------------------------------------------------------
1408
1409 #[tokio::test]
1410 async fn webhook_charge_refunded_partial_preserves_access() {
1411 let mut h = TestHarness::with_stripe().await;
1412
1413 let buyer_id = h.signup("prbuyer", "prb@test.com", "password123").await;
1414 h.client.post_form("/logout", "").await;
1415 let seller_id = h.signup("prseller", "prs@test.com", "password123").await;
1416 h.grant_creator(seller_id).await;
1417 h.client.post_form("/logout", "").await;
1418 h.login("prseller", "password123").await;
1419
1420 let resp = h
1421 .client
1422 .post_form("/api/projects", "slug=partialproj&title=Partial+Project")
1423 .await;
1424 let project: Value = resp.json();
1425 let project_id = project["id"].as_str().unwrap().to_string();
1426 let resp = h
1427 .client
1428 .post_form(
1429 &format!("/api/projects/{}/items", project_id),
1430 "title=Partial+Track&price_cents=1000&item_type=audio",
1431 )
1432 .await;
1433 let item: Value = resp.json();
1434 let item_id = item["id"].as_str().unwrap().to_string();
1435
1436 let pi_id = "pi_partial_refund_1";
1437 sqlx::query("UPDATE items SET sales_count = 1 WHERE id = $1::uuid")
1438 .bind(&item_id)
1439 .execute(&h.db)
1440 .await
1441 .unwrap();
1442 sqlx::query(
1443 r#"INSERT INTO transactions
1444 (buyer_id, seller_id, item_id, amount_cents, status,
1445 stripe_payment_intent_id, stripe_checkout_session_id,
1446 item_title, seller_username, completed_at)
1447 VALUES ($1, $2, $3::uuid, 1000, 'completed', $4, 'cs_partial', 'Partial Track', 'prseller', NOW())"#,
1448 )
1449 .bind(buyer_id)
1450 .bind(seller_id)
1451 .bind(&item_id)
1452 .bind(pi_id)
1453 .execute(&h.db)
1454 .await
1455 .unwrap();
1456
1457 // Refund only 400 of 1000 cents — a partial refund.
1458 let charge = serde_json::json!({
1459 "id": "ch_partial_1",
1460 "object": "charge",
1461 "amount": 1000,
1462 "amount_refunded": 400,
1463 "payment_intent": pi_id,
1464 });
1465 let resp = post_event_json(&mut h, "charge.refunded", charge).await;
1466 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1467
1468 // Access preserved: the transaction is NOT marked refunded and the sale stands.
1469 let status: String = sqlx::query_scalar(
1470 "SELECT status FROM transactions WHERE stripe_payment_intent_id = $1",
1471 )
1472 .bind(pi_id)
1473 .fetch_one(&h.db)
1474 .await
1475 .unwrap();
1476 assert_eq!(status, "completed", "partial refund must NOT revoke access");
1477
1478 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
1479 .bind(&item_id)
1480 .fetch_one(&h.db)
1481 .await
1482 .unwrap();
1483 assert_eq!(sales, 1, "partial refund must NOT decrement the sale");
1484 }
1485
1486 #[tokio::test]
1487 async fn webhook_charge_refunded_no_transaction_queues_pending() {
1488 let mut h = TestHarness::with_stripe().await;
1489
1490 // A full refund whose payment_intent matches no transaction or tip —
1491 // the payment webhook likely hasn't arrived yet. Must be queued, not dropped.
1492 let pi_id = "pi_orphan_refund_1";
1493 let charge = serde_json::json!({
1494 "id": "ch_orphan_1",
1495 "object": "charge",
1496 "amount": 1500,
1497 "amount_refunded": 1500,
1498 "payment_intent": pi_id,
1499 });
1500 let resp = post_event_json(&mut h, "charge.refunded", charge).await;
1501 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1502
1503 let (amount, amount_refunded): (i64, i64) = sqlx::query_as(
1504 "SELECT amount, amount_refunded FROM pending_refunds WHERE payment_intent_id = $1",
1505 )
1506 .bind(pi_id)
1507 .fetch_one(&h.db)
1508 .await
1509 .expect("an unmatched full refund must be queued as a pending refund");
1510 assert_eq!(amount, 1500);
1511 assert_eq!(amount_refunded, 1500);
1512 }
1513
1514 // ---------------------------------------------------------------------------
1515 // Subscription / invoice edge cases (test-fuzz Phase 2.1)
1516 // ---------------------------------------------------------------------------
1517
1518 #[tokio::test]
1519 async fn webhook_subscription_updated_unknown_status_is_noop() {
1520 // Stripe periodically adds statuses (e.g. "paused"). The handler must treat
1521 // an unknown status as a no-op — returning Err would pin Stripe in an
1522 // infinite retry storm. The existing subscription must keep its prior status.
1523 let mut h = TestHarness::with_stripe().await;
1524 let fix = setup_subscription_fixture(&mut h).await;
1525 let stripe_sub_id = "sub_unknown_status_1";
1526 insert_active_subscription(&h, &fix, stripe_sub_id).await;
1527
1528 let sub = make_subscription(stripe_sub_id, "paused"); // not a known SubscriptionStatus
1529 let resp = post_event_json(&mut h, "customer.subscription.updated", sub).await;
1530 assert_eq!(resp.status.as_u16(), 200, "unknown status must not error: {}", resp.text);
1531
1532 let status: String = sqlx::query_scalar(
1533 "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1",
1534 )
1535 .bind(stripe_sub_id)
1536 .fetch_one(&h.db)
1537 .await
1538 .unwrap();
1539 assert_eq!(status, "active", "unknown status must be a no-op, leaving status untouched");
1540 }
1541
1542 #[tokio::test]
1543 async fn webhook_invoice_without_subscription_is_noop() {
1544 // A non-subscription invoice (no subscription id anywhere) must short-circuit
1545 // to Ok without touching any subscription table.
1546 let mut h = TestHarness::with_stripe().await;
1547 let invoice = serde_json::json!({
1548 "id": "in_no_sub_1",
1549 "object": "invoice",
1550 "period_start": 1700000000_i64,
1551 "period_end": 1702592000_i64,
1552 "billing_reason": "manual",
1553 "livemode": false,
1554 });
1555 let resp = post_event_json(&mut h, "invoice.payment_succeeded", invoice).await;
1556 assert_eq!(resp.status.as_u16(), 200, "invoice without subscription must be a 200 no-op: {}", resp.text);
1557 }
1558
1559 // ---------------------------------------------------------------------------
1560 // Adversarial: malformed fulfillment metadata (test-fuzz Phase 2.1)
1561 //
1562 // A checkout.session.completed whose metadata is missing the required buyer_id
1563 // makes the handler error. The dispatcher must queue it for retry (a row in
1564 // webhook_events) and still 200 Stripe — never drop it, never 500.
1565 // ---------------------------------------------------------------------------
1566
1567 #[tokio::test]
1568 async fn webhook_purchase_missing_buyer_id_metadata_is_queued() {
1569 let mut h = TestHarness::with_stripe().await;
1570 let seller_id = h.signup("mmseller", "mms@test.com", "password123").await;
1571
1572 // No checkout_type → routed to the purchase handler, whose
1573 // CheckoutMetadata::from_metadata requires buyer_id and errors without it.
1574 let mut meta = HashMap::new();
1575 meta.insert("seller_id".to_string(), seller_id.to_string());
1576 let session = serde_json::json!({
1577 "id": "cs_missing_buyer_1",
1578 "object": "checkout_session",
1579 "mode": "payment",
1580 "metadata": meta,
1581 "payment_intent": "pi_missing_buyer_1",
1582 });
1583
1584 let resp = post_event_json(&mut h, "checkout.session.completed", session).await;
1585 assert_eq!(resp.status.as_u16(), 200, "malformed event must still 200 after queueing: {}", resp.text);
1586
1587 // The failed handler queued the event for retry rather than dropping it.
1588 let queued: i64 = sqlx::query_scalar(
1589 "SELECT COUNT(*) FROM webhook_events WHERE source = 'stripe' AND event_type = 'checkout.session.completed'",
1590 )
1591 .fetch_one(&h.db)
1592 .await
1593 .unwrap();
1594 assert_eq!(queued, 1, "a handler error must enqueue exactly one retry row");
1595 }
1596
1597 // ---------------------------------------------------------------------------
1598 // Canceled is terminal: an out-of-order update must not revive it (Run #11 fix)
1599 // ---------------------------------------------------------------------------
1600
1601 #[tokio::test]
1602 async fn webhook_subscription_updated_cannot_revive_canceled_sub() {
1603 let mut h = TestHarness::with_stripe().await;
1604 let fix = setup_subscription_fixture(&mut h).await;
1605 let stripe_sub_id = "sub_revival_guard_1";
1606 insert_active_subscription(&h, &fix, stripe_sub_id).await;
1607
1608 // Cancel it (terminal state).
1609 let resp = post_event_json_with_id(
1610 &mut h, "evt_revive_del", "customer.subscription.deleted",
1611 make_subscription(stripe_sub_id, "canceled"),
1612 ).await;
1613 assert_eq!(resp.status.as_u16(), 200, "delete failed: {}", resp.text);
1614 let status: String = sqlx::query_scalar(
1615 "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1",
1616 ).bind(stripe_sub_id).fetch_one(&h.db).await.unwrap();
1617 assert_eq!(status, "canceled");
1618
1619 // An out-of-order `updated`(active) arrives AFTER the cancellation — distinct
1620 // event id, so the dedup layer does NOT short-circuit it. The DB guard must
1621 // refuse to revive the canceled subscription.
1622 let resp = post_event_json_with_id(
1623 &mut h, "evt_revive_upd", "customer.subscription.updated",
1624 make_subscription(stripe_sub_id, "active"),
1625 ).await;
1626 assert_eq!(resp.status.as_u16(), 200, "update must not error: {}", resp.text);
1627 let status: String = sqlx::query_scalar(
1628 "SELECT status FROM subscriptions WHERE stripe_subscription_id = $1",
1629 ).bind(stripe_sub_id).fetch_one(&h.db).await.unwrap();
1630 assert_eq!(status, "canceled", "canceled is terminal — an out-of-order update must not revive it");
1631 }
1632
1633 #[tokio::test]
1634 async fn webhook_subscription_updated_cannot_revive_canceled_creator_tier() {
1635 let mut h = TestHarness::with_stripe().await;
1636 let user_id = h.signup("ctrevive", "ctrevive@test.com", "password123").await;
1637 let stripe_sub_id = "sub_ct_revive_1";
1638 insert_active_creator_sub(&h, user_id, stripe_sub_id, "small_files").await;
1639
1640 // Cancel — clears the denormalized tier.
1641 let resp = post_event_json_with_id(
1642 &mut h, "evt_ctrevive_del", "customer.subscription.deleted",
1643 make_subscription(stripe_sub_id, "canceled"),
1644 ).await;
1645 assert_eq!(resp.status.as_u16(), 200, "delete failed: {}", resp.text);
1646
1647 // Out-of-order active update must not revive the canceled creator sub.
1648 let resp = post_event_json_with_id(
1649 &mut h, "evt_ctrevive_upd", "customer.subscription.updated",
1650 make_subscription(stripe_sub_id, "active"),
1651 ).await;
1652 assert_eq!(resp.status.as_u16(), 200, "update must not error: {}", resp.text);
1653
1654 let status: String = sqlx::query_scalar(
1655 "SELECT status FROM creator_subscriptions WHERE stripe_subscription_id = $1",
1656 ).bind(stripe_sub_id).fetch_one(&h.db).await.unwrap();
1657 assert_eq!(status, "canceled", "canceled creator sub must not be revived");
1658 let tier: Option<String> = sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = $1")
1659 .bind(user_id).fetch_one(&h.db).await.unwrap();
1660 assert_eq!(tier, None, "denormalized tier must stay cleared after a refused revival");
1661 }
1662
1663 #[tokio::test]
1664 async fn webhook_subscription_updated_cannot_revive_canceled_fan_plus() {
1665 // Fan+ was the sibling the Run #11 revival guard missed (Run #12 SERIOUS).
1666 let mut h = TestHarness::with_stripe().await;
1667 let user_id = h.signup("fprevive", "fprevive@test.com", "password123").await;
1668
1669 let stripe_sub_id = "sub_fp_revive_1";
1670 sqlx::query(
1671 r#"INSERT INTO fan_plus_subscriptions
1672 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
1673 VALUES ($1, $2, 'cus_fp_revive', 'active', NOW() + interval '30 days')"#,
1674 )
1675 .bind(user_id)
1676 .bind(stripe_sub_id)
1677 .execute(&h.db)
1678 .await
1679 .unwrap();
1680
1681 // Cancel (terminal).
1682 let resp = post_event_json_with_id(
1683 &mut h, "evt_fp_revive_del", "customer.subscription.deleted",
1684 make_subscription(stripe_sub_id, "canceled"),
1685 ).await;
1686 assert_eq!(resp.status.as_u16(), 200, "delete failed: {}", resp.text);
1687
1688 // Out-of-order active update must NOT revive it.
1689 let resp = post_event_json_with_id(
1690 &mut h, "evt_fp_revive_upd", "customer.subscription.updated",
1691 make_subscription(stripe_sub_id, "active"),
1692 ).await;
1693 assert_eq!(resp.status.as_u16(), 200, "update must not error: {}", resp.text);
1694
1695 // The downstream "is this user Fan+?" check must classify them as NOT active.
1696 let active: bool = sqlx::query_scalar(
1697 "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions \
1698 WHERE user_id = $1 AND status = 'active' AND canceled_at IS NULL)",
1699 )
1700 .bind(user_id)
1701 .fetch_one(&h.db)
1702 .await
1703 .unwrap();
1704 assert!(!active, "a canceled Fan+ sub must not be revived by an out-of-order update");
1705 }
1706
1707 // ---------------------------------------------------------------------------
1708 // Canceled is terminal for the PERIOD too: an out-of-order invoice.paid must
1709 // not refresh current_period_* on a canceled row. This is the sibling the
1710 // status guard used to miss — now status + period are written together under
1711 // one guard, so the period write can't bypass it. (Run #13 chronic fix.)
1712 // ---------------------------------------------------------------------------
1713
1714 #[tokio::test]
1715 async fn webhook_invoice_paid_cannot_refresh_period_on_canceled_sub() {
1716 let mut h = TestHarness::with_stripe().await;
1717 let fix = setup_subscription_fixture(&mut h).await;
1718 let stripe_sub_id = "sub_period_guard_1";
1719 insert_active_subscription(&h, &fix, stripe_sub_id).await;
1720
1721 // Cancel (terminal).
1722 let resp = post_event_json_with_id(
1723 &mut h, "evt_periodguard_del", "customer.subscription.deleted",
1724 make_subscription(stripe_sub_id, "canceled"),
1725 ).await;
1726 assert_eq!(resp.status.as_u16(), 200, "delete failed: {}", resp.text);
1727
1728 let before: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
1729 "SELECT current_period_end FROM subscriptions WHERE stripe_subscription_id = $1",
1730 ).bind(stripe_sub_id).fetch_one(&h.db).await.unwrap();
1731
1732 // A stray invoice.payment_succeeded (period_end 1702592000) arrives after cancel.
1733 let resp = post_event_json(&mut h, "invoice.payment_succeeded", make_invoice(stripe_sub_id, "subscription_cycle")).await;
1734 assert_eq!(resp.status.as_u16(), 200, "invoice must not error: {}", resp.text);
1735
1736 let after: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
1737 "SELECT current_period_end FROM subscriptions WHERE stripe_subscription_id = $1",
1738 ).bind(stripe_sub_id).fetch_one(&h.db).await.unwrap();
1739 assert_eq!(after, before, "period must not be refreshed on a canceled subscription");
1740 assert_ne!(after.map(|d| d.timestamp()), Some(1702592000), "invoice period must not land on a canceled row");
1741 }
1742
1743 #[tokio::test]
1744 async fn webhook_invoice_paid_cannot_refresh_period_on_canceled_creator_tier() {
1745 let mut h = TestHarness::with_stripe().await;
1746 let user_id = h.signup("ctperiod", "ctperiod@test.com", "password123").await;
1747 let stripe_sub_id = "sub_ct_period_1";
1748 insert_active_creator_sub(&h, user_id, stripe_sub_id, "small_files").await;
1749
1750 let resp = post_event_json_with_id(
1751 &mut h, "evt_ctperiod_del", "customer.subscription.deleted",
1752 make_subscription(stripe_sub_id, "canceled"),
1753 ).await;
1754 assert_eq!(resp.status.as_u16(), 200, "delete failed: {}", resp.text);
1755
1756 let before: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
1757 "SELECT current_period_end FROM creator_subscriptions WHERE stripe_subscription_id = $1",
1758 ).bind(stripe_sub_id).fetch_one(&h.db).await.unwrap();
1759
1760 let resp = post_event_json(&mut h, "invoice.payment_succeeded", make_invoice(stripe_sub_id, "subscription_cycle")).await;
1761 assert_eq!(resp.status.as_u16(), 200, "invoice must not error: {}", resp.text);
1762
1763 let after: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
1764 "SELECT current_period_end FROM creator_subscriptions WHERE stripe_subscription_id = $1",
1765 ).bind(stripe_sub_id).fetch_one(&h.db).await.unwrap();
1766 assert_eq!(after, before, "period must not be refreshed on a canceled creator sub");
1767 }
1768
1769 #[tokio::test]
1770 async fn webhook_invoice_paid_cannot_refresh_period_on_canceled_fan_plus() {
1771 let mut h = TestHarness::with_stripe().await;
1772 let user_id = h.signup("fpperiod", "fpperiod@test.com", "password123").await;
1773 let stripe_sub_id = "sub_fp_period_1";
1774 sqlx::query(
1775 r#"INSERT INTO fan_plus_subscriptions
1776 (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end)
1777 VALUES ($1, $2, 'cus_fp_period', 'active', to_timestamp(1000000000))"#,
1778 )
1779 .bind(user_id).bind(stripe_sub_id).execute(&h.db).await.unwrap();
1780
1781 let resp = post_event_json_with_id(
1782 &mut h, "evt_fpperiod_del", "customer.subscription.deleted",
1783 make_subscription(stripe_sub_id, "canceled"),
1784 ).await;
1785 assert_eq!(resp.status.as_u16(), 200, "delete failed: {}", resp.text);
1786
1787 // Stray invoice.paid with a DIFFERENT period_end (1702592000) must be refused.
1788 let resp = post_event_json(&mut h, "invoice.payment_succeeded", make_invoice(stripe_sub_id, "subscription_cycle")).await;
1789 assert_eq!(resp.status.as_u16(), 200, "invoice must not error: {}", resp.text);
1790
1791 let after: chrono::DateTime<chrono::Utc> = sqlx::query_scalar(
1792 "SELECT current_period_end FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
1793 ).bind(stripe_sub_id).fetch_one(&h.db).await.unwrap();
1794 assert_eq!(after.timestamp(), 1000000000, "period must stay frozen on a canceled Fan+ sub, not jump to the invoice's period_end");
1795 }
1796