@openlup/core 0.24.0 → 0.26.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 CHANGED
@@ -10,6 +10,20 @@ in the source repository.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.26.0]
14
+
15
+ - Publishable on the npm `latest` dist-tag as `0.26.0`, from tag
16
+ `openlup-core-v0.26.0`.
17
+
18
+ ## [0.25.0]
19
+
20
+ - Publishable on the npm `latest` dist-tag as `0.25.0`, from tag
21
+ `openlup-core-v0.25.0`.
22
+
23
+ - Keep active subscriptions unchanged when the retry ladder is exhausted; the compatibility function emits no pause event and retains historical paused-row recovery.
24
+
25
+ Migration notes: Exhaustion no longer changes an active subscription to paused. Existing paused records are not rewritten; explicit customer lifecycle commands and historical recovery remain separate.
26
+
13
27
  ## [0.24.0]
14
28
 
15
29
  - Publishable on the npm `latest` dist-tag as `0.24.0`, from tag
@@ -26,33 +40,3 @@ Migration: upgrade the complete set together for exact internal peers; core intr
26
40
  ### Known limitations
27
41
 
28
42
  The fulfillment status map retains localized adopter labels; hosts still need injected labels or internationalization keys.
29
-
30
- ## [0.23.0]
31
-
32
- - Publishable on the npm `latest` dist-tag as `0.23.0`, from tag
33
- `openlup-core-v0.23.0`.
34
-
35
- - Republish unchanged APIs, runtime behavior and shipped SQL from `0.22.0` as part of the complete `0.23.0` package set.
36
-
37
- Migration: upgrade the complete set together for exact internal peers; core introduces no API or schema migration.
38
-
39
- ### Known limitations
40
-
41
- The fulfillment status map retains localized adopter labels; hosts still need injected labels or internationalization keys.
42
-
43
- ## [0.22.0]
44
-
45
- - Publishable on the npm `latest` dist-tag as `0.22.0`, from tag
46
- `openlup-core-v0.22.0`.
47
-
48
- - Add pure fulfillment status, tracking-progress, current-parcel and canonical
49
- fact-parser helpers to the existing `./fulfillment` subpath. Generic fact types
50
- retain their names; narrowed fact aliases use the `Canonical` prefix. New
51
- fulfillment re-exports carry the same `@beta` annotation as existing facts.
52
-
53
- Migration: existing platform fulfillment imports remain compatible re-exports;
54
- no application import or schema migration is required.
55
-
56
- ### Known limitations
57
-
58
- - `FULFILLMENT_STATUS_MAP` carries localized adopter labels. Replace them with host-injected labels or internationalization keys before any stable-framework claim.
package/README.md CHANGED
@@ -8,11 +8,12 @@ application" section, which `docs:check` requires, tells an application's agent
8
8
  not to edit or patch the installed package and to upgrade every `@openlup/*`
9
9
  package together.
10
10
 
