//! 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. 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 async fn $fn<'e>( executor: impl sqlx::PgExecutor<'e>, stripe_sub_id: &str, status: Option<$crate::db::SubscriptionStatus>, period: Option<( ::chrono::DateTime<::chrono::Utc>, ::chrono::DateTime<::chrono::Utc>, )>, ) -> $crate::error::Result> { let (period_start, period_end) = match period { Some((start, end)) => (Some(start), Some(end)), None => (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;