@ssheleg/sheleg-dev 0.11.8 → 0.12.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 +16 -0
- package/package.json +4 -3
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +24 -8
- package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +57 -12
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +24 -26
- package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +52 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +42 -23
- package/plugins/sheleg-dev/skills/error-tracking/SKILL.md +34 -6
- package/plugins/sheleg-dev/skills/error-tracking/references/scrubbing.md +65 -2
- package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +51 -23
- package/plugins/sheleg-dev/skills/google-auth/SKILL.md +13 -1
- package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +13 -3
- package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +150 -64
- package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +36 -19
- package/plugins/sheleg-dev/skills/google-signin/SKILL.md +55 -23
- package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +150 -49
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +9 -7
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +17 -1
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +171 -5
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +333 -198
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +259 -35
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +149 -28
- package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +76 -5
|
@@ -21,6 +21,32 @@
|
|
|
21
21
|
|
|
22
22
|
export const RULES = Object.freeze([
|
|
23
23
|
'claim',
|
|
24
|
+
// A claim records RECEIPT, and only `completeEvent` records completion. Without this
|
|
25
|
+
// rule the row never leaves 'processing', so a retry arriving after the claim expiry
|
|
26
|
+
// takes the event over and grants a second time — received quietly read as completed.
|
|
27
|
+
'claim-completion',
|
|
28
|
+
// The recovery half of the same split: a 'processing' claim older than CLAIM_TTL_MS
|
|
29
|
+
// belonged to a worker that died between receipt and completion, and a retry may take
|
|
30
|
+
// it over. Without this rule the crashed claim answers "in flight" forever and the
|
|
31
|
+
// payment is swallowed.
|
|
32
|
+
'claim-expiry',
|
|
33
|
+
// Entitlement, dedup marker, completion and the outbox rows commit TOGETHER.
|
|
34
|
+
// Without this rule a crash mid-application leaves the grant applied and the
|
|
35
|
+
// completion missing — half a payment, which no retry can see or repair.
|
|
36
|
+
'atomic-application',
|
|
37
|
+
// The outbox delivers at least once, so the CONSUMER dedups on its own key
|
|
38
|
+
// (event id + effect kind). Without this rule a redelivered outbox row sends
|
|
39
|
+
// the renewal notice and fires the conversion a second time.
|
|
40
|
+
'outbox-consumer-key',
|
|
41
|
+
// The period grant is claimed by an ATOMIC keyed insert (subscription + item
|
|
42
|
+
// + period), not a read-then-mark. Without this rule two concurrent grants of
|
|
43
|
+
// one period — a webhook and the reconciler, say — both pass the read and
|
|
44
|
+
// both credit: the read is a round trip, and a round trip is a race.
|
|
45
|
+
'grant-key-atomic',
|
|
46
|
+
// A serialization conflict retries, BOUNDED, inside the same claim. Without
|
|
47
|
+
// this rule a conflicted transaction is dropped and the route still answers
|
|
48
|
+
// 200 — the renewal silently never lands, and Stripe was told it did.
|
|
49
|
+
'tx-retry-bounded',
|
|
24
50
|
'billing-reason',
|
|
25
51
|
'grant-marker',
|
|
26
52
|
'ordering',
|
|
@@ -50,6 +76,11 @@ export const RULES = Object.freeze([
|
|
|
50
76
|
/** Credits one paid period is worth. Arbitrary; the assertions count grants, not credits. */
|
|
51
77
|
export const CREDITS_PER_PERIOD = 500;
|
|
52
78
|
|
|
79
|
+
/** How long a 'processing' claim may sit before a retry may take it over. In production:
|
|
80
|
+
* comfortably longer than your slowest handler run (say, twice the route timeout). The
|
|
81
|
+
* store keeps a logical clock, so the assertions can age a claim without waiting. */
|
|
82
|
+
export const CLAIM_TTL_MS = 5 * 60 * 1000;
|
|
83
|
+
|
|
53
84
|
/** The save offer this handler is willing to make. Yours, not Stripe's -- a coupon cannot
|
|
54
85
|
* be restricted to one customer, so the identity of the offer has to live on your side. */
|
|
55
86
|
export const RETENTION_OFFER_ID = 'cancel-50-once';
|
|
@@ -59,29 +90,123 @@ export const RETENTION_DECLINE_LIMIT = 2;
|
|
|
59
90
|
|
|
60
91
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
61
92
|
|
|
93
|
+
/** The crash simulation's marker: a process death, not a thrown handler error. */
|
|
94
|
+
class SystemError_ extends Error {}
|
|
95
|
+
|
|
96
|
+
/** A database serialization failure — retryable by definition, never a verdict. */
|
|
97
|
+
class SerializationConflict_ extends Error {}
|
|
98
|
+
|
|
99
|
+
/** How many serialization conflicts one delivery absorbs before answering 5xx. */
|
|
100
|
+
export const TX_RETRIES = 3;
|
|
101
|
+
|
|
62
102
|
export function createStore() {
|
|
63
103
|
return {
|
|
64
|
-
processedEvents: new
|
|
104
|
+
processedEvents: new Map(), // event id -> { state: 'processing'|'completed', claimedAt }
|
|
105
|
+
clock: 0, // logical ms; advanced by tests, never read from the wall
|
|
106
|
+
advanceClock(ms) {
|
|
107
|
+
this.clock += ms;
|
|
108
|
+
},
|
|
65
109
|
grantedPeriods: new Map(), // subscription id -> Set of period starts already granted
|
|
110
|
+
grantKeys: new Set(), // atomic per-period claim keys: sub:item:period
|
|
111
|
+
grantLedger: [], // one row per granted period: { key, subId, itemId, invoiceId, periodStart }
|
|
66
112
|
subscriptions: new Map(), // the mirrored row: period, status, quantity, price
|
|
67
113
|
purchases: new Map(), // payment intent -> { amount, refundedTotal } (minor units)
|
|
68
114
|
credits: new Map(), // user id -> integer
|
|
69
115
|
grants: [], // one row per grant actually applied
|
|
70
116
|
clawbacks: [], // { paymentIntent, amount } in minor units
|
|
71
117
|
conversions: [], // what the server sent to the ad platforms
|
|
118
|
+
outbox: [], // side-effect rows written IN the grant transaction: { key, apply, state }
|
|
119
|
+
failNextTransactions: 0, // test hook: how many commits raise a serialization conflict
|
|
120
|
+
txAttempts: 0, // how many transaction attempts actually ran
|
|
121
|
+
sentKeys: new Set(), // the consumer's own dedup — survives a redelivered row
|
|
72
122
|
retentionOffers: [], // { customerId, offerId, couponId, subscriptionId, redeemedAt }
|
|
73
123
|
saves: [], // one row per cancellation deflected, recorded from the discount event
|
|
74
124
|
notifications: [], // side effects that must not run twice
|
|
75
125
|
log: [],
|
|
76
126
|
|
|
77
|
-
/**
|
|
127
|
+
/** All-or-nothing for the business application: what the fixture calls a
|
|
128
|
+
* transaction. A crash inside the callback restores every collection, so a
|
|
129
|
+
* half-applied grant cannot exist — which is the property the retry relies on. */
|
|
130
|
+
snapshot() {
|
|
131
|
+
return {
|
|
132
|
+
processedEvents: new Map([...this.processedEvents]
|
|
133
|
+
.map(([k, v]) => [k, { ...v }])),
|
|
134
|
+
grantedPeriods: new Map([...this.grantedPeriods].map(([k, v]) => [k, new Set(v)])),
|
|
135
|
+
grantKeys: new Set(this.grantKeys),
|
|
136
|
+
grantLedger: this.grantLedger.map((r) => ({ ...r })),
|
|
137
|
+
subscriptions: new Map([...this.subscriptions].map(([k, v]) => [k, { ...v }])),
|
|
138
|
+
purchases: new Map([...this.purchases].map(([k, v]) => [k, { ...v }])),
|
|
139
|
+
credits: new Map(this.credits),
|
|
140
|
+
grants: this.grants.slice(),
|
|
141
|
+
clawbacks: this.clawbacks.slice(),
|
|
142
|
+
conversions: this.conversions.slice(),
|
|
143
|
+
retentionOffers: this.retentionOffers.map((o) => ({ ...o })),
|
|
144
|
+
saves: this.saves.slice(),
|
|
145
|
+
notifications: this.notifications.slice(),
|
|
146
|
+
outbox: this.outbox.map((r) => ({ ...r })),
|
|
147
|
+
sentKeys: new Set(this.sentKeys),
|
|
148
|
+
log: this.log.slice(),
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
restore(snap) {
|
|
152
|
+
Object.assign(this, snap);
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/** An INSERT on a primary key: atomic, and the only claim that survives a race.
|
|
156
|
+
* The row it writes says 'processing' — a RECEIPT. What the caller does with an
|
|
157
|
+
* existing row is a decision about states, so this returns one:
|
|
158
|
+
* 'claimed' (new row), 'completed' (real duplicate), 'expired' (a worker died
|
|
159
|
+
* holding it), 'in_flight' (someone fresh is on it — answer retry-later). */
|
|
78
160
|
claimEvent(id) {
|
|
79
|
-
|
|
80
|
-
|
|
161
|
+
const row = this.processedEvents.get(id);
|
|
162
|
+
if (!row) {
|
|
163
|
+
this.processedEvents.set(id, { state: 'processing', claimedAt: this.clock });
|
|
164
|
+
return 'claimed';
|
|
165
|
+
}
|
|
166
|
+
if (row.state === 'completed') return 'completed';
|
|
167
|
+
return this.clock - row.claimedAt >= CLAIM_TTL_MS ? 'expired' : 'in_flight';
|
|
168
|
+
},
|
|
169
|
+
/** The takeover: an UPDATE guarded by the same staleness test it was granted for —
|
|
170
|
+
* atomic, so two retries racing for one corpse cannot both win. */
|
|
171
|
+
reclaimEvent(id) {
|
|
172
|
+
const row = this.processedEvents.get(id);
|
|
173
|
+
if (!row || row.state !== 'processing'
|
|
174
|
+
|| this.clock - row.claimedAt < CLAIM_TTL_MS) return false;
|
|
175
|
+
row.claimedAt = this.clock;
|
|
81
176
|
return true;
|
|
82
177
|
},
|
|
178
|
+
/** Receipt becomes completion, explicitly — inside the grant transaction, so a row
|
|
179
|
+
* that says 'completed' is one whose business write committed. */
|
|
180
|
+
completeEvent(id) {
|
|
181
|
+
const row = this.processedEvents.get(id);
|
|
182
|
+
if (row && row.state === 'processing') {
|
|
183
|
+
row.state = 'completed';
|
|
184
|
+
row.completedAt = this.clock;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
/** Only for a handler that THREW and answered 5xx: the claim goes back so the retry
|
|
188
|
+
* need not wait out the expiry. A crashed worker never reaches this line — that is
|
|
189
|
+
* what the expiry is for. */
|
|
83
190
|
releaseEventClaim(id) {
|
|
84
|
-
this.processedEvents.
|
|
191
|
+
const row = this.processedEvents.get(id);
|
|
192
|
+
if (row && row.state === 'processing') this.processedEvents.delete(id);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/** The DATABASE arbitrates: an INSERT on a UNIQUE period-grant key.
|
|
196
|
+
* Synchronous — no round trip between the check and the write, which is the
|
|
197
|
+
* whole difference from readGrantedPeriods-then-mark. The ledger row carries
|
|
198
|
+
* subscription, item, invoice and period; UNIQUENESS is (subscription,
|
|
199
|
+
* period) — this reference models one plan item per subscription, and a
|
|
200
|
+
* multi-item subscription widens the key with the item. The invoice is
|
|
201
|
+
* provenance, never part of the uniqueness: a second invoice for one period
|
|
202
|
+
* must not grant the period twice. */
|
|
203
|
+
claimPeriodGrant(subId, itemId, invoiceId, periodStart) {
|
|
204
|
+
const key = `${subId}:${periodStart}`;
|
|
205
|
+
if (this.grantKeys.has(key)) return false;
|
|
206
|
+
this.grantKeys.add(key);
|
|
207
|
+
this.grantLedger.push({ key, subId, itemId, invoiceId, periodStart });
|
|
208
|
+
this.markPeriodGranted(subId, periodStart); // the view existing readers use
|
|
209
|
+
return true;
|
|
85
210
|
},
|
|
86
211
|
|
|
87
212
|
async readGrantedPeriods(subId) {
|
|
@@ -211,12 +336,23 @@ export function createHandler(store, options = {}) {
|
|
|
211
336
|
const metadata = invoiceMetadata(invoice);
|
|
212
337
|
const userId = metadata.userId;
|
|
213
338
|
|
|
214
|
-
const
|
|
215
|
-
if (has('grant-
|
|
216
|
-
|
|
217
|
-
|
|
339
|
+
const itemId = (period.line && period.line.id) || 'item';
|
|
340
|
+
if (has('grant-key-atomic')) {
|
|
341
|
+
if (has('grant-marker')
|
|
342
|
+
&& !store.claimPeriodGrant(subId, itemId, invoice.id, period.start)) {
|
|
343
|
+
store.log.push({ event: event.id, decision: 'skipped: period already granted' });
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
if (!has('grant-marker')) store.claimPeriodGrant(subId, itemId, invoice.id, period.start);
|
|
347
|
+
} else {
|
|
348
|
+
// The pre-fix shape: a SELECT (a round trip), then a mark. Kept as the mutant.
|
|
349
|
+
const granted = await store.readGrantedPeriods(subId);
|
|
350
|
+
if (has('grant-marker') && granted.has(period.start)) {
|
|
351
|
+
store.log.push({ event: event.id, decision: 'skipped: period already granted' });
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
store.markPeriodGranted(subId, period.start);
|
|
218
355
|
}
|
|
219
|
-
store.markPeriodGranted(subId, period.start);
|
|
220
356
|
store.addCredits(userId, CREDITS_PER_PERIOD);
|
|
221
357
|
store.grants.push({
|
|
222
358
|
subscription: subId, userId, periodStart: period.start, source: 'webhook', event: event.id,
|
|
@@ -239,14 +375,20 @@ export function createHandler(store, options = {}) {
|
|
|
239
375
|
const conversionId = conversionIdFor(invoice);
|
|
240
376
|
store.log.push({ event: event.id, decision: 'granted' });
|
|
241
377
|
return [
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
378
|
+
{
|
|
379
|
+
key: `${event.id}:renewal-notice`,
|
|
380
|
+
apply: () => store.notifications.push({ userId, kind: 'renewal', periodStart: period.start }),
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
key: `${event.id}:conversion`,
|
|
384
|
+
apply: () => store.conversions.push({
|
|
385
|
+
eventId: conversionId,
|
|
386
|
+
eventName: 'Purchase',
|
|
387
|
+
source: 'webhook',
|
|
388
|
+
value: invoice.amount_paid / 100,
|
|
389
|
+
currency: (invoice.currency || '').toUpperCase(),
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
250
392
|
];
|
|
251
393
|
}
|
|
252
394
|
|
|
@@ -393,34 +535,116 @@ export function createHandler(store, options = {}) {
|
|
|
393
535
|
}
|
|
394
536
|
}
|
|
395
537
|
|
|
396
|
-
/** The route Stripe posts to. Signature verification is the caller's; see SKILL.md.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
538
|
+
/** The route Stripe posts to. Signature verification is the caller's; see SKILL.md.
|
|
539
|
+
* `opts.crashAfterClaim` simulates the process dying between receipt and completion:
|
|
540
|
+
* no release, no completion, no answer — the case a boolean claim turns into a
|
|
541
|
+
* swallowed payment, because the row already says "seen". */
|
|
542
|
+
async function deliver(event, opts = {}) {
|
|
543
|
+
if (has('claim')) {
|
|
544
|
+
let claim = store.claimEvent(event.id);
|
|
545
|
+
if (claim === 'expired') {
|
|
546
|
+
claim = has('claim-expiry') && store.reclaimEvent(event.id) ? 'claimed' : 'in_flight';
|
|
547
|
+
}
|
|
548
|
+
if (claim === 'completed') {
|
|
549
|
+
return { status: 200, body: { received: true, duplicate: true } };
|
|
550
|
+
}
|
|
551
|
+
if (claim === 'in_flight') {
|
|
552
|
+
// Honest answer: somebody is (or may still be) working this event. 5xx makes
|
|
553
|
+
// Stripe retry THIS delivery too, so whichever worker dies, the event survives.
|
|
554
|
+
return { status: 500, body: { error: 'in flight' } };
|
|
555
|
+
}
|
|
400
556
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
557
|
+
if (opts.crashAfterClaim) return { status: 0, body: null }; // the worker is gone
|
|
558
|
+
// ONE transaction: entitlement, dedup marker, completion mark and the outbox rows
|
|
559
|
+
// commit together, or none of them exist. A crash inside leaves nothing applied —
|
|
560
|
+
// the claim row still says 'processing', and the retry runs the whole thing again.
|
|
561
|
+
// A serialization conflict aborts THIS attempt and retries inside the same
|
|
562
|
+
// claim — bounded at TX_RETRIES, because a database under real contention
|
|
563
|
+
// conflicts more than once and a loop with no bound is an outage. Beyond the
|
|
564
|
+
// bound the route answers 5xx with the claim released, so Stripe's redelivery
|
|
565
|
+
// brings the renewal back: a conflict may DELAY a grant, never lose it.
|
|
566
|
+
let attempts = 0;
|
|
567
|
+
for (;;) {
|
|
568
|
+
const snap = has('atomic-application') ? store.snapshot() : null;
|
|
569
|
+
try {
|
|
570
|
+
store.txAttempts += 1;
|
|
571
|
+
attempts += 1;
|
|
572
|
+
const effects = await handle(event);
|
|
573
|
+
// Receipt becomes completion in the same commit as the grant. A handler that
|
|
574
|
+
// answers 200 without this line has told Stripe "done" about work only received.
|
|
575
|
+
if (has('claim') && has('claim-completion')) store.completeEvent(event.id);
|
|
576
|
+
for (const e of effects) {
|
|
577
|
+
store.outbox.push({ key: e.key, apply: e.apply, state: 'pending' });
|
|
578
|
+
}
|
|
579
|
+
if (store.failNextTransactions > 0) {
|
|
580
|
+
store.failNextTransactions -= 1;
|
|
581
|
+
throw new SerializationConflict_();
|
|
582
|
+
}
|
|
583
|
+
if (opts.crashBeforeCommit) throw new SystemError_('killed inside the transaction');
|
|
584
|
+
break;
|
|
585
|
+
} catch (error) {
|
|
586
|
+
if (snap) store.restore(snap);
|
|
587
|
+
if (error instanceof SerializationConflict_) {
|
|
588
|
+
if (has('tx-retry-bounded') && attempts <= TX_RETRIES) continue; // same claim, fresh attempt
|
|
589
|
+
if (!has('tx-retry-bounded')) {
|
|
590
|
+
// The mutant: the conflict is swallowed and the route lies "done".
|
|
591
|
+
return { status: 200, body: { received: true } };
|
|
592
|
+
}
|
|
593
|
+
if (has('claim')) store.releaseEventClaim(event.id);
|
|
594
|
+
return { status: 500, body: { error: 'serialization conflict — retry' } };
|
|
595
|
+
}
|
|
596
|
+
if (error instanceof SystemError_) return { status: 0, body: null }; // process died
|
|
597
|
+
if (has('claim')) store.releaseEventClaim(event.id);
|
|
598
|
+
return { status: 500, body: { error: 'handler error' } };
|
|
599
|
+
}
|
|
407
600
|
}
|
|
408
|
-
|
|
601
|
+
// After the commit the outbox drains — inline here; a worker in production, with
|
|
602
|
+
// the same at-least-once semantics and therefore the same need for a consumer key.
|
|
603
|
+
drainOutbox();
|
|
409
604
|
return { status: 200, body: { received: true } };
|
|
410
605
|
}
|
|
411
606
|
|
|
607
|
+
/** The outbox consumer. The queue redelivers, so the consumer carries its OWN
|
|
608
|
+
* dedup key — the row's `key` (event id + effect kind), remembered across rows. */
|
|
609
|
+
function drainOutbox() {
|
|
610
|
+
for (const row of store.outbox) {
|
|
611
|
+
if (row.state === 'sent') continue;
|
|
612
|
+
if (has('outbox-consumer-key') && store.sentKeys.has(row.key)) {
|
|
613
|
+
row.state = 'sent';
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
row.apply();
|
|
617
|
+
if (has('outbox-consumer-key')) store.sentKeys.add(row.key);
|
|
618
|
+
row.state = 'sent';
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
412
622
|
/**
|
|
413
623
|
* The nightly repair, and the "Sync now" button. It has NO event id, so the event
|
|
414
624
|
* claim cannot protect it -- only the per-period grant marker can. This is the entry
|
|
415
625
|
* point that separates the two.
|
|
416
626
|
*/
|
|
417
627
|
async function reconcile(subId, period, metadata) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
628
|
+
// The SAME key derivation as the webhook path — a reconciler keying the item
|
|
629
|
+
// differently is two ledgers for one period, i.e. the double grant back again.
|
|
630
|
+
const reconcileItemId = (period.line && period.line.id) || 'item';
|
|
631
|
+
if (has('grant-key-atomic')) {
|
|
632
|
+
if (has('grant-marker')
|
|
633
|
+
&& !store.claimPeriodGrant(subId, reconcileItemId, 'reconcile', period.start)) {
|
|
634
|
+
store.log.push({ event: 'reconcile', decision: 'skipped: period already granted' });
|
|
635
|
+
return { granted: false };
|
|
636
|
+
}
|
|
637
|
+
if (!has('grant-marker')) {
|
|
638
|
+
store.claimPeriodGrant(subId, reconcileItemId, 'reconcile', period.start);
|
|
639
|
+
}
|
|
640
|
+
} else {
|
|
641
|
+
const granted = await store.readGrantedPeriods(subId);
|
|
642
|
+
if (has('grant-marker') && granted.has(period.start)) {
|
|
643
|
+
store.log.push({ event: 'reconcile', decision: 'skipped: period already granted' });
|
|
644
|
+
return { granted: false };
|
|
645
|
+
}
|
|
646
|
+
store.markPeriodGranted(subId, period.start);
|
|
422
647
|
}
|
|
423
|
-
store.markPeriodGranted(subId, period.start);
|
|
424
648
|
store.addCredits(metadata.userId, CREDITS_PER_PERIOD);
|
|
425
649
|
store.grants.push({
|
|
426
650
|
subscription: subId, userId: metadata.userId, periodStart: period.start,
|
|
@@ -453,5 +677,5 @@ export function createHandler(store, options = {}) {
|
|
|
453
677
|
return offer;
|
|
454
678
|
}
|
|
455
679
|
|
|
456
|
-
return { deliver, reconcile, offerRetention };
|
|
680
|
+
return { deliver, reconcile, offerRetention, drainOutbox };
|
|
457
681
|
}
|
|
@@ -143,49 +143,105 @@ user back to the plan chooser shows a paywall to somebody who just paid.
|
|
|
143
143
|
```ts
|
|
144
144
|
if (invoice.billing_reason !== "subscription_cycle") return; // see webhook-events.md
|
|
145
145
|
|
|
146
|
+
// One LEDGER ROW per granted period, arbitrated by the database:
|
|
147
|
+
// grant_ledger: UNIQUE (subscriptionId, periodStart)
|
|
148
|
+
// The row carries the item and the invoice as provenance; they are not part of
|
|
149
|
+
// the uniqueness (a second invoice for one period must not grant it twice; a
|
|
150
|
+
// multi-item subscription widens the key with the item).
|
|
146
151
|
await db.$transaction(async (tx) => {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
try {
|
|
153
|
+
await tx.grantLedger.create({ data: { subscriptionId: id, itemId, invoiceId: invoice.id,
|
|
154
|
+
periodStart, amount: allowance } });
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (isUniqueViolation(e)) return; // this period is already granted
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
151
159
|
await tx.wallet.update({ where: { userId }, data: { credit: { increment: allowance } } });
|
|
152
160
|
await tx.auditLog.create({ data: { userId, action: "grant", amount: allowance,
|
|
153
161
|
source: "subscription-renewal", metadata: { invoiceId: invoice.id } } });
|
|
154
162
|
});
|
|
155
163
|
```
|
|
156
164
|
|
|
157
|
-
|
|
158
|
-
|
|
165
|
+
Key write and grant in **one** transaction, and the KEY is per period — never a
|
|
166
|
+
high-water mark. `lastGrantedPeriodStart >= periodStart` looks like the same
|
|
167
|
+
guard and is a different rule: events arrive out of order, and a January
|
|
168
|
+
invoice landing after February's would read as a replay and be suppressed —
|
|
169
|
+
the reordered-periods fixture (`out-of-order-pair-does-not-rewind-state`)
|
|
170
|
+
forbids exactly that. And the guard must be the INSERT itself: a SELECT before
|
|
171
|
+
the write is a round trip, and two concurrent grants of one period — the
|
|
172
|
+
webhook and the reconciler, say — both pass the read and both credit
|
|
173
|
+
(`concurrent-entry-points-grant-once` in
|
|
174
|
+
[`fixtures/assert-money-invariants.mjs`](../fixtures/assert-money-invariants.mjs)
|
|
175
|
+
is the assertion that measures it, against
|
|
176
|
+
`fixtures/invoice-paid-subscription-cycle.json`).
|
|
159
177
|
|
|
160
178
|
## Seat and quantity changes
|
|
161
179
|
|
|
180
|
+
A quantity upgrade with `always_invoice` CHARGES the customer at the moment
|
|
181
|
+
Stripe accepts it. Everything below follows from that: a revert of the
|
|
182
|
+
quantity does not return the money — the proration invoice already settled —
|
|
183
|
+
so "compensate by putting the number back" compensates the count and keeps
|
|
184
|
+
the charge. The operation is recorded BEFORE the effect, an ambiguous outcome
|
|
185
|
+
stays `unknown`, and the database repairs itself FROM Stripe rather than
|
|
186
|
+
reverting Stripe to match a failed local write.
|
|
187
|
+
|
|
162
188
|
```ts
|
|
163
189
|
const item = (await stripe.subscriptions.retrieve(subId)).items.data[0];
|
|
164
190
|
|
|
191
|
+
// 1. Durable intent, BEFORE the effect: an operation row with its own
|
|
192
|
+
// idempotency key. If we crash past this point, reconciliation finds the
|
|
193
|
+
// row and asks Stripe what actually happened — the key makes the retry
|
|
194
|
+
// safe and the answer attributable.
|
|
195
|
+
const op = await db.operation.create({ data: { kind: "quantity-change", subId,
|
|
196
|
+
from: item.quantity, to: newQuantity, state: "pending",
|
|
197
|
+
idempotencyKey: crypto.randomUUID() } });
|
|
198
|
+
|
|
199
|
+
let updated;
|
|
165
200
|
try {
|
|
166
|
-
await stripe.subscriptions.update(subId, {
|
|
201
|
+
updated = await stripe.subscriptions.update(subId, {
|
|
167
202
|
items: [{ id: item.id, quantity: newQuantity }],
|
|
168
203
|
proration_behavior: "always_invoice",
|
|
169
204
|
payment_behavior: "error_if_incomplete", // upgrades only
|
|
170
|
-
});
|
|
205
|
+
}, { idempotencyKey: op.idempotencyKey });
|
|
171
206
|
} catch (err) {
|
|
172
207
|
if (err.type === "StripeCardError" || err.code === "invoice_payment_intent_requires_action") {
|
|
208
|
+
await db.operation.update({ where: { id: op.id }, data: { state: "failed" } });
|
|
173
209
|
return json({ error: "payment failed", code: "payment_failed" }, 402);
|
|
174
210
|
}
|
|
211
|
+
if (err.type === "StripeConnectionError" || err.type === "StripeAPIError") {
|
|
212
|
+
// 2. A timeout after Stripe may have ACCEPTED the effect is not a
|
|
213
|
+
// failure. Mark it unknown and leave it for reconciliation, which
|
|
214
|
+
// retrieves the subscription (or replays the key) and settles the
|
|
215
|
+
// row either way. Declaring it failed here is how a charged
|
|
216
|
+
// customer keeps the old quantity.
|
|
217
|
+
await db.operation.update({ where: { id: op.id }, data: { state: "unknown" } });
|
|
218
|
+
return json({ error: "outcome unknown, reconciling", code: "pending" }, 202);
|
|
219
|
+
}
|
|
175
220
|
throw err;
|
|
176
221
|
}
|
|
177
222
|
|
|
178
223
|
try {
|
|
179
224
|
await db.subscription.update({ where: { id }, data: { quantity: newQuantity } });
|
|
225
|
+
await db.operation.update({ where: { id: op.id }, data: { state: "applied" } });
|
|
180
226
|
} catch (dbErr) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
227
|
+
// 3. Stripe CONFIRMED the new quantity and invoiced it. The database is
|
|
228
|
+
// what failed — so the database is what gets repaired, from the
|
|
229
|
+
// confirmed Stripe state, by reconciliation reading the op row. Do NOT
|
|
230
|
+
// revert Stripe to match a broken local write: the proration invoice
|
|
231
|
+
// has already charged, and quantity:oldQuantity with proration "none"
|
|
232
|
+
// returns none of it.
|
|
233
|
+
await db.operation.update({ where: { id: op.id }, data: { state: "apply-pending" } })
|
|
234
|
+
.catch(() => log.error("CRITICAL: op row unreachable", { opId: op.id }));
|
|
185
235
|
throw dbErr;
|
|
186
236
|
}
|
|
187
237
|
```
|
|
188
238
|
|
|
239
|
+
- **If the business genuinely wants a rollback** (the product decides the
|
|
240
|
+
change must not stand), that is a SECOND financial operation, not a flag:
|
|
241
|
+
revert the quantity AND issue the credit note for the settled proration
|
|
242
|
+
invoice (`stripe.creditNotes.create`), each with its own op row and state —
|
|
243
|
+
the money's return is tracked to `applied`, never assumed from the
|
|
244
|
+
quantity's.
|
|
189
245
|
- **Downgrades** need no `payment_behavior`; they produce a credit.
|
|
190
246
|
- **Reducing below what is in use** is a business decision, not an API call.
|
|
191
247
|
Answer 409 with the list of things that must be released first, and let the
|
|
@@ -196,6 +252,51 @@ try {
|
|
|
196
252
|
- Do not add seats to a subscription flagged `cancel_at_period_end` — reactivate
|
|
197
253
|
first, or the seats vanish at period end.
|
|
198
254
|
|
|
255
|
+
## Reconciliation — repair the record, and money moves only by policy
|
|
256
|
+
|
|
257
|
+
The reconciler walks every operation row in `unknown` or `apply-pending` and
|
|
258
|
+
asks Stripe what actually happened — the op's idempotency key makes the
|
|
259
|
+
question safe to repeat and the answer attributable:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
for (const op of await db.operation.findMany({ where: { state: { in: ["unknown", "apply-pending"] } } })) {
|
|
263
|
+
const sub = await stripe.subscriptions.retrieve(op.subId);
|
|
264
|
+
const applied = sub.items.data[0].quantity === op.to;
|
|
265
|
+
if (applied) {
|
|
266
|
+
// RECONCILE-FIRST, the default: the database is repaired FROM the
|
|
267
|
+
// confirmed Stripe state. This is a record repair — updating the local
|
|
268
|
+
// count is never called a refund, because no money moved.
|
|
269
|
+
await db.subscription.update({ where: { id: op.subId }, data: { quantity: op.to } });
|
|
270
|
+
await db.operation.update({ where: { id: op.id }, data: { state: "applied" } });
|
|
271
|
+
} else {
|
|
272
|
+
await db.operation.update({ where: { id: op.id }, data: { state: "failed" } });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Two rules the loop must keep:
|
|
278
|
+
|
|
279
|
+
- **Idempotent by construction.** Every transition above is absorbing —
|
|
280
|
+
`applied` and `failed` are terminal, and re-running the loop over settled
|
|
281
|
+
rows changes nothing and charges nothing. A reconciler that can move money
|
|
282
|
+
on a replay is a billing bug wearing a repair's name.
|
|
283
|
+
- **A refund is a POLICY, never a reflex.** The reconciler repairs records;
|
|
284
|
+
it does not decide that money should come back. Only an explicitly
|
|
285
|
+
configured business policy (`rollbackPolicy: "revert-and-credit"` on the
|
|
286
|
+
operation's kind) triggers the credit-note flow from the section above —
|
|
287
|
+
and that flow issues AT MOST ONCE per operation: the credit note is keyed
|
|
288
|
+
by op id, so a repeated reconciliation finds the existing note and stops.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
if (policy(op.kind) === "revert-and-credit" && !op.creditNoteId) {
|
|
292
|
+
const note = await stripe.creditNotes.create(
|
|
293
|
+
{ invoice: op.invoiceId, amount: op.chargedAmount },
|
|
294
|
+
{ idempotencyKey: `${op.idempotencyKey}:credit` });
|
|
295
|
+
await db.operation.update({ where: { id: op.id },
|
|
296
|
+
data: { creditNoteId: note.id, state: "compensated" } });
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
199
300
|
## Plan changes
|
|
200
301
|
|
|
201
302
|
Same shape as a quantity change, with `items: [{ id: item.id, price: newPriceId }]`.
|
|
@@ -253,26 +354,46 @@ expires 5 minutes after creation if unused, so mint it in the request that redir
|
|
|
253
354
|
|
|
254
355
|
## Refund clawback
|
|
255
356
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
where: { id: stored.id, refundedTotal: stored.refundedTotal },
|
|
263
|
-
data: { refundedTotal: totalRefunded },
|
|
264
|
-
});
|
|
265
|
-
if (count === 0) return; // concurrent delivery won
|
|
357
|
+
`charge.amount_refunded` is CUMULATIVE and delivered out of order: a 4000 and
|
|
358
|
+
a 9000 refund of one charge can arrive in either order, and two handlers can
|
|
359
|
+
read the SAME `stored.refundedTotal` at once. The whole clawback — read,
|
|
360
|
+
delta, marker AND ledger — happens inside ONE serializable transaction, and a
|
|
361
|
+
CAS loser RE-READS and retries rather than returning: the old code's loser
|
|
362
|
+
went home, so the second refund clawed back nothing.
|
|
266
363
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
364
|
+
```ts
|
|
365
|
+
// Money in MINOR UNITS end to end. `amount_refunded` is already integer cents;
|
|
366
|
+
// `/ 100` turns 995 into 9.95 and reintroduces the float error the integer
|
|
367
|
+
// avoids — never divide until you format for a human.
|
|
368
|
+
const totalRefunded = charge.amount_refunded; // cents, integer
|
|
369
|
+
|
|
370
|
+
for (let attempt = 0; attempt < RETRIES; attempt++) {
|
|
371
|
+
try {
|
|
372
|
+
await db.$transaction(async (tx) => {
|
|
373
|
+
const row = await tx.purchase.findUnique({
|
|
374
|
+
where: { id: stored.id }, ...forUpdate }); // row lock
|
|
375
|
+
const seen = Math.max(row.refundedTotal, totalRefunded); // monotone: never rewind
|
|
376
|
+
const increment = seen - row.refundedTotal;
|
|
377
|
+
if (increment <= 0) return; // this delta already counted
|
|
378
|
+
// Marker and ledger move TOGETHER — a marker written without its
|
|
379
|
+
// clawback (or the reverse) is the split the old code shipped.
|
|
380
|
+
await tx.purchase.update({ where: { id: stored.id },
|
|
381
|
+
data: { refundedTotal: seen } });
|
|
382
|
+
await clawBack(tx, stored.userId, increment,
|
|
383
|
+
{ key: `${charge.id}:${seen}` }); // idempotent per (charge,total)
|
|
384
|
+
}, { isolation: "Serializable" });
|
|
385
|
+
return;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
if (isSerializationConflict(err) && attempt < RETRIES - 1) continue; // re-read, retry
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
273
390
|
}
|
|
274
391
|
```
|
|
275
392
|
|
|
393
|
+
The invariant the fixtures pin: cumulative 4000 then 9000 (or 9000 then 4000),
|
|
394
|
+
a crash after the marker, and a retry of the old event all converge on
|
|
395
|
+
`refundedTotal = 9000` and a summed clawback of exactly 9000 — once.
|
|
396
|
+
|
|
276
397
|
Clamp the deduction at zero. A user who already spent the credit goes to a
|
|
277
398
|
negative balance or to a collections decision — pick one deliberately and log
|
|
278
399
|
which. Silently deducting into negative is how a support ticket becomes a
|