11
- The prepared `0.24.0` set carries the Zod validation floor below; remaining APIs,
12
- runtime behavior and shipped SQL are unchanged from `0.23.0`.
13
- Upgrade all packages together and read [the migration and label limitation](CHANGELOG.md#0240).
14
- The size baseline is the published `0.23.0` artifact; older releases remain in
15
- the source-only `CHANGELOG-HISTORY.md`. Preparation establishes no npm publication.
11
+ The prepared `0.26.0` set republishes this kernel with the platform's managed
12
+ dunning attempt-replay changes. Upgrade all packages together and read
13
+ [the migration notes](CHANGELOG.md#0260), including the platform's required
14
+ nullable failure-attempt field. The size baseline is the published `0.25.0`
15
+ artifact; older releases remain in the source-only `CHANGELOG-HISTORY.md`.
16
+ Preparation establishes no npm publication.
16
17
 
17
18
  The prepared set requires Zod `^4.6.6` for GHSA-mrhx-c7xc-m3w5.
18
19
  Oversized constrained collections fail before per-element errors accumulate;
@@ -11,10 +11,11 @@
11
11
  * - `ladderTerminatedByClass(failureClass, cadence?)` — whether the REASON for
12
12
  * the refusal, rather than the rung reached, ends the ladder.
13
13
  * - `isDunningLadderExhausted(cycle, cadence?)` — whether a refused cycle has
14
- * run past the last rung, the only non-payment reason to pause.
14
+ * run past the last rung; exhaustion leaves the subscription active.
15
15
  *
16
16
  * TERMINATING, not capping. An attempt past the end of the ladder answers
17
- * `null`, which is the pause signal; it does not answer "the last slot again".
17
+ * `null`, which ends automatic retries without pausing the subscription; it never
18
+ * answers "the last slot again".
18
19
  * Callers therefore never need a `attempt <= maxRetryAttempts()` guard of their
19
20
  * own — asking for the next slot IS the decision, and a second copy of the
20
21
  * budget check is exactly how two rails drift apart.
@@ -76,15 +77,11 @@ export declare function maxRetryAttempts(cadence?: CycleRetryCadence): number;
76
77
  * Whether a refused cycle has EXHAUSTED the ladder: it failed, nothing is
77
78
  * scheduled after it, and the attempt that failed sits past the last rung.
78
79
  *
79
- * This is the one condition under which the platform may pause a live
80
- * subscription for non-payment. Both halves are required. An absent schedule on
81
- * its own is not exhaustion: a class-terminated refusal on rung one also leaves
82
- * nothing scheduled, and treating it as exhaustion would pause a subscription on
83
- * the day of its first refusal. The rung is derived from the cadence, so pass
84
- * the same cadence here and to `recordPaymentFailure`. The managed SQL dunning
85
- * boundary spells the shipped ladder's rung as a fixed constant
86
- * (`v_ladder_exhausted_from`, 4); a deployment that publishes a different
87
- * ladder must move that constant with it.
80
+ * Exhaustion stops automatic retries; it never pauses the subscription.
81
+ * An absent schedule alone is not exhaustion: a terminating refusal class can
82
+ * leave no retry on the first rung. Pass the same cadence here and to
83
+ * `recordPaymentFailure`. Legacy expired-dunning recovery also uses this
84
+ * predicate to distinguish an exhausted cycle from an early refusal.
88
85
  *
89
86
  * @beta
90
87
  */
@@ -11,10 +11,11 @@
11
11
  * - `ladderTerminatedByClass(failureClass, cadence?)` — whether the REASON for
12
12
  * the refusal, rather than the rung reached, ends the ladder.
13
13
  * - `isDunningLadderExhausted(cycle, cadence?)` — whether a refused cycle has
14
- * run past the last rung, the only non-payment reason to pause.
14
+ * run past the last rung; exhaustion leaves the subscription active.
15
15
  *
16
16
  * TERMINATING, not capping. An attempt past the end of the ladder answers
17
- * `null`, which is the pause signal; it does not answer "the last slot again".
17
+ * `null`, which ends automatic retries without pausing the subscription; it never
18
+ * answers "the last slot again".
18
19
  * Callers therefore never need a `attempt <= maxRetryAttempts()` guard of their
19
20
  * own — asking for the next slot IS the decision, and a second copy of the
20
21
  * budget check is exactly how two rails drift apart.
@@ -99,15 +100,11 @@ export function maxRetryAttempts(cadence = DEFAULT_CYCLE_RETRY_CADENCE) {
99
100
  * Whether a refused cycle has EXHAUSTED the ladder: it failed, nothing is
100
101
  * scheduled after it, and the attempt that failed sits past the last rung.
101
102
  *
102
- * This is the one condition under which the platform may pause a live
103
- * subscription for non-payment. Both halves are required. An absent schedule on
104
- * its own is not exhaustion: a class-terminated refusal on rung one also leaves
105
- * nothing scheduled, and treating it as exhaustion would pause a subscription on
106
- * the day of its first refusal. The rung is derived from the cadence, so pass
107
- * the same cadence here and to `recordPaymentFailure`. The managed SQL dunning
108
- * boundary spells the shipped ladder's rung as a fixed constant
109
- * (`v_ladder_exhausted_from`, 4); a deployment that publishes a different
110
- * ladder must move that constant with it.
103
+ * Exhaustion stops automatic retries; it never pauses the subscription.
104
+ * An absent schedule alone is not exhaustion: a terminating refusal class can
105
+ * leave no retry on the first rung. Pass the same cadence here and to
106
+ * `recordPaymentFailure`. Legacy expired-dunning recovery also uses this
107
+ * predicate to distinguish an exhausted cycle from an early refusal.
111
108
  *
112
109
  * @beta
113
110
  */
@@ -72,19 +72,11 @@ export declare function reactivateSubscription(input: {
72
72
  reason?: string;
73
73
  }): SubscriptionEngineResult<SubscriptionMutationResult>;
74
74
  /**
75
- * The sanctioned non-payment rule: a cycle that exhausted the retry ladder
76
- * pauses an ACTIVE subscription.
77
- *
78
- * This is the only path in the engine that suspends a live subscription for a
79
- * reason the owner did not choose, and it fires only when
80
- * {@link isDunningLadderExhausted} holds for the refused cycle. A ladder cut
81
- * short by a terminating refusal class, or a failure on any rung that still has
82
- * a retry after it, is refused as `dunning_not_exhausted` and leaves the
83
- * subscription untouched. A subscription that is not active (already paused,
84
- * cancelled or completed) is returned unchanged with no event, which is what
85
- * the managed SQL dunning boundary does and what makes a replay harmless.
86
- *
87
- * The schedule is not touched: pausing never moves `nextCycleAt`.
75
+ * Compatibility entry point for an exhausted retry ladder. Exhaustion leaves
76
+ * the subscription unchanged and emits no pause event. The unpaid cycle stays
77
+ * available for authorized recovery; it never starts a new cycle implicitly.
78
+ * Below exhaustion the call still refuses as `dunning_not_exhausted`.
79
+ * Explicit owner pauses and legacy expired-dunning recovery remain separate.
88
80
  *
89
81
  * @beta
90
82
  */
@@ -95,7 +87,7 @@ export declare function pauseSubscriptionForExpiredDunning(input: {
95
87
  cadence?: CycleRetryCadence;
96
88
  }): SubscriptionEngineResult<SubscriptionMutationResult>;
97
89
  /**
98
- * Customer-initiated recovery out of the non-payment pause.
90
+ * Customer-initiated recovery out of a historical non-payment pause.
99
91
  *
100
92
  * Mirrors the managed SQL `subscription_resume_after_expired_dunning`: the
101
93
  * subscription must be paused and the cycle it passes must be the refused cycle
@@ -140,8 +132,9 @@ export declare const RESTART_LEAD_DAYS = 2;
140
132
  * sides. `cancelled` -> `active` is the owner's win-back.
141
133
  *
142
134
  * Which OPERATION may take an edge is narrower than the matrix: `active` ->
143
- * `paused` is the owner's pause or {@link pauseSubscriptionForExpiredDunning},
144
- * and `cancelled` -> `active` is only {@link reactivateSubscription}.
135
+ * `paused` is now the owner's pause; historical non-payment pauses remain stored
136
+ * and use their separate recovery path. `cancelled` -> `active` is only
137
+ * {@link reactivateSubscription}.
145
138
  *
146
139
  * @beta
147
140
  */
@@ -130,7 +130,7 @@ export function pauseSubscription(input) {
130
130
  export function resumeSubscription(input) {
131
131
  // Resume leaves `paused` only. Leaving `cancelled` is a win-back and goes
132
132
  // through reactivateSubscription, which re-arms the schedule. A subscription
133
- // the exhausted ladder paused must resume through
133
+ // a legacy exhausted ladder paused must resume through
134
134
  // resumeSubscriptionFromExpiredDunning, which skips the uncollected cycle.
135
135
  return changeSubscriptionStatus(input.subscription, "active", input.now, "subscription.resumed", {}, ["paused"]);
136
136
  }
@@ -182,19 +182,11 @@ export function reactivateSubscription(input) {
182
182
  });
183
183
  }
184
184
  /**
185
- * The sanctioned non-payment rule: a cycle that exhausted the retry ladder
186
- * pauses an ACTIVE subscription.
187
- *
188
- * This is the only path in the engine that suspends a live subscription for a
189
- * reason the owner did not choose, and it fires only when
190
- * {@link isDunningLadderExhausted} holds for the refused cycle. A ladder cut
191
- * short by a terminating refusal class, or a failure on any rung that still has
192
- * a retry after it, is refused as `dunning_not_exhausted` and leaves the
193
- * subscription untouched. A subscription that is not active (already paused,
194
- * cancelled or completed) is returned unchanged with no event, which is what
195
- * the managed SQL dunning boundary does and what makes a replay harmless.
196
- *
197
- * The schedule is not touched: pausing never moves `nextCycleAt`.
185
+ * Compatibility entry point for an exhausted retry ladder. Exhaustion leaves
186
+ * the subscription unchanged and emits no pause event. The unpaid cycle stays
187
+ * available for authorized recovery; it never starts a new cycle implicitly.
188
+ * Below exhaustion the call still refuses as `dunning_not_exhausted`.
189
+ * Explicit owner pauses and legacy expired-dunning recovery remain separate.
198
190
  *
199
191
  * @beta
200
192
  */
@@ -206,32 +198,12 @@ export function pauseSubscriptionForExpiredDunning(input) {
206
198
  if (mismatch)
207
199
  return mismatch;
208
200
  if (!isDunningLadderExhausted(input.cycle, input.cadence ?? DEFAULT_CYCLE_RETRY_CADENCE)) {
209
- return failure("dunning_not_exhausted", "only a cycle that exhausted the retry ladder may pause a subscription for non-payment");
210
- }
211
- if (input.subscription.status !== "active") {
212
- return success({ subscription: touchSubscription(input.subscription, input.subscription.updatedAt, {}), events: [] });
201
+ return failure("dunning_not_exhausted", "the cycle did not exhaust the retry ladder");
213
202
  }
214
- const subscription = touchSubscription(input.subscription, now.toISOString(), statusPatch("paused"));
215
- return success({
216
- subscription,
217
- events: [
218
- makeEvent({
219
- eventType: "subscription.paused",
220
- subscription,
221
- cycleNumber: input.cycle.cycleNumber,
222
- idempotencyKey: `${input.cycle.engineIdempotencyKey}:subscription_paused`,
223
- occurredAt: now.toISOString(),
224
- payload: {
225
- reason: "payment_failed_expired",
226
- retryAttempt: input.cycle.retryAttempt,
227
- failureReason: input.cycle.failureReason,
228
- },
229
- }),
230
- ],
231
- });
203
+ return success({ subscription: touchSubscription(input.subscription, input.subscription.updatedAt, {}), events: [] });
232
204
  }
233
205
  /**
234
- * Customer-initiated recovery out of the non-payment pause.
206
+ * Customer-initiated recovery out of a historical non-payment pause.
235
207
  *
236
208
  * Mirrors the managed SQL `subscription_resume_after_expired_dunning`: the
237
209
  * subscription must be paused and the cycle it passes must be the refused cycle
@@ -413,8 +385,9 @@ function cycleOwnershipError(subscription, cycle) {
413
385
  * sides. `cancelled` -> `active` is the owner's win-back.
414
386
  *
415
387
  * Which OPERATION may take an edge is narrower than the matrix: `active` ->
416
- * `paused` is the owner's pause or {@link pauseSubscriptionForExpiredDunning},
417
- * and `cancelled` -> `active` is only {@link reactivateSubscription}.
388
+ * `paused` is now the owner's pause; historical non-payment pauses remain stored
389
+ * and use their separate recovery path. `cancelled` -> `active` is only
390
+ * {@link reactivateSubscription}.
418
391
  *
419
392
  * @beta
420
393
  */
@@ -137,9 +137,9 @@ there is none. `DEFAULT_CYCLE_RETRY_CADENCE` ships `backoffHours: [24, 72, 168]`
137
137
  ladder in place, and injectable so a deployment can publish its own.
138
138
 
139
139
  Asking for the next slot **is** the decision. `null` means only that no next
140
- retry is scheduled; it is a pause signal only after the rung budget is
141
- exhausted. Class termination before exhaustion leaves the subscription active
142
- and must not be treated as exhaustion. The function never answers "the last
140
+ retry is scheduled; neither exhaustion nor class termination pauses the
141
+ subscription. Class termination before exhaustion must not be treated as
142
+ exhaustion. The function never answers "the last
143
143
  slot again". Callers must not add their own
144
144
  `attempt <= maxRetryAttempts()` guard — a second copy of the budget check is
145
145
  exactly how two rails drift apart.
@@ -158,7 +158,7 @@ listed, recognised class that the decision table already refuses can shorten
158
158
  the ladder. The SQL retry rail publishes the same singleton, and the parity test
159
159
  fails if those two spellings drift.
160
160
 
161
- ## The status matrix, and the one non-payment pause
161
+ ## The status matrix and dunning continuity
162
162
 
163
163
  `SUBSCRIPTION_STATUS_TRANSITIONS` is the whole status matrix, frozen:
164
164
 
@@ -182,26 +182,23 @@ the end instant and cancellation reason, requires the customer's confirmation of
182
182
  the charge timing, refuses while a cycle is locked and bumps the template
183
183
  version. Those durable duties stay with the host.
184
184
 
185
- **The sanctioned non-payment rule.** Nothing in this engine suspends an active
186
- subscription for a reason its owner did not choose, except a renewal that
187
- exhausted the retry ladder. `isDunningLadderExhausted(cycle, cadence?)` holds
188
- only when the cycle is `payment_failed`, has no `nextRetryAt`, and its
189
- `retryAttempt` is past `maxRetryAttempts(cadence)`. A deployment with its own
190
- ladder passes the same cadence to `recordPaymentFailure` and to both dunning
191
- functions, or they disagree about exhaustion. Both halves are required: a
192
- class-terminated refusal on rung one also leaves nothing scheduled, and it must
193
- leave the subscription active. `pauseSubscriptionForExpiredDunning` pauses an
194
- active subscription when that predicate holds. It records `subscription.paused`
195
- with reason `payment_failed_expired` and never touches `nextCycleAt`. It refuses
196
- every other cycle as `dunning_not_exhausted`, and returns a subscription that is
197
- not active unchanged with no event, so a replay is harmless.
198
-
199
- `resumeSubscriptionFromExpiredDunning` is the customer's recovery. It needs a
185
+ **The sanctioned non-payment rule.** Payment failure never pauses an active
186
+ subscription. `isDunningLadderExhausted(cycle, cadence?)` means the cycle is
187
+ `payment_failed`, has no `nextRetryAt`, and is past the last retry rung.
188
+ Pass the same cadence to payment failure and dunning operations.
189
+ `pauseSubscriptionForExpiredDunning` retains its compatibility name and inputs,
190
+ but now returns an exhausted subscription unchanged with no event. It refuses
191
+ non-exhausted cycles as `dunning_not_exhausted`. Exhaustion ends automatic
192
+ retries; authorized recovery may still pay the same cycle. Managed SQL still
193
+ hard-codes exhaustion at rung 4 in its dunning, recovery and due-list guards;
194
+ a custom core cadence does not configure those durable boundaries.
195
+
196
+ `resumeSubscriptionFromExpiredDunning` is the customer's historical-pause recovery. It needs a
200
197
  paused subscription, the exhausted cycle and a payment method, stored or
201
198
  supplied. The given cycle becomes `skipped` rather than being charged late.
202
199
  Whether the method is chargeable unattended is the host's check, and so is
203
200
  skipping any other uncollected cycle the host holds; the SQL boundary does both.
204
- A subscription this rule paused must not go through plain `resumeSubscription`,
201
+ A subscription the historical rule paused must not go through plain `resumeSubscription`,
205
202
  which neither skips the cycle nor moves a stale `nextCycleAt`.
206
203
 
207
204
  Both restarts put `nextCycleAt` `RESTART_LEAD_DAYS` (two) days out, but never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlup/core",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Neutral D2C subscription and bundle commerce kernels; a development preview with no stable API.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -290,8 +290,8 @@
290
290
  "release-gates.json"
291
291
  ],
292
292
  "baseline": {
293
- "version": "0.23.0",
294
- "packedBytes": 167815
293
+ "version": "0.25.0",
294
+ "packedBytes": 167564
295
295
  },
296
296
  "browserEntries": {}
297
297
  },
