max / makenotwork
| 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 | => |
| 39 | |
| 40 | |
| 41 | |
| 42 | pub async fn $fn<'e> |
| 43 | executor: |
| 44 | stripe_sub_id: &str, |
| 45 | status: Option, |
| 46 | period: Option |
| 47 | , |
| 48 | , |
| 49 | )>, |
| 50 | ) -> $crate::error::Result |
| 51 | let = match period |
| 52 | Some => , |
| 53 | None => , |
| 54 | ; |
| 55 | let row = |
| 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 |
| 66 | .bind |
| 67 | .bind |
| 68 | .bind |
| 69 | .fetch_optional |
| 70 | .await?; |
| 71 | |
| 72 | Ok |
| 73 | |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | pub use define_stripe_subscription_writer; |
| 78 |