tenseleyflow/shithub / ea9dedb

Browse files

docs/runbooks/stripe-billing: subject resolution chain + enforce flags + refund + inspection queries

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ea9dedbdcd6a4fd6f0d8d06efb36e3571a932ec8
Parents
5713625
Tree
c2e6b82

1 changed file

StatusFile+-
M docs/internal/runbooks/stripe-billing.md 122 3
docs/internal/runbooks/stripe-billing.mdmodified
@@ -130,6 +130,125 @@ To pause paid onboarding without changing stored subscription state:
130130
 
131131
 Existing local billing rows remain in the database. Billing routes
132132
 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
+