@@ -11,10 +11,11 @@
11
11
  * - `ladderTerminatedByClass(failureClass, cadence?)` — whether the REASON for
12
12
  * the refusal, rather than the rung reached, ends the ladder.
13
13
  * - `isDunningLadderExhausted(cycle, cadence?)` — whether a refused cycle has
14
- * run past the last rung, the only non-payment reason to pause.
14
+ * run past the last rung; exhaustion leaves the subscription active.
15
15
  *
16
16
  * TERMINATING, not capping. An attempt past the end of the ladder answers
17
- * `null`, which is the pause signal; it does not answer "the last slot again".
17
+ * `null`, which ends automatic retries without pausing the subscription; it never
18
+ * answers "the last slot again".
18
19
  * Callers therefore never need a `attempt <= maxRetryAttempts()` guard of their
19
20
  * own — asking for the next slot IS the decision, and a second copy of the
20
21
  * budget check is exactly how two rails drift apart.
@@ -146,15 +147,11 @@ export function maxRetryAttempts(
146
147
  * Whether a refused cycle has EXHAUSTED the ladder: it failed, nothing is
147
148
  * scheduled after it, and the attempt that failed sits past the last rung.
148
149
  *
149
- * This is the one condition under which the platform may pause a live
150
- * subscription for non-payment. Both halves are required. An absent schedule on
151
- * its own is not exhaustion: a class-terminated refusal on rung one also leaves
152
- * nothing scheduled, and treating it as exhaustion would pause a subscription on
153
- * the day of its first refusal. The rung is derived from the cadence, so pass
154
- * the same cadence here and to `recordPaymentFailure`. The managed SQL dunning
155
- * boundary spells the shipped ladder's rung as a fixed constant
156
- * (`v_ladder_exhausted_from`, 4); a deployment that publishes a different
157
- * ladder must move that constant with it.
150
+ * Exhaustion stops automatic retries; it never pauses the subscription.
151
+ * An absent schedule alone is not exhaustion: a terminating refusal class can
152
+ * leave no retry on the first rung. Pass the same cadence here and to
153
+ * `recordPaymentFailure`. Legacy expired-dunning recovery also uses this
154
+ * predicate to distinguish an exhausted cycle from an early refusal.
158
155
  *
159
156
  * @beta
160
157
  */
@@ -193,7 +193,7 @@ export function resumeSubscription(input: {
193
193
  }): SubscriptionEngineResult<SubscriptionMutationResult> {
194
194
  // Resume leaves `paused` only. Leaving `cancelled` is a win-back and goes
195
195
  // through reactivateSubscription, which re-arms the schedule. A subscription
196
- // the exhausted ladder paused must resume through
196
+ // a legacy exhausted ladder paused must resume through
197
197
  // resumeSubscriptionFromExpiredDunning, which skips the uncollected cycle.
198
198
  return changeSubscriptionStatus(input.subscription, "active", input.now, "subscription.resumed", {}, ["paused"]);
199
199
  }
@@ -263,19 +263,11 @@ export function reactivateSubscription(input: {
263
263
  }
264
264
 
265
265
  /**
266
- * The sanctioned non-payment rule: a cycle that exhausted the retry ladder
267
- * pauses an ACTIVE subscription.
268
- *
269
- * This is the only path in the engine that suspends a live subscription for a
270
- * reason the owner did not choose, and it fires only when
271
- * {@link isDunningLadderExhausted} holds for the refused cycle. A ladder cut
272
- * short by a terminating refusal class, or a failure on any rung that still has
273
- * a retry after it, is refused as `dunning_not_exhausted` and leaves the
274
- * subscription untouched. A subscription that is not active (already paused,
275
- * cancelled or completed) is returned unchanged with no event, which is what
276
- * the managed SQL dunning boundary does and what makes a replay harmless.
277
- *
278
- * The schedule is not touched: pausing never moves `nextCycleAt`.
266
+ * Compatibility entry point for an exhausted retry ladder. Exhaustion leaves
267
+ * the subscription unchanged and emits no pause event. The unpaid cycle stays
268
+ * available for authorized recovery; it never starts a new cycle implicitly.
269
+ * Below exhaustion the call still refuses as `dunning_not_exhausted`.
270
+ * Explicit owner pauses and legacy expired-dunning recovery remain separate.
279
271
  *
280
272
  * @beta
281
273
  */
@@ -292,35 +284,14 @@ export function pauseSubscriptionForExpiredDunning(input: {
292
284
  if (!isDunningLadderExhausted(input.cycle, input.cadence ?? DEFAULT_CYCLE_RETRY_CADENCE)) {
293
285
  return failure(
294
286
  "dunning_not_exhausted",
295
- "only a cycle that exhausted the retry ladder may pause a subscription for non-payment",
287
+ "the cycle did not exhaust the retry ladder",
296
288
  );
297
289
  }
298
- if (input.subscription.status !== "active") {
299
- return success({ subscription: touchSubscription(input.subscription, input.subscription.updatedAt, {}), events: [] });
300
- }
301
-
302
- const subscription = touchSubscription(input.subscription, now.toISOString(), statusPatch("paused"));
303
- return success({
304
- subscription,
305
- events: [
306
- makeEvent({
307
- eventType: "subscription.paused",
308
- subscription,
309
- cycleNumber: input.cycle.cycleNumber,
310
- idempotencyKey: `${input.cycle.engineIdempotencyKey}:subscription_paused`,
311
- occurredAt: now.toISOString(),
312
- payload: {
313
- reason: "payment_failed_expired",
314
- retryAttempt: input.cycle.retryAttempt,
315
- failureReason: input.cycle.failureReason,
316
- },
317
- }),
318
- ],
319
- });
290
+ return success({ subscription: touchSubscription(input.subscription, input.subscription.updatedAt, {}), events: [] });
320
291
  }
321
292
 
322
293
  /**
323
- * Customer-initiated recovery out of the non-payment pause.
294
+ * Customer-initiated recovery out of a historical non-payment pause.
324
295
  *
325
296
  * Mirrors the managed SQL `subscription_resume_after_expired_dunning`: the
326
297
  * subscription must be paused and the cycle it passes must be the refused cycle
@@ -551,8 +522,9 @@ function cycleOwnershipError(
551
522
  * sides. `cancelled` -> `active` is the owner's win-back.
552
523
  *
553
524
  * Which OPERATION may take an edge is narrower than the matrix: `active` ->
554
- * `paused` is the owner's pause or {@link pauseSubscriptionForExpiredDunning},
555
- * and `cancelled` -> `active` is only {@link reactivateSubscription}.
525
+ * `paused` is now the owner's pause; historical non-payment pauses remain stored
526
+ * and use their separate recovery path. `cancelled` -> `active` is only
527
+ * {@link reactivateSubscription}.
556
528
  *
557
529
  * @beta
558
530
  */