@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.
- package/CHANGELOG.md +69 -0
- package/README.md +7 -0
- package/SECURITY.md +9 -9
- package/package.json +2 -2
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +68 -95
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +13 -4
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +66 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/customer-discount-created-retention.json +47 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/customer-subscription-updated-cancel-classic.json +59 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/customer-subscription-updated-cancel-flexible.json +59 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/customer-subscription-updated-discount-consumed.json +57 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +28 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +145 -3
- package/plugins/sheleg-dev/skills/stripe-billing/references/cancellation-and-retention.md +418 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +8 -1
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +36 -15
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|