@ssheleg/sheleg-dev 0.9.2 → 0.10.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.
@@ -0,0 +1,59 @@
1
+ {
2
+ "id": "evt_PLACEHOLDER_cancel_classic",
3
+ "object": "event",
4
+ "api_version": "2026-07-29.dahlia",
5
+ "created": 1767226000,
6
+ "livemode": false,
7
+ "pending_webhooks": 1,
8
+ "request": { "id": null, "idempotency_key": null },
9
+ "type": "customer.subscription.updated",
10
+ "data": {
11
+ "object": {
12
+ "id": "sub_PLACEHOLDER_bob",
13
+ "object": "subscription",
14
+ "customer": "cus_PLACEHOLDER_bob",
15
+ "status": "active",
16
+ "currency": "usd",
17
+ "billing_mode": { "type": "classic" },
18
+ "cancel_at": 1769904000,
19
+ "cancel_at_period_end": true,
20
+ "canceled_at": 1767226000,
21
+ "cancellation_details": {
22
+ "comment": null,
23
+ "feedback": "unused",
24
+ "reason": "cancellation_requested"
25
+ },
26
+ "collection_method": "charge_automatically",
27
+ "discounts": [],
28
+ "metadata": {
29
+ "userId": "usr_PLACEHOLDER_bob",
30
+ "productId": "prod_PLACEHOLDER_pro",
31
+ "quantity": "1"
32
+ },
33
+ "items": {
34
+ "object": "list",
35
+ "has_more": false,
36
+ "total_count": 1,
37
+ "data": [
38
+ {
39
+ "id": "si_PLACEHOLDER_bob",
40
+ "object": "subscription_item",
41
+ "quantity": 1,
42
+ "current_period_start": 1767225600,
43
+ "current_period_end": 1769904000,
44
+ "discounts": [],
45
+ "price": {
46
+ "id": "price_PLACEHOLDER_pro_monthly",
47
+ "object": "price",
48
+ "product": "prod_PLACEHOLDER_pro",
49
+ "currency": "usd",
50
+ "unit_amount": 2900,
51
+ "recurring": { "interval": "month", "interval_count": 1 }
52
+ }
53
+ }
54
+ ]
55
+ }
56
+ },
57
+ "previous_attributes": { "cancel_at": null, "cancel_at_period_end": false }
58
+ }
59
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "id": "evt_PLACEHOLDER_cancel_flexible",
3
+ "object": "event",
4
+ "api_version": "2026-07-29.dahlia",
5
+ "created": 1767226000,
6
+ "livemode": false,
7
+ "pending_webhooks": 1,
8
+ "request": { "id": null, "idempotency_key": null },
9
+ "type": "customer.subscription.updated",
10
+ "data": {
11
+ "object": {
12
+ "id": "sub_PLACEHOLDER_alice",
13
+ "object": "subscription",
14
+ "customer": "cus_PLACEHOLDER_alice",
15
+ "status": "active",
16
+ "currency": "usd",
17
+ "billing_mode": { "type": "flexible" },
18
+ "cancel_at": 1769904000,
19
+ "cancel_at_period_end": false,
20
+ "canceled_at": 1767226000,
21
+ "cancellation_details": {
22
+ "comment": null,
23
+ "feedback": "too_expensive",
24
+ "reason": "cancellation_requested"
25
+ },
26
+ "collection_method": "charge_automatically",
27
+ "discounts": [],
28
+ "metadata": {
29
+ "userId": "usr_PLACEHOLDER_alice",
30
+ "productId": "prod_PLACEHOLDER_pro",
31
+ "quantity": "1"
32
+ },
33
+ "items": {
34
+ "object": "list",
35
+ "has_more": false,
36
+ "total_count": 1,
37
+ "data": [
38
+ {
39
+ "id": "si_PLACEHOLDER_alice",
40
+ "object": "subscription_item",
41
+ "quantity": 1,
42
+ "current_period_start": 1767225600,
43
+ "current_period_end": 1769904000,
44
+ "discounts": [],
45
+ "price": {
46
+ "id": "price_PLACEHOLDER_pro_monthly",
47
+ "object": "price",
48
+ "product": "prod_PLACEHOLDER_pro",
49
+ "currency": "usd",
50
+ "unit_amount": 2900,
51
+ "recurring": { "interval": "month", "interval_count": 1 }
52
+ }
53
+ }
54
+ ]
55
+ }
56
+ },
57
+ "previous_attributes": { "cancel_at": null, "canceled_at": null }
58
+ }
59
+ }
@@ -0,0 +1,57 @@
1
+ {
2
+ "id": "evt_PLACEHOLDER_discount_consumed",
3
+ "object": "event",
4
+ "api_version": "2026-07-29.dahlia",
5
+ "created": 1769904100,
6
+ "livemode": false,
7
+ "pending_webhooks": 1,
8
+ "request": { "id": null, "idempotency_key": null },
9
+ "type": "customer.subscription.updated",
10
+ "data": {
11
+ "object": {
12
+ "id": "sub_PLACEHOLDER_alice",
13
+ "object": "subscription",
14
+ "customer": "cus_PLACEHOLDER_alice",
15
+ "status": "active",
16
+ "currency": "usd",
17
+ "billing_mode": { "type": "flexible" },
18
+ "cancel_at": null,
19
+ "cancel_at_period_end": false,
20
+ "canceled_at": null,
21
+ "cancellation_details": { "comment": null, "feedback": null, "reason": null },
22
+ "collection_method": "charge_automatically",
23
+ "discounts": [],
24
+ "metadata": {
25
+ "userId": "usr_PLACEHOLDER_alice",
26
+ "productId": "prod_PLACEHOLDER_pro",
27
+ "quantity": "1"
28
+ },
29
+ "items": {
30
+ "object": "list",
31
+ "has_more": false,
32
+ "total_count": 1,
33
+ "data": [
34
+ {
35
+ "id": "si_PLACEHOLDER_alice",
36
+ "object": "subscription_item",
37
+ "quantity": 1,
38
+ "current_period_start": 1769904000,
39
+ "current_period_end": 1772323200,
40
+ "discounts": [],
41
+ "price": {
42
+ "id": "price_PLACEHOLDER_pro_monthly",
43
+ "object": "price",
44
+ "product": "prod_PLACEHOLDER_pro",
45
+ "currency": "usd",
46
+ "unit_amount": 2900,
47
+ "recurring": { "interval": "month", "interval_count": 1 }
48
+ }
49
+ }
50
+ ]
51
+ }
52
+ },
53
+ "previous_attributes": {
54
+ "discounts": ["di_PLACEHOLDER_save50"]
55
+ }
56
+ }
57
+ }
@@ -167,6 +167,34 @@
167
167
  "phrase": "the id the browser will reuse"
