@ssheleg/sheleg-dev 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +101 -1
  3. package/SECURITY.md +190 -52
  4. package/bin/sheleg-dev.js +18 -0
  5. package/package.json +2 -2
  6. package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
  7. package/plugins/sheleg-dev/hooks/hooks.json +29 -0
  8. package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
  9. package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
  10. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +7 -3
  11. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +76 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +249 -0
  13. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
  14. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
  15. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
  16. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
  17. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
  18. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +13 -1
  19. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +68 -0
  20. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
  21. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
  22. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +110 -0
  23. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +404 -0
  24. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
  25. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
  26. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
  32. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
  33. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
  34. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
  35. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +38 -2
  36. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
@@ -0,0 +1,315 @@
1
+ // A webhook handler that satisfies every money invariant this skill states, and the
2
+ // same handler with one rule removed at a time.
3
+ //
4
+ // It is deliberately tiny and dependency-free: an in-memory store standing in for your
5
+ // database, and the seven decisions the SKILL.md and `references/webhook-events.md`
6
+ // require. Point `assert-money-invariants.mjs` at YOUR handler instead and the
7
+ // assertions do not change — that is the whole purpose of shipping it.
8
+ //
9
+ // Two properties of the store matter, and both model a real database rather than a
10
+ // convenient one:
11
+ //
12
+ // * `claimEvent` is ATOMIC. It is an `INSERT` on a primary key: a read and a write
13
+ // with nothing between them. That is why a claim survives two deliveries in flight.
14
+ // * every OTHER read awaits. A `SELECT` is a network round trip, so a read-then-check
15
+ // -then-write is a race — which is exactly why the claim cannot be replaced by the
16
+ // per-period grant marker, however well the marker handles a sequential replay.
17
+ //
18
+ // `without: ['claim']` and friends are not toy switches. Each one deletes the lines a
19
+ // generated handler routinely omits; `fixtures/README.md` shows the code each flag
20
+ // removes. Nothing here reaches the network, the filesystem or the clock.
21
+
22
+ export const RULES = Object.freeze([
23
+ 'claim',
24
+ 'billing-reason',
25
+ 'grant-marker',
26
+ 'ordering',
27
+ 'cumulative-refund',
28
+ 'async-gate',
29
+ 'conversion-id-from-metadata',
30
+ // The baseline, and the broadest mutant: a handler that treats `invoice.paid` as
31
+ // information and grants nothing. It breaks every grant assertion at once, which is the
32
+ // point — without it, a handler that does nothing would satisfy every invariant about
33
+ // NOT doing something twice.
34
+ 'grant-on-renewal',
35
+ // The same baseline for the first grant: a handler that answers 200 to
36
+ // `checkout.session.completed` and writes nothing. Without it, refusing every session
37
+ // would satisfy every assertion about not granting an unpaid one.
38
+ 'grant-on-checkout',
39
+ ]);
40
+
41
+ /** Credits one paid period is worth. Arbitrary; the assertions count grants, not credits. */
42
+ export const CREDITS_PER_PERIOD = 500;
43
+
44
+ const tick = () => new Promise((resolve) => setImmediate(resolve));
45
+
46
+ export function createStore() {
47
+ return {
48
+ processedEvents: new Set(),
49
+ grantedPeriods: new Map(), // subscription id -> Set of period starts already granted
50
+ subscriptions: new Map(), // the mirrored row: period, status, quantity, price
51
+ purchases: new Map(), // payment intent -> { amount, refundedTotal } (minor units)
52
+ credits: new Map(), // user id -> integer
53
+ grants: [], // one row per grant actually applied
54
+ clawbacks: [], // { paymentIntent, amount } in minor units
55
+ conversions: [], // what the server sent to the ad platforms
56
+ notifications: [], // side effects that must not run twice
57
+ log: [],
58
+
59
+ /** An INSERT on a primary key: atomic, and the only claim that survives a race. */
60
+ claimEvent(id) {
61
+ if (this.processedEvents.has(id)) return false;
62
+ this.processedEvents.add(id);
63
+ return true;
64
+ },
65
+ releaseEventClaim(id) {
66
+ this.processedEvents.delete(id);
67
+ },
68
+
69
+ async readGrantedPeriods(subId) {
70
+ await tick(); // a SELECT is a round trip
71
+ return this.grantedPeriods.get(subId) || new Set();
72
+ },
73
+ markPeriodGranted(subId, periodStart) {
74
+ const seen = this.grantedPeriods.get(subId) || new Set();
75
+ seen.add(periodStart);
76
+ this.grantedPeriods.set(subId, seen);
77
+ },
78
+
79
+ async readPurchase(paymentIntent) {
80
+ await tick();
81
+ return this.purchases.get(paymentIntent) || null;
82
+ },
83
+ /** compare-and-swap: writes only if nobody moved the total underneath us. */
84
+ swapRefundedTotal(paymentIntent, expected, next) {
85
+ const row = this.purchases.get(paymentIntent);
86
+ if (!row || row.refundedTotal !== expected) return false;
87
+ row.refundedTotal = next;
88
+ return true;
89
+ },
90
+
91
+ addCredits(userId, amount) {
92
+ this.credits.set(userId, (this.credits.get(userId) || 0) + amount);
93
+ },
94
+ };
95
+ }
96
+
97
+ /** The API version moved this; a string OR an object, and `null` for a non-subscription invoice. */
98
+ export function subscriptionIdOf(invoice) {
99
+ const ref = invoice.parent
100
+ && invoice.parent.subscription_details
101
+ && invoice.parent.subscription_details.subscription;
102
+ if (typeof ref === 'string') return ref;
103
+ if (ref && typeof ref === 'object') return ref.id;
104
+ return null;
105
+ }
106
+
107
+ function invoiceMetadata(invoice) {
108
+ return (invoice.parent
109
+ && invoice.parent.subscription_details
110
+ && invoice.parent.subscription_details.metadata) || {};
111
+ }
112
+
113
+ /** Period dates live on the line, not on the top-level invoice, and not on the subscription. */
114
+ function periodOf(invoice) {
115
+ const lines = (invoice.lines && invoice.lines.data) || [];
116
+ const line = lines.find((l) => !l.proration) || lines[0];
117
+ if (!line || !line.period) return null;
118
+ return { start: line.period.start, end: line.period.end, line };
119
+ }
120
+
121
+ export function createHandler(store, options = {}) {
122
+ const without = new Set(options.without || []);
123
+ for (const rule of without) {
124
+ if (!RULES.includes(rule)) throw new Error(`unknown rule: ${rule}`);
125
+ }
126
+ const has = (rule) => !without.has(rule);
127
+ let generated = 0;
128
+
129
+ function conversionIdFor(invoice) {
130
+ if (has('conversion-id-from-metadata')) {
131
+ // Written at session creation into `subscription_data.metadata`, which is why it
132
+ // still exists on a renewal invoice a year after the session is gone.
133
+ return invoiceMetadata(invoice).conversionEventId || null;
134
+ }
135
+ generated += 1;
136
+ return `evtid_generated_at_emission_${generated}`;
137
+ }
138
+
139
+ async function invoicePaid(event) {
140
+ const invoice = event.data.object;
141
+ if (has('billing-reason') && invoice.billing_reason !== 'subscription_cycle') {
142
+ store.log.push({ event: event.id, decision: `skipped: ${invoice.billing_reason}` });
143
+ return [];
144
+ }
145
+ if (!has('grant-on-renewal')) {
146
+ store.log.push({ event: event.id, decision: 'skipped: renewals not implemented' });
147
+ return [];
148
+ }
149
+ const subId = subscriptionIdOf(invoice);
150
+ const period = periodOf(invoice);
151
+ if (!subId || !period) {
152
+ store.log.push({ event: event.id, decision: 'skipped: not a subscription invoice' });
153
+ return [];
154
+ }
155
+ const metadata = invoiceMetadata(invoice);
156
+ const userId = metadata.userId;
157
+
158
+ const granted = await store.readGrantedPeriods(subId);
159
+ if (has('grant-marker') && granted.has(period.start)) {
160
+ store.log.push({ event: event.id, decision: 'skipped: period already granted' });
161
+ return [];
162
+ }
163
+ store.markPeriodGranted(subId, period.start);
164
+ store.addCredits(userId, CREDITS_PER_PERIOD);
165
+ store.grants.push({
166
+ subscription: subId, userId, periodStart: period.start, source: 'webhook', event: event.id,
167
+ });
168
+
169
+ // The mirror moves FORWARD only. Arrival order is not state.
170
+ const mirror = store.subscriptions.get(subId);
171
+ if (!has('ordering') || !mirror || period.start > mirror.periodStart) {
172
+ store.subscriptions.set(subId, {
173
+ periodStart: period.start,
174
+ periodEnd: period.end,
175
+ status: 'active',
176
+ quantity: period.line.quantity,
177
+ priceId: period.line.pricing
178
+ && period.line.pricing.price_details
179
+ && period.line.pricing.price_details.price,
180
+ });
181
+ }
182
+
183
+ const conversionId = conversionIdFor(invoice);
184
+ store.log.push({ event: event.id, decision: 'granted' });
185
+ return [
186
+ () => store.notifications.push({ userId, kind: 'renewal', periodStart: period.start }),
187
+ () => store.conversions.push({
188
+ eventId: conversionId,
189
+ eventName: 'Purchase',
190
+ source: 'webhook',
191
+ value: invoice.amount_paid / 100,
192
+ currency: (invoice.currency || '').toUpperCase(),
193
+ }),
194
+ ];
195
+ }
196
+
197
+ async function chargeRefunded(event) {
198
+ const charge = event.data.object;
199
+ const row = await store.readPurchase(charge.payment_intent);
200
+ if (!row) {
201
+ // The other half of the fork: a subscription refund has no purchase row and is
202
+ // resolved `charge.invoice` -> invoice -> subscription. Out of this pack's scope,
203
+ // and named rather than silently dropped.
204
+ store.log.push({ event: event.id, decision: 'skipped: no purchase row (subscription refund path)' });
205
+ return [];
206
+ }
207
+ if (!has('cumulative-refund')) {
208
+ store.clawbacks.push({ paymentIntent: charge.payment_intent, amount: charge.amount_refunded });
209
+ store.log.push({ event: event.id, decision: 'clawed back amount_refunded' });
210
+ return [];
211
+ }
212
+ const increment = charge.amount_refunded - row.refundedTotal;
213
+ if (increment <= 0) {
214
+ store.log.push({ event: event.id, decision: 'skipped: replay or reorder' });
215
+ return [];
216
+ }
217
+ if (!store.swapRefundedTotal(charge.payment_intent, row.refundedTotal, charge.amount_refunded)) {
218
+ store.log.push({ event: event.id, decision: 'skipped: a concurrent delivery won' });
219
+ return [];
220
+ }
221
+ store.clawbacks.push({ paymentIntent: charge.payment_intent, amount: increment });
222
+ store.log.push({ event: event.id, decision: 'clawed back the increment' });
223
+ return [];
224
+ }
225
+
226
+ async function sessionCompleted(event) {
227
+ const session = event.data.object;
228
+ if (has('async-gate') && session.payment_status === 'unpaid') {
229
+ // An async method. The grant belongs to `async_payment_succeeded`, which may
230
+ // arrive days later -- or never.
231
+ store.log.push({ event: event.id, decision: 'skipped: payment_status unpaid' });
232
+ return [];
233
+ }
234
+ if (!has('grant-on-checkout')) {
235
+ store.log.push({ event: event.id, decision: 'skipped: checkout not implemented' });
236
+ return [];
237
+ }
238
+ return grantForSession(session, event.id);
239
+ }
240
+
241
+ function grantForSession(session, eventId) {
242
+ const subId = session.subscription;
243
+ if (store.subscriptions.has(subId)) {
244
+ store.log.push({ event: eventId, decision: 'duplicate: subscription already granted' });
245
+ return [];
246
+ }
247
+ store.subscriptions.set(subId, {
248
+ periodStart: null, periodEnd: null, status: 'active', quantity: 1, priceId: null,
249
+ });
250
+ store.addCredits(session.metadata.userId, CREDITS_PER_PERIOD);
251
+ store.grants.push({
252
+ subscription: subId,
253
+ userId: session.metadata.userId,
254
+ periodStart: null,
255
+ source: 'webhook',
256
+ event: eventId,
257
+ });
258
+ return [];
259
+ }
260
+
261
+ async function handle(event) {
262
+ switch (event.type) {
263
+ case 'invoice.paid':
264
+ return invoicePaid(event);
265
+ case 'charge.refunded':
266
+ return chargeRefunded(event);
267
+ case 'checkout.session.completed':
268
+ case 'checkout.session.async_payment_succeeded':
269
+ return sessionCompleted(event);
270
+ case 'checkout.session.async_payment_failed':
271
+ store.log.push({ event: event.id, decision: 'async payment failed: nothing granted' });
272
+ return [];
273
+ default:
274
+ return []; // 200 for a type we do not handle
275
+ }
276
+ }
277
+
278
+ /** The route Stripe posts to. Signature verification is the caller's; see SKILL.md. */
279
+ async function deliver(event) {
280
+ if (has('claim') && !store.claimEvent(event.id)) {
281
+ return { status: 200, body: { received: true, duplicate: true } };
282
+ }
283
+ let afterCommit = [];
284
+ try {
285
+ afterCommit = await handle(event);
286
+ } catch (error) {
287
+ if (has('claim')) store.releaseEventClaim(event.id);
288
+ return { status: 500, body: { error: 'handler error' } };
289
+ }
290
+ for (const effect of afterCommit) effect(); // side effects run after the commit
291
+ return { status: 200, body: { received: true } };
292
+ }
293
+
294
+ /**
295
+ * The nightly repair, and the "Sync now" button. It has NO event id, so the event
296
+ * claim cannot protect it -- only the per-period grant marker can. This is the entry
297
+ * point that separates the two.
298
+ */
299
+ async function reconcile(subId, period, metadata) {
300
+ const granted = await store.readGrantedPeriods(subId);
301
+ if (has('grant-marker') && granted.has(period.start)) {
302
+ store.log.push({ event: 'reconcile', decision: 'skipped: period already granted' });
303
+ return { granted: false };
304
+ }
305
+ store.markPeriodGranted(subId, period.start);
306
+ store.addCredits(metadata.userId, CREDITS_PER_PERIOD);
307
+ store.grants.push({
308
+ subscription: subId, userId: metadata.userId, periodStart: period.start,
309
+ source: 'reconciliation', event: 'reconcile',
310
+ });
311
+ return { granted: true };
312
+ }
313
+
314
+ return { deliver, reconcile };
315
+ }
@@ -11,6 +11,7 @@ deciding whether an existing suite would actually catch a money defect.
11
11
  - [Test cards](#test-cards)
12
12
  - [What to mock](#what-to-mock)
13
13
  - [A webhook idempotency test](#a-webhook-idempotency-test)
14
+ - [The shipped fixtures and the assertion pack](#the-shipped-fixtures-and-the-assertion-pack)
14
15
  - [Mutation testing — the only proof that counts](#mutation-testing--the-only-proof-that-counts)
15
16
  - [Asserting on source](#asserting-on-source)
16
17
  - [Staging verification by hand](#staging-verification-by-hand)
@@ -154,9 +155,42 @@ it("credits exactly once when the same event is delivered twice", async () => {
154
155
  Drive the **real** exported handler. A test that calls a private helper proves
155
156
  the helper works and says nothing about the route Stripe posts to.
156
157
 
158
+ ## The shipped fixtures and the assertion pack
159
+
160
+ Everything above tells you what to write. `fixtures/` is that suite already written:
161
+ nine Stripe event bodies, twelve assertions, and the mutants that prove each assertion can
162
+ fail. Copy the directory into your repository and run it.
163
+
164
+ ```bash
165
+ node fixtures/assert-money-invariants.mjs # the assertions
166
+ node fixtures/assert-money-invariants.mjs --self-test # every one of them, watched failing
167
+ ```
168
+
169
+ Point it at your handler by replacing the two imports at the top of
170
+ `fixtures/assert-money-invariants.mjs`; the assertions do not change.
171
+ `fixtures/README.md` is the map, and `fixtures/manifest.json` is the machine-readable one —
172
+ each invariant, the fixtures that prove it, and the paragraph in this pack that claims it.
173
+
174
+ Three of the twelve exist because of a measurement rather than a plan, and they are the ones
175
+ worth reading before you write your own:
176
+
177
+ - **`sequential-redelivery-grants-once-by-count-alone`.** A redelivery judged only by the
178
+ grant count is refused by the event claim OR by the per-period marker, so no single
179
+ missing rule turns it red. Where **two mechanisms cover each other**, neither is tested,
180
+ and a suite built on that fixture would ship with no idempotency and stay green. The claim's
181
+ real fixture is the **concurrent** delivery; the marker's is the reconciliation path.
182
+ - **`duplicate-refund-claws-back-once`.** The same pair one layer over: the claim stops a
183
+ duplicate refund, and `increment <= 0` stops it without the claim. The arithmetic is
184
+ measured by the two-step refund, whose events carry *different* ids.
185
+ - **`conversion-id-survives-the-session`.** The conversion id is read out of
186
+ `subscription_data.metadata`, so a renewal a year later still carries **the id the browser
187
+ will reuse** — the deduplication contract `ad-tracking` states, tested from the side that
188
+ emits it.
189
+
157
190
  ## Mutation testing — the only proof that counts
158
191
 
159
- For every guard, delete it and re-run. A test that still passes is decoration.
192
+ For every guard the pack does **not** ship, delete it and re-run. A test that still passes
193
+ is decoration.
160
194
 
161
195
  | Delete this | A real test fails with |
162
196
  |---|---|
@@ -169,7 +203,9 @@ For every guard, delete it and re-run. A test that still passes is decoration.
169
203
  | the non-Stripe guard in reconciliation | comped subscriptions cancelled |
170
204
 
171
205
  Do this once per guard, write down what failed, and you have a suite you can
172
- believe. Skip it and you have a green build.
206
+ believe. Skip it and you have a green build. For the nine rules `fixtures/` covers, the
207
+ deletion is already written: `--self-test` performs it and prints which fixture isolates
208
+ each rule.
173
209
 
174
210
  ## Asserting on source
175
211
 
@@ -74,6 +74,11 @@ if (!userId) { log.error("session without userId"); return; } // 200, but loud
74
74
  - A user that no longer exists is a 200 with an error log, not a 500. Retrying
75
75
  will not bring them back.
76
76
 
77
+ **Proved, not asserted.** `fixtures/checkout-session-completed-paid.json` is the positive
78
+ control: `paid-session-grants-once` fails a handler that answers 200 and writes nothing. It
79
+ exists because without it, a handler refusing *every* session would satisfy
80
+ `unpaid-session-grants-nothing` and this pack would teach the opposite defect.
81
+
77
82
  ## Asynchronous payment methods
78
83
 
79
84
  Bank debits, vouchers and bank transfers complete the session before the money
@@ -82,6 +87,11 @@ grant belongs to `async_payment_succeeded`, which may arrive days later — and
82
87
  `async_payment_failed` means it never will. Both must be handled or the product
83
88
  is either never granted or granted for a payment that failed.
84
89
 
90
+ **Proved, not asserted.** `fixtures/checkout-session-completed-async-unpaid.json` and
91
+ `fixtures/checkout-session-async-payment-failed.json` through
92
+ `fixtures/assert-money-invariants.mjs`: `unpaid-session-grants-nothing` fails any handler
93
+ that grants on `checkout.session.completed` without reading `payment_status`.
94
+
85
95
  ## invoice.paid
86
96
 
87
97
  The single most consequential field is `billing_reason`:
@@ -116,6 +126,15 @@ Guard the grant with a marker that survives a replay: a `lastGrantedPeriodStart`
116
126
  compared against the new period start, or an audit row keyed by `invoice.id`.
117
127
  Check it **inside** the same transaction as the grant.
118
128
 
129
+ **Proved, not asserted.** Three assertions in `fixtures/assert-money-invariants.mjs` cover
130
+ this table: `renewal-grants-exactly-once` against
131
+ `fixtures/invoice-paid-subscription-cycle.json`,
132
+ `proration-invoice-grants-nothing` against
133
+ `fixtures/invoice-paid-subscription-update-proration.json` — whose line period starts at the
134
+ change date, so the marker lets it through and only `billing_reason` stops it — and
135
+ `reconciliation-does-not-regrant`, which drives the nightly repair rather than a webhook and
136
+ is the only fixture the event claim cannot save.
137
+
119
138
  ## invoice.payment_failed and dunning
120
139
 
121
140
  Mark the subscription `past_due` and tell the user, with a link to the customer
@@ -161,7 +180,10 @@ transaction that then rolls back.
161
180
  ## charge.refunded and charge.dispute.created
162
181
 
163
182
  `amount_refunded` is **cumulative**. Compute the increment against your stored
164
- total under a compare-and-swap; see the `SKILL.md` section. Resolve the target
183
+ total under a compare-and-swap; see the `SKILL.md` section. **Proved, not asserted:**
184
+ `fixtures/charge-refunded-partial.json` then `fixtures/charge-refunded-remainder.json`
185
+ deliver `4000` and then `9000` against a `9000` charge, and `refund-total-is-cumulative`
186
+ fails a handler that reads either as an increment. Resolve the target
165
187
  two ways: by `payment_intent` for one-off purchases, and by `charge.invoice` →
166
188
  invoice → subscription for subscription refunds. A refund handler that only
167
189
  knows the first path silently ignores every subscription refund.
@@ -169,6 +191,13 @@ knows the first path silently ignores every subscription refund.
169
191
  `charge.dispute.created` is money already gone plus a fee. Treat it as a refund
170
192
  for entitlement purposes and route it to a human — evidence has a deadline.
171
193
 
194
+ "Route it to a human" is a precondition in this pack rather than a sentence: the
195
+ `PreToolUse` gate at `plugins/sheleg-dev/hooks/money-gate.js` refuses
196
+ `stripe refunds create`, `stripe disputes close` and a POST to `…/v1/refunds` unless the
197
+ authorised person has signed that category off for the session. It stops an agent, not
198
+ your handler — the handler still needs the queue. The pack's `README.md` carries the
199
+ categories and how one is authorised.
200
+
172
201
  ## Ordering is not guaranteed
173
202
 
174
203
  Events can arrive out of order, and Stripe's own docs say so. Two consequences:
@@ -183,6 +212,12 @@ For a subscription whose state you cannot reconstruct from one event, retrieve
183
212
  the subscription fresh inside the handler. One extra API call is cheaper than a
184
213
  class of ordering bugs.
185
214
 
215
+ **Proved, not asserted.** `fixtures/invoice-paid-subscription-cycle-next-period.json`
216
+ delivered *before* `fixtures/invoice-paid-subscription-cycle.json`:
217
+ `out-of-order-pair-does-not-rewind-state` requires both periods to be granted and the
218
+ mirrored row to still hold February. The two events carry different ids, so idempotency
219
+ cannot mask the defect — which is the only reason this fixture measures the ordering rule.
220
+
186
221
  ## Idempotency store
187
222
 
188
223
  ```sql
@@ -202,6 +237,13 @@ CREATE INDEX ON processed_webhook_events (processed_at);
202
237
  - A failure to *claim* (database down) is a 503, never an optimistic "probably
203
238
  new".
204
239
 
240
+ **Proved, not asserted.** `fixtures/invoice-paid-subscription-cycle-redelivery.json` carries
241
+ the same `evt_` as the cycle invoice. `sequential-redelivery-grants-once` requires the second
242
+ delivery to answer `duplicate` and the renewal notice and the conversion to fire once;
243
+ `concurrent-redelivery-grants-once` delivers both **at the same time**, which is the fixture
244
+ that separates this claim from the per-period marker, because the marker reads before it
245
+ writes and the claim does not.
246
+
205
247
  ## What to log
206
248
 
207
249
  Structured, and enough to answer "what happened to this payment" without the