Skip to main content

max / makenotwork

3.8 KB · 78 lines History Blame Raw
1 //! One guarded writer for Stripe-driven subscription state, shared by every
2 //! standard subscription family.
3 //!
4 //! ## Why this exists
5 //!
6 //! Across four consecutive audit runs the same bug shape recurred: a family's
7 //! `update_*_status` carried the `canceled`-is-terminal guard, but its sibling
8 //! `update_*_period` did NOT — so an out-of-order `invoice.payment_succeeded`
9 //! (or a stray `customer.subscription.updated`) arriving after a cancellation
10 //! refreshed the period on a canceled row, and each new family copied the same
11 //! split (status guarded, period unguarded).
12 //!
13 //! Copying the guard onto each period setter is the fix class that kept failing.
14 //! Instead, this macro makes the *split itself* unwritable for these families:
15 //! it emits a SINGLE function that writes status AND period together under one
16 //! guard. There is no separate period write to forget the guard on, and the
17 //! guard exists in exactly ONE place (this macro body) rather than copied per
18 //! family. Adding a new standard family is one macro invocation that carries
19 //! the guard by construction.
20 //!
21 //! `synckit_billing` (string `billing_status`, keyed by app id, with a
22 //! `suspended_unpaid -> active` recovery exception) and `synckit` app-sub
23 //! (only `current_period_end`) don't share this exact shape; each has its own
24 //! single guarded writer in its module.
25
26 /// Generate `pub async fn $fn(executor, stripe_sub_id, status, period)` for a
27 /// table with `status TEXT`, `canceled_at`, and `current_period_{start,end}`
28 /// columns keyed by `stripe_subscription_id`, returning the updated `$row`.
29 ///
30 /// `status` and `period` are independently optional: `customer.subscription.
31 /// updated` passes both, `invoice.payment_succeeded` passes period-only,
32 /// `invoice.payment_failed` passes status-only. In every case the write is
33 /// refused (no row matched -> `Ok(None)`) when the row is already `canceled`,
34 /// unless the new status is itself `canceled` (so a duplicate delete stays
35 /// idempotent). Reactivation never flows through here — it happens at checkout
36 /// via each family's `create_*`/`ON CONFLICT DO UPDATE` path.
37 macro_rules! define_stripe_subscription_writer {
38 ($fn:ident, $table:literal, $row:ty) => {
39 #[doc = concat!("Guarded Stripe state write for `", $table, "`. See ")]
40 #[doc = "[`crate::db::subscription_writer`] for why status + period are written together."]
41 #[tracing::instrument(skip_all)]
42 pub async fn $fn<'e>(
43 executor: impl sqlx::PgExecutor<'e>,
44 stripe_sub_id: &str,
45 status: Option<$crate::db::SubscriptionStatus>,
46 period: Option<(
47 ::chrono::DateTime<::chrono::Utc>,
48 ::chrono::DateTime<::chrono::Utc>,
49 )>,
50 ) -> $crate::error::Result<Option<$row>> {
51 let (period_start, period_end) = match period {
52 Some((start, end)) => (Some(start), Some(end)),
53 None => (None, None),
54 };
55 let row = sqlx::query_as::<_, $row>(concat!(
56 "UPDATE ", $table, " SET ",
57 "status = COALESCE($2, status), ",
58 "canceled_at = CASE WHEN $2 = 'canceled' THEN COALESCE(canceled_at, NOW()) ELSE canceled_at END, ",
59 "current_period_start = COALESCE($3, current_period_start), ",
60 "current_period_end = COALESCE($4, current_period_end) ",
61 "WHERE stripe_subscription_id = $1 ",
62 "AND (status != 'canceled' OR $2 = 'canceled') ",
63 "RETURNING *",
64 ))
65 .bind(stripe_sub_id)
66 .bind(status)
67 .bind(period_start)
68 .bind(period_end)
69 .fetch_optional(executor)
70 .await?;
71
72 Ok(row)
73 }
74 };
75 }
76
77 pub(crate) use define_stripe_subscription_writer;
78