168
168
  }
169
169
  ]
170
+ },
171
+ {
172
+ "id": "retention-offer-is-consumed-once",
173
+ "isolates": "retention-eligibility",
174
+ "fixtures": [
175
+ "customer-discount-created-retention.json",
176
+ "customer-subscription-updated-discount-consumed.json"
177
+ ],
178
+ "claimedBy": [
179
+ {
180
+ "document": "references/cancellation-and-retention.md",
181
+ "phrase": "**The ledger is the source of truth, Stripe is the second opinion.**"
182
+ }
183
+ ]
184
+ },
185
+ {
186
+ "id": "scheduled-cancellation-survives-billing-mode",
187
+ "isolates": "cancellation-timestamp",
188
+ "fixtures": [
189
+ "customer-subscription-updated-cancel-flexible.json",
190
+ "customer-subscription-updated-cancel-classic.json"
191
+ ],
192
+ "claimedBy": [
193
+ {
194
+ "document": "references/cancellation-and-retention.md",
195
+ "phrase": "Store the timestamp, not the boolean."
196
+ }
197
+ ]
170
198
  }
171
199
  ]
172
200
  }
@@ -2,8 +2,8 @@
2
2
  // same handler with one rule removed at a time.
3
3
  //
4
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
5
+ // database, and the decisions `SKILL.md`, `references/webhook-events.md` and
6
+ // `references/cancellation-and-retention.md` require. Point `assert-money-invariants.mjs` at YOUR handler instead and the
7
7
  // assertions do not change — that is the whole purpose of shipping it.
8
8
  //
9
9
  // Two properties of the store matter, and both model a real database rather than a
