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:
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
+