Skip to main content

max / makenotwork

13.4 KB · 374 lines History Blame Raw
1 //! Route-layer contract tests for `routes::stripe::webhook::billing`, the
2 //! invoice half of the exactly-once protocol.
3 //!
4 //! Stripe redelivers, so a renewal invoice arrives more than once and the money
5 //! contract is that it mints one $5 Fan+ credit however many times it lands.
6 //! Pinned here: the credit is minted exactly once per renewal period and its
7 //! value is asserted in cents; the first invoice of a subscription refreshes
8 //! the period and mints nothing, so the renewal boundary is asserted on both
9 //! sides; an invoice arriving after cancellation cannot refresh the period on
10 //! the canceled row; and `invoice.payment_failed` writes the status without
11 //! touching the period.
12 //!
13 //! The checkout half is `stripe_webhook_exactly_once`.
14 //!
15 //! Delete this file and a redelivered renewal invoice could mint a second
16 //! credit, which is a direct cash loss with no error anywhere.
17
18 use crate::harness::TestHarness;
19 use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload};
20 use makenotwork::db::UserId;
21
22 /// Stripe's period window on the test invoices. Fixed constants rather than
23 /// `NOW()`-relative arithmetic, so every period assertion is wall-clock
24 /// independent and the "refreshed" and "left alone" cases are different
25 /// timestamps rather than both plausibly NOW().
26 const PERIOD_START: i64 = 1_700_000_000;
27 const PERIOD_END: i64 = 1_702_592_000;
28
29 // ── posting events ──────────────────────────────────────────────────────────
30
31 /// Sign an event envelope with the harness webhook secret and POST it.
32 async fn post_event(
33 h: &mut TestHarness,
34 event_id: &str,
35 event_type: &str,
36 object: serde_json::Value,
37 ) -> crate::harness::client::TestResponse {
38 let payload = serde_json::json!({
39 "id": event_id,
40 "type": event_type,
41 "data": {"object": object},
42 })
43 .to_string();
44 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET);
45
46 // 503 is the route's documented "redeliver me" answer, not a failure: the
47 // handler takes a `pg_try_advisory_xact_lock` on the event id and answers
48 // 503 rather than parking a connection when that lock is held. sqlx rolls a
49 // dropped transaction back lazily, so the lock from the delivery that just
50 // returned can still be held for a moment, and a test that posts the
51 // redelivery immediately would be asserting that timing rather than the
52 // contract. Stripe's own answer to a 503 is to send the event again, so
53 // that is what this does, bounded. The status the caller asserts is the
54 // one the route settles on.
55 for _ in 0..200 {
56 let resp = post_signed(h, &payload, &signature).await;
57 if resp.status != 503 {
58 return resp;
59 }
60 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
61 }
62 panic!("{event_id}: the webhook route answered 503 for two seconds");
63 }
64
65 /// POST a payload with a caller-supplied signature header, so a test can hand
66 /// over one that does not match the body.
67 async fn post_signed(
68 h: &mut TestHarness,
69 payload: &str,
70 signature: &str,
71 ) -> crate::harness::client::TestResponse {
72 h.client
73 .request_with_headers(
74 "POST",
75 "/stripe/webhook",
76 Some(payload),
77 &[
78 ("stripe-signature", signature),
79 ("content-type", "application/json"),
80 ],
81 )
82 .await
83 }
84
85 /// A Stripe invoice for `stripe_sub_id`, whose `billing_reason` is what
86 /// separates a renewal from the first invoice of a subscription.
87 fn invoice(stripe_sub_id: &str, billing_reason: &str) -> serde_json::Value {
88 serde_json::json!({
89 "id": "in_exactly_once",
90 "object": "invoice",
91 "subscription": stripe_sub_id,
92 "billing_reason": billing_reason,
93 "period_start": PERIOD_START,
94 "period_end": PERIOD_END,
95 "currency": "usd",
96 })
97 }
98
99 /// Insert an active Fan+ subscription with a period that ends well before the
100 /// invoice period, so a refresh is visible as a change and not as a coincidence.
101 async fn active_fan_plus(h: &TestHarness, user_id: UserId, stripe_sub_id: &str) {
102 sqlx::query(
103 r"INSERT INTO fan_plus_subscriptions
104 (user_id, stripe_subscription_id, stripe_customer_id, status,
105 current_period_start, current_period_end)
106 VALUES ($1, $2, 'cus_exactly_once', 'active', to_timestamp($3), to_timestamp($4))",
107 )
108 .bind(user_id)
109 .bind(stripe_sub_id)
110 .bind(PERIOD_START - 2_592_000)
111 .bind(PERIOD_END - 2_592_000)
112 .execute(&h.db)
113 .await
114 .expect("insert fan+ subscription");
115 }
116
117 /// How many platform credit codes `user_id` holds, plus the code and value of
118 /// the newest, so a second mint is visible as a count and a wrong value is
119 /// visible as cents.
120 async fn credit_codes(h: &TestHarness, user_id: UserId) -> (i64, Option<String>, Option<i32>) {
121 sqlx::query_as(
122 "SELECT COUNT(*), MIN(discount_type), MIN(discount_value) FROM promo_codes \
123 WHERE creator_id = $1 AND code_purpose = 'discount'",
124 )
125 .bind(user_id)
126 .fetch_one(&h.db)
127 .await
128 .expect("count fan+ credit codes")
129 }
130
131 /// The Fan+ row's status and period end, the pair every guarded subscription
132 /// write either moves together or leaves alone.
133 async fn fan_plus_status_and_period(
134 h: &TestHarness,
135 stripe_sub_id: &str,
136 ) -> (String, chrono::DateTime<chrono::Utc>) {
137 sqlx::query_as(
138 "SELECT status::text, current_period_end FROM fan_plus_subscriptions \
139 WHERE stripe_subscription_id = $1",
140 )
141 .bind(stripe_sub_id)
142 .fetch_one(&h.db)
143 .await
144 .expect("read fan+ row")
145 }
146
147 /// Wait for the fire-and-forget mail tasks to settle and return the purchase
148 /// receipts sent to `address`.
149 ///
150 /// The receipt is spawned on the background pool, so it lands after the webhook
151 /// response and cannot be read synchronously. This is a wait on a condition
152 /// rather than a fixed sleep: it returns as soon as the count has held at
153 /// `expected` for a stretch, and it fails the instant a second copy appears,
154 /// which is the failure the redelivery test is hunting.
155 // ── billing: the Fan+ renewal credit ────────────────────────────────────────
156
157 #[tokio::test]
158 async fn a_renewal_invoice_issues_exactly_one_five_dollar_credit() {
159 let mut h = TestHarness::with_mocks().await;
160 let user_id = h
161 .signup("fpcreditonce", "fpcreditonce@test.com", "password123")
162 .await;
163 let stripe_sub_id = "sub_exactly_once_credit";
164 active_fan_plus(&h, user_id, stripe_sub_id).await;
165
166 let renewal = invoice(stripe_sub_id, "subscription_cycle");
167 let first = post_event(
168 &mut h,
169 "evt_exactly_once_credit_a",
170 "invoice.payment_succeeded",
171 renewal.clone(),
172 )
173 .await;
174 assert_eq!(first.status, 200, "renewal invoice failed: {}", first.text);
175
176 let (count, discount_type, discount_value) = credit_codes(&h, user_id).await;
177 assert_eq!(
178 count, 1,
179 "a renewal mints exactly one credit code, got {count}"
180 );
181 assert_eq!(
182 discount_type.as_deref(),
183 Some("fixed"),
184 "fixed-amount discount"
185 );
186 assert_eq!(
187 discount_value,
188 Some(500),
189 "credit is 500 cents, got {discount_value:?}"
190 );
191
192 // Same event id: refused by the dedup read.
193 let replay = post_event(
194 &mut h,
195 "evt_exactly_once_credit_a",
196 "invoice.payment_succeeded",
197 renewal.clone(),
198 )
199 .await;
200 assert_eq!(replay.status, 200, "replay failed: {}", replay.text);
201
202 // Fresh event id, same period: dedup does not fire, and the credit-issuance
203 // claim keyed on (subscription, period_end) is what has to hold.
204 let reissue = post_event(
205 &mut h,
206 "evt_exactly_once_credit_b",
207 "invoice.payment_succeeded",
208 renewal,
209 )
210 .await;
211 assert_eq!(reissue.status, 200, "second event failed: {}", reissue.text);
212
213 let (count, _, discount_value) = credit_codes(&h, user_id).await;
214 assert_eq!(
215 count, 1,
216 "three deliveries of one renewal must mint one credit, got {count}"
217 );
218 assert_eq!(
219 discount_value,
220 Some(500),
221 "surviving credit must be 500 cents"
222 );
223
224 // The renewal also refreshes the billing period to Stripe's window.
225 let period_end: chrono::DateTime<chrono::Utc> = sqlx::query_scalar(
226 "SELECT current_period_end FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
227 )
228 .bind(stripe_sub_id)
229 .fetch_one(&h.db)
230 .await
231 .expect("read fan+ period");
232 assert_eq!(
233 period_end.timestamp(),
234 PERIOD_END,
235 "the renewal must move current_period_end to the invoice period end"
236 );
237 }
238
239 #[tokio::test]
240 async fn the_first_invoice_refreshes_the_period_and_mints_no_credit() {
241 let mut h = TestHarness::with_mocks().await;
242 let user_id = h
243 .signup("fpfirstonce", "fpfirstonce@test.com", "password123")
244 .await;
245 let stripe_sub_id = "sub_exactly_once_first";
246 active_fan_plus(&h, user_id, stripe_sub_id).await;
247
248 // `subscription_create` is the first invoice of the subscription. The
249 // credit funds renewals only, so this is the other side of that boundary.
250 let resp = post_event(
251 &mut h,
252 "evt_exactly_once_first",
253 "invoice.payment_succeeded",
254 invoice(stripe_sub_id, "subscription_create"),
255 )
256 .await;
257 assert_eq!(resp.status, 200, "first invoice failed: {}", resp.text);
258
259 let (count, _, _) = credit_codes(&h, user_id).await;
260 assert_eq!(
261 count, 0,
262 "the first invoice of a subscription mints no credit, got {count}"
263 );
264
265 let period_end: chrono::DateTime<chrono::Utc> = sqlx::query_scalar(
266 "SELECT current_period_end FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
267 )
268 .bind(stripe_sub_id)
269 .fetch_one(&h.db)
270 .await
271 .expect("read fan+ period");
272 assert_eq!(
273 period_end.timestamp(),
274 PERIOD_END,
275 "the first invoice still refreshes the period"
276 );
277 }
278
279 // ── billing: out-of-order and status-only writes ────────────────────────────
280
281 #[tokio::test]
282 async fn an_invoice_arriving_after_cancellation_cannot_refresh_the_period() {
283 let mut h = TestHarness::with_mocks().await;
284 let user_id = h
285 .signup("fpcancelonce", "fpcancelonce@test.com", "password123")
286 .await;
287 let stripe_sub_id = "sub_exactly_once_canceled";
288 active_fan_plus(&h, user_id, stripe_sub_id).await;
289 sqlx::query(
290 "UPDATE fan_plus_subscriptions SET status = 'canceled', canceled_at = NOW() \
291 WHERE stripe_subscription_id = $1",
292 )
293 .bind(stripe_sub_id)
294 .execute(&h.db)
295 .await
296 .expect("cancel fan+ subscription");
297
298 // Stripe delivers events out of order: a payment event for a subscription
299 // that has since been canceled must not revive its access window.
300 let resp = post_event(
301 &mut h,
302 "evt_exactly_once_canceled",
303 "invoice.payment_succeeded",
304 invoice(stripe_sub_id, "subscription_cycle"),
305 )
306 .await;
307 assert_eq!(resp.status, 200, "late invoice failed: {}", resp.text);
308
309 let (status, period_end) = fan_plus_status_and_period(&h, stripe_sub_id).await;
310 assert_eq!(
311 status, "canceled",
312 "a late invoice must not revive a canceled subscription"
313 );
314 assert_eq!(
315 period_end.timestamp(),
316 PERIOD_END - 2_592_000,
317 "a late invoice must leave the canceled row's period exactly where it was"
318 );
319 }
320
321 #[tokio::test]
322 async fn a_failed_invoice_sets_past_due_without_touching_the_period() {
323 let mut h = TestHarness::with_mocks().await;
324 let user_id = h
325 .signup("fpfailonce", "fpfailonce@test.com", "password123")
326 .await;
327 let stripe_sub_id = "sub_exactly_once_failed";
328 active_fan_plus(&h, user_id, stripe_sub_id).await;
329
330 let resp = post_event(
331 &mut h,
332 "evt_exactly_once_failed_a",
333 "invoice.payment_failed",
334 invoice(stripe_sub_id, "subscription_cycle"),
335 )
336 .await;
337 assert_eq!(resp.status, 200, "failed invoice failed: {}", resp.text);
338
339 let (status, period_end) = fan_plus_status_and_period(&h, stripe_sub_id).await;
340 assert_eq!(
341 status, "past_due",
342 "a failed payment must mark the subscription past_due"
343 );
344 // The failure path writes status only. The invoice carries a later period,
345 // so a handler that also wrote the period would extend paid access on a
346 // payment that did not go through.
347 assert_eq!(
348 period_end.timestamp(),
349 PERIOD_END - 2_592_000,
350 "a failed payment must not move current_period_end"
351 );
352
353 // Stripe retries the same failure; the second delivery changes nothing.
354 let replay = post_event(
355 &mut h,
356 "evt_exactly_once_failed_a",
357 "invoice.payment_failed",
358 invoice(stripe_sub_id, "subscription_cycle"),
359 )
360 .await;
361 assert_eq!(replay.status, 200, "replayed failure: {}", replay.text);
362
363 let (status, period_end) = fan_plus_status_and_period(&h, stripe_sub_id).await;
364 assert_eq!(
365 status, "past_due",
366 "status must stay past_due on redelivery"
367 );
368 assert_eq!(
369 period_end.timestamp(),
370 PERIOD_END - 2_592_000,
371 "the redelivery must not move current_period_end either"
372 );
373 }
374