//! Route-layer contract tests for `routes::stripe::webhook::billing`, the //! invoice half of the exactly-once protocol. //! //! Stripe redelivers, so a renewal invoice arrives more than once and the money //! contract is that it mints one $5 Fan+ credit however many times it lands. //! Pinned here: the credit is minted exactly once per renewal period and its //! value is asserted in cents; the first invoice of a subscription refreshes //! the period and mints nothing, so the renewal boundary is asserted on both //! sides; an invoice arriving after cancellation cannot refresh the period on //! the canceled row; and `invoice.payment_failed` writes the status without //! touching the period. //! //! The checkout half is `stripe_webhook_exactly_once`. //! //! Delete this file and a redelivered renewal invoice could mint a second //! credit, which is a direct cash loss with no error anywhere. use crate::harness::TestHarness; use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload}; use makenotwork::db::UserId; /// Stripe's period window on the test invoices. Fixed constants rather than /// `NOW()`-relative arithmetic, so every period assertion is wall-clock /// independent and the "refreshed" and "left alone" cases are different /// timestamps rather than both plausibly NOW(). const PERIOD_START: i64 = 1_700_000_000; const PERIOD_END: i64 = 1_702_592_000; // ── posting events ────────────────────────────────────────────────────────── /// Sign an event envelope with the harness webhook secret and POST it. async fn post_event( h: &mut TestHarness, event_id: &str, event_type: &str, object: serde_json::Value, ) -> crate::harness::client::TestResponse { let payload = serde_json::json!({ "id": event_id, "type": event_type, "data": {"object": object}, }) .to_string(); let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET); // 503 is the route's documented "redeliver me" answer, not a failure: the // handler takes a `pg_try_advisory_xact_lock` on the event id and answers // 503 rather than parking a connection when that lock is held. sqlx rolls a // dropped transaction back lazily, so the lock from the delivery that just // returned can still be held for a moment, and a test that posts the // redelivery immediately would be asserting that timing rather than the // contract. Stripe's own answer to a 503 is to send the event again, so // that is what this does, bounded. The status the caller asserts is the // one the route settles on. for _ in 0..200 { let resp = post_signed(h, &payload, &signature).await; if resp.status != 503 { return resp; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; } panic!("{event_id}: the webhook route answered 503 for two seconds"); } /// POST a payload with a caller-supplied signature header, so a test can hand /// over one that does not match the body. async fn post_signed( h: &mut TestHarness, payload: &str, signature: &str, ) -> crate::harness::client::TestResponse { h.client .request_with_headers( "POST", "/stripe/webhook", Some(payload), &[ ("stripe-signature", signature), ("content-type", "application/json"), ], ) .await } /// A Stripe invoice for `stripe_sub_id`, whose `billing_reason` is what /// separates a renewal from the first invoice of a subscription. fn invoice(stripe_sub_id: &str, billing_reason: &str) -> serde_json::Value { serde_json::json!({ "id": "in_exactly_once", "object": "invoice", "subscription": stripe_sub_id, "billing_reason": billing_reason, "period_start": PERIOD_START, "period_end": PERIOD_END, "currency": "usd", }) } /// Insert an active Fan+ subscription with a period that ends well before the /// invoice period, so a refresh is visible as a change and not as a coincidence. async fn active_fan_plus(h: &TestHarness, user_id: UserId, stripe_sub_id: &str) { sqlx::query( r"INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_start, current_period_end) VALUES ($1, $2, 'cus_exactly_once', 'active', to_timestamp($3), to_timestamp($4))", ) .bind(user_id) .bind(stripe_sub_id) .bind(PERIOD_START - 2_592_000) .bind(PERIOD_END - 2_592_000) .execute(&h.db) .await .expect("insert fan+ subscription"); } /// How many platform credit codes `user_id` holds, plus the code and value of /// the newest, so a second mint is visible as a count and a wrong value is /// visible as cents. async fn credit_codes(h: &TestHarness, user_id: UserId) -> (i64, Option, Option) { sqlx::query_as( "SELECT COUNT(*), MIN(discount_type), MIN(discount_value) FROM promo_codes \ WHERE creator_id = $1 AND code_purpose = 'discount'", ) .bind(user_id) .fetch_one(&h.db) .await .expect("count fan+ credit codes") } /// The Fan+ row's status and period end, the pair every guarded subscription /// write either moves together or leaves alone. async fn fan_plus_status_and_period( h: &TestHarness, stripe_sub_id: &str, ) -> (String, chrono::DateTime) { sqlx::query_as( "SELECT status::text, current_period_end FROM fan_plus_subscriptions \ WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .expect("read fan+ row") } /// Wait for the fire-and-forget mail tasks to settle and return the purchase /// receipts sent to `address`. /// /// The receipt is spawned on the background pool, so it lands after the webhook /// response and cannot be read synchronously. This is a wait on a condition /// rather than a fixed sleep: it returns as soon as the count has held at /// `expected` for a stretch, and it fails the instant a second copy appears, /// which is the failure the redelivery test is hunting. // ── billing: the Fan+ renewal credit ──────────────────────────────────────── #[tokio::test] async fn a_renewal_invoice_issues_exactly_one_five_dollar_credit() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("fpcreditonce", "fpcreditonce@test.com", "password123") .await; let stripe_sub_id = "sub_exactly_once_credit"; active_fan_plus(&h, user_id, stripe_sub_id).await; let renewal = invoice(stripe_sub_id, "subscription_cycle"); let first = post_event( &mut h, "evt_exactly_once_credit_a", "invoice.payment_succeeded", renewal.clone(), ) .await; assert_eq!(first.status, 200, "renewal invoice failed: {}", first.text); let (count, discount_type, discount_value) = credit_codes(&h, user_id).await; assert_eq!( count, 1, "a renewal mints exactly one credit code, got {count}" ); assert_eq!( discount_type.as_deref(), Some("fixed"), "fixed-amount discount" ); assert_eq!( discount_value, Some(500), "credit is 500 cents, got {discount_value:?}" ); // Same event id: refused by the dedup read. let replay = post_event( &mut h, "evt_exactly_once_credit_a", "invoice.payment_succeeded", renewal.clone(), ) .await; assert_eq!(replay.status, 200, "replay failed: {}", replay.text); // Fresh event id, same period: dedup does not fire, and the credit-issuance // claim keyed on (subscription, period_end) is what has to hold. let reissue = post_event( &mut h, "evt_exactly_once_credit_b", "invoice.payment_succeeded", renewal, ) .await; assert_eq!(reissue.status, 200, "second event failed: {}", reissue.text); let (count, _, discount_value) = credit_codes(&h, user_id).await; assert_eq!( count, 1, "three deliveries of one renewal must mint one credit, got {count}" ); assert_eq!( discount_value, Some(500), "surviving credit must be 500 cents" ); // The renewal also refreshes the billing period to Stripe's window. let period_end: chrono::DateTime = sqlx::query_scalar( "SELECT current_period_end FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .expect("read fan+ period"); assert_eq!( period_end.timestamp(), PERIOD_END, "the renewal must move current_period_end to the invoice period end" ); } #[tokio::test] async fn the_first_invoice_refreshes_the_period_and_mints_no_credit() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("fpfirstonce", "fpfirstonce@test.com", "password123") .await; let stripe_sub_id = "sub_exactly_once_first"; active_fan_plus(&h, user_id, stripe_sub_id).await; // `subscription_create` is the first invoice of the subscription. The // credit funds renewals only, so this is the other side of that boundary. let resp = post_event( &mut h, "evt_exactly_once_first", "invoice.payment_succeeded", invoice(stripe_sub_id, "subscription_create"), ) .await; assert_eq!(resp.status, 200, "first invoice failed: {}", resp.text); let (count, _, _) = credit_codes(&h, user_id).await; assert_eq!( count, 0, "the first invoice of a subscription mints no credit, got {count}" ); let period_end: chrono::DateTime = sqlx::query_scalar( "SELECT current_period_end FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .fetch_one(&h.db) .await .expect("read fan+ period"); assert_eq!( period_end.timestamp(), PERIOD_END, "the first invoice still refreshes the period" ); } // ── billing: out-of-order and status-only writes ──────────────────────────── #[tokio::test] async fn an_invoice_arriving_after_cancellation_cannot_refresh_the_period() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("fpcancelonce", "fpcancelonce@test.com", "password123") .await; let stripe_sub_id = "sub_exactly_once_canceled"; active_fan_plus(&h, user_id, stripe_sub_id).await; sqlx::query( "UPDATE fan_plus_subscriptions SET status = 'canceled', canceled_at = NOW() \ WHERE stripe_subscription_id = $1", ) .bind(stripe_sub_id) .execute(&h.db) .await .expect("cancel fan+ subscription"); // Stripe delivers events out of order: a payment event for a subscription // that has since been canceled must not revive its access window. let resp = post_event( &mut h, "evt_exactly_once_canceled", "invoice.payment_succeeded", invoice(stripe_sub_id, "subscription_cycle"), ) .await; assert_eq!(resp.status, 200, "late invoice failed: {}", resp.text); let (status, period_end) = fan_plus_status_and_period(&h, stripe_sub_id).await; assert_eq!( status, "canceled", "a late invoice must not revive a canceled subscription" ); assert_eq!( period_end.timestamp(), PERIOD_END - 2_592_000, "a late invoice must leave the canceled row's period exactly where it was" ); } #[tokio::test] async fn a_failed_invoice_sets_past_due_without_touching_the_period() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("fpfailonce", "fpfailonce@test.com", "password123") .await; let stripe_sub_id = "sub_exactly_once_failed"; active_fan_plus(&h, user_id, stripe_sub_id).await; let resp = post_event( &mut h, "evt_exactly_once_failed_a", "invoice.payment_failed", invoice(stripe_sub_id, "subscription_cycle"), ) .await; assert_eq!(resp.status, 200, "failed invoice failed: {}", resp.text); let (status, period_end) = fan_plus_status_and_period(&h, stripe_sub_id).await; assert_eq!( status, "past_due", "a failed payment must mark the subscription past_due" ); // The failure path writes status only. The invoice carries a later period, // so a handler that also wrote the period would extend paid access on a // payment that did not go through. assert_eq!( period_end.timestamp(), PERIOD_END - 2_592_000, "a failed payment must not move current_period_end" ); // Stripe retries the same failure; the second delivery changes nothing. let replay = post_event( &mut h, "evt_exactly_once_failed_a", "invoice.payment_failed", invoice(stripe_sub_id, "subscription_cycle"), ) .await; assert_eq!(replay.status, 200, "replayed failure: {}", replay.text); let (status, period_end) = fan_plus_status_and_period(&h, stripe_sub_id).await; assert_eq!( status, "past_due", "status must stay past_due on redelivery" ); assert_eq!( period_end.timestamp(), PERIOD_END - 2_592_000, "the redelivery must not move current_period_end either" ); }