//! One guarded writer for Stripe-driven subscription state, shared by every //! standard subscription family. //! //! ## Why this exists //! //! Across four consecutive audit runs the same bug shape recurred: a family's //! `update_*_status` carried the `canceled`-is-terminal guard, but its sibling //! `update_*_period` did NOT, so an out-of-order `invoice.payment_succeeded` //! (or a stray `customer.subscription.updated`) arriving after a cancellation //! refreshed the period on a canceled row, and each new family copied the same //! split (status guarded, period unguarded). //! //! Copying the guard onto each period setter is the fix class that kept failing. //! Instead, this macro makes the *split itself* unwritable for these families: //! it emits a SINGLE function that writes status AND period together under one //! guard. There is no separate period write to forget the guard on, and the //! guard exists in exactly ONE place (this macro body) rather than copied per //! family. Adding a new standard family is one macro invocation that carries //! the guard by construction. //! //! `synckit_billing` (string `billing_status`, keyed by app id, with a //! `suspended_unpaid -> active` recovery exception) and `synckit` app-sub //! (only `current_period_end`) don't share this exact shape; each has its own //! single guarded writer in its module. /// Generate `pub async fn $fn(executor, stripe_sub_id, status, period)` for a /// table with `status TEXT`, `canceled_at`, and `current_period_{start,end}` /// columns keyed by `stripe_subscription_id`, returning the updated `$row`. /// /// `status` and `period` are independently optional: `customer.subscription. /// updated` passes both, `invoice.payment_succeeded` passes period-only, /// `invoice.payment_failed` passes status-only. In every case the write is /// refused (no row matched -> `Ok(None)`) when the row is already `canceled`, /// unless the new status is itself `canceled` (so a duplicate delete stays /// idempotent). Reactivation never flows through here, it happens at checkout /// via each family's `create_*`/`ON CONFLICT DO UPDATE` path. /// /// `period` is the **raw Stripe period** `(current_period_start, /// current_period_end)` as Unix seconds, exactly what /// `SubscriptionLifecycle::current_period` / an invoice yields. The funnel owns the only legal /// conversion: a `None`, or a non-positive `end` (thin/zero webhook shapes), /// writes no period at all (the `COALESCE` keeps the existing value). Handlers /// never construct a `DateTime` for the period, so there is no way to hand the /// writer a `Some((epoch, epoch))` that would stamp 1970-01-01 onto an active /// row and trip the access gate's `current_period_end > NOW()` check. The /// `end > 0` guard lives here, once, instead of being copied (and forgotten) at /// each call site. macro_rules! define_stripe_subscription_writer { ($fn:ident, $table:literal, $row:ty) => { #[doc = concat!("Guarded Stripe state write for `", $table, "`. See ")] #[doc = "[`crate::db::subscription_writer`] for why status + period are written together."] #[tracing::instrument(skip_all)] pub(crate) async fn $fn<'e>( executor: impl sqlx::PgExecutor<'e>, stripe_sub_id: &str, status: Option<$crate::db::SubscriptionStatus>, period: Option<(i64, i64)>, ) -> $crate::error::Result> { let (period_start, period_end) = match period { // `end > 0` rejects the thin/zero webhook (no 1970 period); the // additional `start <= end` rejects an inverted range, so a // malformed Stripe period writes nothing (the `COALESCE` keeps the // existing values) rather than stamping a backwards window, parity // with `synckit_billing::apply_billing_update` (audit Run 13). Some((start, end)) if end > 0 && start <= end => ( ::chrono::DateTime::from_timestamp(start, 0), ::chrono::DateTime::from_timestamp(end, 0), ), _ => (None, None), }; let row = sqlx::query_as::<_, $row>(concat!( "UPDATE ", $table, " SET ", "status = COALESCE($2, status), ", "canceled_at = CASE WHEN $2 = 'canceled' THEN COALESCE(canceled_at, NOW()) ELSE canceled_at END, ", "current_period_start = COALESCE($3, current_period_start), ", "current_period_end = COALESCE($4, current_period_end) ", "WHERE stripe_subscription_id = $1 ", "AND (status != 'canceled' OR $2 = 'canceled') ", "RETURNING *", )) .bind(stripe_sub_id) .bind(status) .bind(period_start) .bind(period_end) .fetch_optional(executor) .await?; Ok(row) } }; } pub(crate) use define_stripe_subscription_writer;