@ssheleg/sheleg-dev 0.11.8 → 0.13.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 (24) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +4 -3
  3. package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
  4. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +35 -8
  5. package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +57 -12
  6. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +39 -26
  7. package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +52 -0
  8. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +42 -23
  9. package/plugins/sheleg-dev/skills/error-tracking/SKILL.md +34 -6
  10. package/plugins/sheleg-dev/skills/error-tracking/references/scrubbing.md +65 -2
  11. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +62 -23
  12. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +22 -1
  13. package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +13 -3
  14. package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +150 -64
  15. package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +36 -19
  16. package/plugins/sheleg-dev/skills/google-signin/SKILL.md +64 -23
  17. package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +150 -49
  18. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +26 -9
  19. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +17 -1
  20. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +171 -5
  21. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +333 -198
  22. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +259 -35
  23. package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +149 -28
  24. 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 Set(),
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
- /** An INSERT on a primary key: atomic, and the only claim that survives a race. */
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
- if (this.processedEvents.has(id)) return false;
80
- this.processedEvents.add(id);
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.delete(id);
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 granted = await store.readGrantedPeriods(subId);
215
- if (has('grant-marker') && granted.has(period.start)) {
216
- store.log.push({ event: event.id, decision: 'skipped: period already granted' });
217
- return [];
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
- () => store.notifications.push({ userId, kind: 'renewal', periodStart: period.start }),
243
- () => store.conversions.push({
244
- eventId: conversionId,
245
- eventName: 'Purchase',
246
- source: 'webhook',
247
- value: invoice.amount_paid / 100,
248
- currency: (invoice.currency || '').toUpperCase(),
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
- async function deliver(event) {
398
- if (has('claim') && !store.claimEvent(event.id)) {
399
- return { status: 200, body: { received: true, duplicate: true } };
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
- let afterCommit = [];
402
- try {
403
- afterCommit = await handle(event);
404
- } catch (error) {
405
- if (has('claim')) store.releaseEventClaim(event.id);
406
- return { status: 500, body: { error: 'handler error' } };
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
- for (const effect of afterCommit) effect(); // side effects run after the commit
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
- const granted = await store.readGrantedPeriods(subId);
419
- if (has('grant-marker') && granted.has(period.start)) {
420
- store.log.push({ event: 'reconcile', decision: 'skipped: period already granted' });
421
- return { granted: false };
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
- const sub = await tx.subscription.findUnique({ where: { id }, select: { lastGrantedPeriodStart: true } });
148
- if (sub?.lastGrantedPeriodStart && sub.lastGrantedPeriodStart >= periodStart) return; // replay
149
-
150
- await tx.subscription.update({ where: { id }, data: { lastGrantedPeriodStart: periodStart } });
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
- Marker and grant in **one** transaction. Two statements outside a transaction is
158
- the same bug as read-then-write, one level up.
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
- await stripe.subscriptions.update(subId, { // compensating revert
182
- items: [{ id: item.id, quantity: oldQuantity }],
183
- proration_behavior: "none", // do not re-bill the revert
184
- }).catch((e) => log.error("CRITICAL: revert failed, Stripe and DB disagree", { subId, e }));
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
- ```ts
257
- const totalRefunded = charge.amount_refunded / 100;
258
- const increment = totalRefunded - stored.refundedTotal;
259
- if (increment <= 0) return;
260
-
261
- const { count } = await db.purchase.updateMany({
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
- try {
268
- await clawBack(stored.userId, increment); // idempotent, keyed on charge id + total
269
- } catch (err) {
270
- await db.purchase.update({ where: { id: stored.id },
271
- data: { refundedTotal: stored.refundedTotal } }); // put the marker back
272
- throw err;
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