Skip to main content

max / makenotwork

4.9 KB · 94 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 ///
38 /// `period` is the **raw Stripe period** `(current_period_start,
39 /// current_period_end)` as Unix seconds, exactly what
40 /// `SubscriptionLifecycle::current_period` / an invoice yields. The funnel owns the only legal
41 /// conversion: a `None`, or a non-positive `end` (thin/zero webhook shapes),
42 /// writes no period at all (the `COALESCE` keeps the existing value). Handlers
43 /// never construct a `DateTime` for the period, so there is no way to hand the
44 /// writer a `Some((epoch, epoch))` that would stamp 1970-01-01 onto an active
45 /// row and trip the access gate's `current_period_end > NOW()` check. The
46 /// `end > 0` guard lives here, once, instead of being copied (and forgotten) at
47 /// each call site.
48 macro_rules! define_stripe_subscription_writer {
49 ($fn:ident, $table:literal, $row:ty) => {
50 #[doc = concat!("Guarded Stripe state write for `", $table, "`. See ")]
51 #[doc = "[`crate::db::subscription_writer`] for why status + period are written together."]
52 #[tracing::instrument(skip_all)]
53 pub(crate) async fn $fn<'e>(
54 executor: impl sqlx::PgExecutor<'e>,
55 stripe_sub_id: &str,
56 status: Option<$crate::db::SubscriptionStatus>,
57 period: Option<(i64, i64)>,
58 ) -> $crate::error::Result<Option<$row>> {
59 let (period_start, period_end) = match period {
60 // `end > 0` rejects the thin/zero webhook (no 1970 period); the
61 // additional `start <= end` rejects an inverted range, so a
62 // malformed Stripe period writes nothing (the `COALESCE` keeps the
63 // existing values) rather than stamping a backwards window, parity
64 // with `synckit_billing::apply_billing_update` (audit Run 13).
65 Some((start, end)) if end > 0 && start <= end => (
66 ::chrono::DateTime::from_timestamp(start, 0),
67 ::chrono::DateTime::from_timestamp(end, 0),
68 ),
69 _ => (None, None),
70 };
71 let row = sqlx::query_as::<_, $row>(concat!(
72 "UPDATE ", $table, " SET ",
73 "status = COALESCE($2, status), ",
74 "canceled_at = CASE WHEN $2 = 'canceled' THEN COALESCE(canceled_at, NOW()) ELSE canceled_at END, ",
75 "current_period_start = COALESCE($3, current_period_start), ",
76 "current_period_end = COALESCE($4, current_period_end) ",
77 "WHERE stripe_subscription_id = $1 ",
78 "AND (status != 'canceled' OR $2 = 'canceled') ",
79 "RETURNING *",
80 ))
81 .bind(stripe_sub_id)
82 .bind(status)
83 .bind(period_start)
84 .bind(period_end)
85 .fetch_optional(executor)
86 .await?;
87
88 Ok(row)
89 }
90 };
91 }
92
93 pub(crate) use define_stripe_subscription_writer;
94