@@ -27,6 +27,15 @@ export const RULES = Object.freeze([
27
27
  'cumulative-refund',
28
28
  'async-gate',
29
29
  'conversion-id-from-metadata',
30
+ // Eligibility for the save offer read from YOUR ledger rather than from Stripe's
31
+ // `subscription.discounts` -- which a `duration=once` discount leaves as soon as its
32
+ // invoice finalizes, so Stripe's answer to "was this customer already discounted" is
33
+ // no, every month, forever.
34
+ 'retention-eligibility',
35
+ // A scheduled cancellation read from BOTH `cancel_at` and `cancel_at_period_end`.
36
+ // Under flexible billing_mode a portal cancellation sets the first and leaves the
37
+ // second false, so the flag alone reads as "renews" on a subscription Stripe will end.
38
+ 'cancellation-timestamp',
30
39
  // The baseline, and the broadest mutant: a handler that treats `invoice.paid` as
31
40
  // information and grants nothing. It breaks every grant assertion at once, which is the
32
41
  // point — without it, a handler that does nothing would satisfy every invariant about
@@ -41,6 +50,13 @@ export const RULES = Object.freeze([
41
50
  /** Credits one paid period is worth. Arbitrary; the assertions count grants, not credits. */
42
51
  export const CREDITS_PER_PERIOD = 500;
43
52
 
53
+ /** The save offer this handler is willing to make. Yours, not Stripe's -- a coupon cannot
54
+ * be restricted to one customer, so the identity of the offer has to live on your side. */
55
+ export const RETENTION_OFFER_ID = 'cancel-50-once';
56
+ export const RETENTION_COUPON = 'cpn_PLACEHOLDER_save50';
57
+ /** How many times a customer may walk the cancel flow without taking the offer. */
58
+ export const RETENTION_DECLINE_LIMIT = 2;
59
+
44
60
  const tick = () => new Promise((resolve) => setImmediate(resolve));
45
61
 
46
62
  export function createStore() {
@@ -53,6 +69,8 @@ export function createStore() {
53
69
  grants: [], // one row per grant actually applied
54
70
  clawbacks: [], // { paymentIntent, amount } in minor units
55
71
  conversions: [], // what the server sent to the ad platforms
72
+ retentionOffers: [], // { customerId, offerId, couponId, subscriptionId, redeemedAt }
73
+ saves: [], // one row per cancellation deflected, recorded from the discount event
56
74
  notifications: [], // side effects that must not run twice
57
75
  log: [],
58
76
 
@@ -88,6 +106,25 @@ export function createStore() {
88
106
  return true;
89
107
  },
90
108
 
109
+ /** The ledger, and the reason it is a SELECT: it is a row, not a Stripe field. */
110
+ async readRetentionOffers(customerId) {
111
+ await tick();
112
+ return this.retentionOffers.filter((o) => o.customerId === customerId);
113
+ },
114
+ /** Written BEFORE the portal session exists. An offer nobody recorded cannot be counted. */
115
+ openRetentionOffer(row) {
116
+ this.retentionOffers.push({ ...row, redeemedAt: null });
117
+ },
118
+ /** Returns how many rows matched -- zero means a discount nobody here offered. */
119
+ markRetentionRedeemed(customerId, couponId, at) {
120
+ const row = this.retentionOffers.find(
121
+ (o) => o.customerId === customerId && o.couponId === couponId && o.redeemedAt === null,
122
+ );
123
+ if (!row) return 0;
124
+ row.redeemedAt = at;
125
+ return 1;
126
+ },
127
+
91
128
  addCredits(userId, amount) {
92
129
  this.credits.set(userId, (this.credits.get(userId) || 0) + amount);
93
130
  },
@@ -118,6 +155,25 @@ function periodOf(invoice) {
118
155
  return { start: line.period.start, end: line.period.end, line };
119
156
  }
120
157
 
158
+ /** The coupon behind a discount moved down a level in `2025-09-30.clover`:
159
+ * `discount.coupon` is gone and `discount.source.coupon` replaced it. Reading the old
160
+ * path yields `undefined` rather than an error, so a save card renders "% off" with
161
+ * nothing in front of it. */
162
+ export function couponIdOf(discount) {
163
+ const source = discount.source;
164
+ if (!source || source.type !== 'coupon') return null;
165
+ const coupon = source.coupon;
166
+ if (typeof coupon === 'string') return coupon;
167
+ return (coupon && coupon.id) || null;
168
+ }
169
+
170
+ /** The latest period end across items -- what `cancel_at_period_end` means once a
171
+ * subscription can hold items on different intervals. */
172
+ export function maxPeriodEnd(sub) {
173
+ const items = (sub.items && sub.items.data) || [];
174
+ return items.reduce((max, i) => Math.max(max, i.current_period_end || 0), 0) || null;
175
+ }
176
+
121
177
  export function createHandler(store, options = {}) {
122
178
  const without = new Set(options.without || []);
123
179
  for (const rule of without) {
@@ -258,6 +314,64 @@ export function createHandler(store, options = {}) {
258
314
  return [];
259
315
  }
260
316
 
317
+ /** A scheduled cancellation, read from both fields because flexible and classic
318
+ * billing modes record it in different ones -- and one account holds both kinds of
319
+ * row, because `billing_mode` cannot be changed after creation. */
320
+ function cancellationAt(sub) {
321
+ if (!has('cancellation-timestamp')) {
322
+ // The classic-only reading. Under flexible mode this returns null for a
323
+ // subscription Stripe will end, and the banner says "renews".
324
+ return sub.cancel_at_period_end ? maxPeriodEnd(sub) : null;
325
+ }
326
+ if (sub.cancel_at) return sub.cancel_at;
327
+ if (sub.cancel_at_period_end) return maxPeriodEnd(sub);
328
+ return null;
329
+ }
330
+
331
+ async function subscriptionUpdated(event) {
332
+ const sub = event.data.object;
333
+ const existing = store.subscriptions.get(sub.id) || {
334
+ periodStart: null, periodEnd: null, quantity: 1, priceId: null,
335
+ };
336
+ store.subscriptions.set(sub.id, {
337
+ ...existing,
338
+ status: sub.status,
339
+ cancelAt: cancellationAt(sub),
340
+ cancellationFeedback: (sub.cancellation_details || {}).feedback || null,
341
+ discountIds: (sub.discounts || []).map((d) => (typeof d === 'string' ? d : d.id)),
342
+ billingMode: (sub.billing_mode || {}).type || null,
343
+ });
344
+ store.log.push({ event: event.id, decision: 'mirrored subscription' });
345
+ return [];
346
+ }
347
+
348
+ /** The redemption. There is no portal event for a completed flow, so this is the only
349
+ * signal that a cancellation was deflected rather than abandoned. */
350
+ async function discountCreated(event) {
351
+ const discount = event.data.object;
352
+ const couponId = couponIdOf(discount);
353
+ const customerId = typeof discount.customer === 'string'
354
+ ? discount.customer
355
+ : (discount.customer && discount.customer.id) || null;
356
+ if (!couponId || !customerId) {
357
+ store.log.push({ event: event.id, decision: 'skipped: not a coupon discount' });
358
+ return [];
359
+ }
360
+ const matched = store.markRetentionRedeemed(customerId, couponId, event.created);
361
+ if (matched === 0) {
362
+ // Applied by support, by the Dashboard, or by a Dashboard-configured retention
363
+ // coupon nobody told the code about. Swallowing this is how a discount programme
364
+ // becomes untraceable.
365
+ store.log.push({ event: event.id, decision: 'discount with no open retention offer' });
366
+ return [];
367
+ }
368
+ store.saves.push({
369
+ customerId, couponId, subscription: discount.subscription, at: event.created,
370
+ });
371
+ store.log.push({ event: event.id, decision: 'retention offer redeemed' });
372
+ return [];
373
+ }
374
+
261
375
  async function handle(event) {
262
376
  switch (event.type) {
263
377
  case 'invoice.paid':
@@ -270,6 +384,10 @@ export function createHandler(store, options = {}) {
270
384
  case 'checkout.session.async_payment_failed':
271
385
  store.log.push({ event: event.id, decision: 'async payment failed: nothing granted' });
272
386
  return [];
387
+ case 'customer.subscription.updated':
388
+ return subscriptionUpdated(event);
389
+ case 'customer.discount.created':
390
+ return discountCreated(event);
273
391
  default:
274
392
  return []; // 200 for a type we do not handle
275
393
  }
@@ -311,5 +429,29 @@ export function createHandler(store, options = {}) {
311
429
  return { granted: true };
312
430
  }
313
431
 
314
- return { deliver, reconcile };
432
+ /**
433
+ * The decision your server makes BEFORE it mints a cancel-flow portal session: may this
434
+ * customer be shown the save offer? It is a fourth entry point because Stripe has no
435
+ * answer to it -- `retention.coupon_offer` takes a coupon, and a coupon cannot be
436
+ * restricted to one customer, capped per customer, or deactivated.
437
+ */
438
+ async function offerRetention(customerId, sub) {
439
+ if ((sub.discounts || []).length > 0) return null; // discounted RIGHT NOW
440
+ if (sub.status !== 'active') return null; // never discount out of dunning
441
+
442
+ const offer = { offerId: RETENTION_OFFER_ID, couponId: RETENTION_COUPON };
443
+ if (!has('retention-eligibility')) return offer; // no ledger at all: Stripe decides
444
+
445
+ const history = await store.readRetentionOffers(customerId);
446
+ if (history.some((o) => o.redeemedAt !== null)) return null;
447
+ if (history.filter((o) => o.redeemedAt === null).length >= RETENTION_DECLINE_LIMIT) {
448
+ return null;
449
+ }
450
+ // Written BEFORE the portal session is minted. Without this row the redemption that
451
+ // arrives later matches nothing, so a save cannot be told from a support discount.
452
+ store.openRetentionOffer({ customerId, ...offer, subscriptionId: sub.id });
453
+ return offer;
454
+ }
455
+
456
+ return { deliver, reconcile, offerRetention };
315
457
  }