@@ -130,6 +130,125 @@ To pause paid onboarding without changing stored subscription state: |
| 130 | 130 | |
| 131 | 131 | Existing local billing rows remain in the database. Billing routes |
| 132 | 132 | unmount, plan comparison links become disabled, and entitlement state |
| 133 | | -continues to derive from the latest local billing projection. Handle any |
| 134 | | -Stripe-side cancellations or refunds in the Stripe dashboard until the |
| 135 | | -admin billing tools exist. |
| 133 | +continues to derive from the latest local billing projection. |
| 134 | + |
| 135 | +## Subject resolution chain (PRO08) |
| 136 | + |
| 137 | +When a Stripe event arrives, the webhook handler walks this chain to |
| 138 | +identify which shithub subject (org or user) the event applies to. |
| 139 | +The first match wins; events that fall off the end are loud-failed |
| 140 | +(except `customer.subscription.deleted`, which is a 200 no-op so |
| 141 | +Stripe stops retrying — operator reconciles manually). |
| 142 | + |
| 143 | +| # | Source | Applies to | Notes | |
| 144 | +|---|---|---|---| |
| 145 | +| 1 | `metadata.shithub_subject_kind` + `shithub_subject_id` | checkout, subscription | PRO04 — only path that resolves user-kind via metadata | |
| 146 | +| 2 | `metadata.shithub_org_id` | checkout, subscription | Legacy SP03 — org-only backstop for pre-PRO04 customers | |
| 147 | +| 3 | `client_reference_id` | checkout only | Legacy SP03 — parsed as int, org-only by convention | |
| 148 | +| 4 | `customer.id` lookup against both `org_billing_states` and `user_billing_states` | all event types | User table searched first then org | |
| 149 | +| 5 | `subscription.id` lookup against both tables | subscription, invoice | Used when customer lookup misses | |
| 150 | + |
| 151 | +Invoice events do not check metadata (1–3); they go straight to |
| 152 | +customer/subscription lookup. Stripe doesn't stamp our metadata on |
| 153 | +invoices by default — they inherit from the subscription via |
| 154 | +`subscription_data.metadata` set at checkout creation time. |
| 155 | + |
| 156 | +## Per-feature enforcement flags (PRO07 + PRO08) |
| 157 | + |
| 158 | +User-tier paygates (Pro) ship in report-only mode by default. Each |
| 159 | +feature has an independent operator flag that flips it from report- |
| 160 | +only to hard enforce. The flag is one-way until the operator reverts |
| 161 | +it — flip per feature after 7 days of clean telemetry. |
| 162 | + |
| 163 | +| Config key | Gate site | Default | |
| 164 | +|---|---|---| |
| 165 | +| `SHITHUB_BILLING__ENFORCE__USER_REQUIRED_REVIEWERS` | branch-protection rule save | `false` | |
| 166 | +| `SHITHUB_BILLING__ENFORCE__USER_ADVANCED_BRANCH_PROTECTION` | branch-protection rule save (prevent_*, signing, status checks) | `false` | |
| 167 | +| `SHITHUB_BILLING__ENFORCE__USER_PROFILE_PINS_BEYOND_FREE` | profile pin save | `false` | |
| 168 | + |
| 169 | +Report-only mode logs `entitlements.report_only_deny` events with |
| 170 | +the principal + feature. Tail logs for 7 days, confirm no Free user |
| 171 | +is tripping a gate, then flip the relevant flag and redeploy. |
| 172 | + |
| 173 | +## Refunds (PRO08 D2) |
| 174 | + |
| 175 | +Stripe refunds are issued from the Stripe Dashboard. shithub picks |
| 176 | +up the `charge.refunded` event automatically and: |
| 177 | + |
| 178 | +1. Looks up the invoice row by `stripe_invoice_id` (from `charge.invoice`). |
| 179 | +2. Flips its status to `refunded` and stamps `refunded_at`. |
| 180 | +3. Surfaces the refunded state on `/settings/billing` and the |
| 181 | + org billing settings page. |
| 182 | + |
| 183 | +Refunds do **not** automatically cancel the subscription. If you |
| 184 | +want to revoke Pro access alongside the refund, cancel the |
| 185 | +subscription separately in the Stripe Dashboard — that fires |
| 186 | +`customer.subscription.deleted` which shithub handles by setting |
| 187 | +`users.plan='free'` (or org equivalent). |
| 188 | + |
| 189 | +A refund for an invoice shithub has never seen (e.g., an out-of-band |
| 190 | +one-off charge) logs a warning and 200-no-ops — investigate via |
| 191 | +the inspection query below. |
| 192 | + |
| 193 | +## Operator inspection: failed webhook events (PRO08 A2 + Agent A) |
| 194 | + |
| 195 | +Webhook receipts that failed to apply (resolver missed, guard |
| 196 | +refused, apply errored) are kept in `billing_webhook_events` with a |
| 197 | +non-empty `process_error`. PRO08 added (subject_kind, subject_id) |
| 198 | +columns so operators can answer "what did this event apply to" even |
| 199 | +on failed rows. |
| 200 | + |
| 201 | +To see events received but not yet successfully applied: |
| 202 | + |
| 203 | +```sql |
| 204 | +SELECT provider_event_id, event_type, received_at, processing_attempts, |
| 205 | + process_error, subject_kind, subject_id |
| 206 | + FROM billing_webhook_events |
| 207 | + WHERE process_error <> '' |
| 208 | + OR (processed_at IS NULL AND processing_attempts > 0) |
| 209 | + ORDER BY received_at DESC |
| 210 | + LIMIT 50; |
| 211 | +``` |
| 212 | + |
| 213 | +The same data is available via `orgbilling.ListFailedWebhookEvents` |
| 214 | +from Go code. |
| 215 | + |
| 216 | +## Cross-kind misroute protection (PRO08 A1) |
| 217 | + |
| 218 | +When both `STRIPE_TEAM_PRICE_ID` and `STRIPE_PRO_PRICE_ID` are |
| 219 | +configured, the webhook guard refuses any subscription event whose |
| 220 | +price-id doesn't match the resolved subject's expected price (Team |
| 221 | +for orgs, Pro for users). A Pro-priced subscription with |
| 222 | +metadata claiming `subject_kind=org` (or vice versa) hits the guard, |
| 223 | +the apply is refused, and the receipt records the mismatch in |
| 224 | +`process_error`. The guard also refuses subscription events with |
| 225 | +empty `items.data` — otherwise an attacker who can spoof Stripe |
| 226 | +could bypass the price check entirely. |
| 227 | + |
| 228 | +## Stale events (PRO08 D4) |
| 229 | + |
| 230 | +Stripe doesn't guarantee delivery order across retries. shithub |
| 231 | +records the latest Stripe event timestamp per subject in |
| 232 | +`{org,user}_billing_states.last_event_at` and refuses to apply |
| 233 | +older events. Reverse-ordered retries (e.g., `subscription.updated[active]` |
| 234 | +arriving after `subscription.updated[canceled]`) are dropped with |
| 235 | +an `org billing: dropping stale Stripe event` log line and a |
| 236 | +200-no-op to Stripe. |
| 237 | + |
| 238 | +## Concurrent replay protection (PRO08 A3) |
| 239 | + |
| 240 | +The webhook handler acquires a session-scoped advisory lock keyed |
| 241 | +on the event id at request entry. Two concurrent deliveries of the |
| 242 | +same event serialize at the lock; the racing replay returns 200 |
| 243 | +without running the apply. Production should never see this in |
| 244 | +practice (Stripe doesn't fan-out parallel retries) — the lock |
| 245 | +defends against malicious senders who hold the webhook secret. |
| 246 | + |
| 247 | +## Subscription-overwrite guard (PRO08 D3) |
| 248 | + |
| 249 | +If a customer somehow ends up with two active Stripe subscriptions |
| 250 | +(operator manually created one in the Dashboard), shithub refuses |
| 251 | +to flip its `stripe_subscription_id` to point at the new one. |
| 252 | +The receipt records the mismatch — operator must reconcile the |
| 253 | +Stripe-side state (cancel the duplicate) before the apply succeeds. |
| 254 | + |