@@ -130,6 +130,125 @@ To pause paid onboarding without changing stored subscription state: |
| 130 | | 130 | |
| 131 | Existing local billing rows remain in the database. Billing routes | 131 | Existing local billing rows remain in the database. Billing routes |
| 132 | unmount, plan comparison links become disabled, and entitlement state | 132 | unmount, plan comparison links become disabled, and entitlement state |
| 133 | -continues to derive from the latest local billing projection. Handle any | 133 | +continues to derive from the latest local billing projection. |
| 134 | -Stripe-side cancellations or refunds in the Stripe dashboard until the | 134 | + |
| 135 | -admin billing tools exist. | 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 | + |