@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.
- package/CHANGELOG.md +256 -0
- package/README.md +101 -1
- package/SECURITY.md +190 -52
- package/bin/sheleg-dev.js +18 -0
- package/package.json +2 -2
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/hooks/hooks.json +29 -0
- package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
- package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +7 -3
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +76 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +249 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +13 -1
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +68 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +110 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +404 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +38 -2
- 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
|
|
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.
|
|
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
|