@openlup/core 0.6.0 → 0.9.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 +43 -0
- package/README.md +21 -0
- package/dist/company-identity/contracts.d.ts +5 -5
- package/dist/company-identity/contracts.js +5 -6
- package/dist/company-identity/index.d.ts +1 -1
- package/dist/company-identity/index.js +1 -1
- package/dist/subscription/cycleHardening.d.ts +24 -1
- package/dist/subscription/cycleHardening.js +24 -1
- package/dist/subscription/subscriptionEngineLifecycle.d.ts +100 -2
- package/dist/subscription/subscriptionEngineLifecycle.js +248 -11
- package/dist/subscription/subscriptionEnginePayment.d.ts +6 -0
- package/dist/subscription/subscriptionEnginePayment.js +1 -1
- package/dist/subscription/subscriptionEngineTypes.d.ts +1 -1
- package/package.json +1 -1
- package/release-gates.json +3 -3
- package/src/company-identity/contracts.ts +5 -6
- package/src/company-identity/index.ts +1 -0
- package/src/subscription/cycleHardening.ts +34 -1
- package/src/subscription/subscriptionEngineLifecycle.ts +302 -10
- package/src/subscription/subscriptionEnginePayment.ts +7 -2
- package/src/subscription/subscriptionEngineTypes.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,30 @@ SemVer promise.
|
|
|
16
16
|
|
|
17
17
|
### Changed
|
|
18
18
|
|
|
19
|
+
- **BEHAVIOUR CHANGE for the subscription status matrix.** `cancelled` is no
|
|
20
|
+
longer terminal: `cancelled -> active` is now an allowed edge. This is the
|
|
21
|
+
owner win-back that the managed SQL guard
|
|
22
|
+
`public.subscription_guard_status_transition` already admits. Only the new
|
|
23
|
+
`reactivateSubscription` takes that edge. `resumeSubscription` still leaves
|
|
24
|
+
`paused` only and still refuses a cancelled subscription. `completed` stays
|
|
25
|
+
terminal. A caller that treated `cancelled` as final must now expect a
|
|
26
|
+
reactivated subscription to renew again.
|
|
27
|
+
- **BEHAVIOUR CHANGE: an exhausted retry ladder now has a modelled outcome.** A
|
|
28
|
+
renewal whose refusals ran past the last rung may pause an active
|
|
29
|
+
subscription through `pauseSubscriptionForExpiredDunning`, which is the
|
|
30
|
+
sanctioned non-payment rule. It is the only engine path that suspends a live
|
|
31
|
+
subscription for a reason the owner did not choose. A ladder cut short by a
|
|
32
|
+
terminating refusal class, or a failure that still has a retry after it, is
|
|
33
|
+
refused as `dunning_not_exhausted` and leaves the subscription active.
|
|
34
|
+
`recordPaymentFailure` is unchanged and still never touches the subscription.
|
|
35
|
+
- `SubscriptionEngineErrorCode` gains `missing_payment_method` and
|
|
36
|
+
`dunning_not_exhausted`. An exhaustive `switch` over the union must handle
|
|
37
|
+
both.
|
|
38
|
+
|
|
39
|
+
- Publishable on the npm `preview` dist-tag as `0.9.0`, for source preview
|
|
40
|
+
`openlup-source-preview/9`.
|
|
41
|
+
- Publishable on the npm `preview` dist-tag as `0.7.0`, for source preview
|
|
42
|
+
`openlup-source-preview/7`.
|
|
19
43
|
- Publishable on the npm `preview` dist-tag as `0.6.0`, for source preview
|
|
20
44
|
`openlup-source-preview/6`. Directory `npm publish` stays refused.
|
|
21
45
|
|
|
@@ -37,6 +61,25 @@ SemVer promise.
|
|
|
37
61
|
|
|
38
62
|
### Added
|
|
39
63
|
|
|
64
|
+
- Export neutral lookup normalization from `./company-identity` for extensions.
|
|
65
|
+
|
|
66
|
+
- `SUBSCRIPTION_STATUS_TRANSITIONS`, the frozen status matrix, and
|
|
67
|
+
`canTransitionSubscriptionStatus` on `./subscription`. The matrix mirrors the
|
|
68
|
+
managed SQL guard over the engine's four statuses, and a contract test
|
|
69
|
+
enumerates every ordered pair.
|
|
70
|
+
- `reactivateSubscription`, the win-back from `cancelled`. It needs a stored
|
|
71
|
+
payment method and restarts the schedule `RESTART_LEAD_DAYS` (two) days out,
|
|
72
|
+
never earlier than the stored `nextCycleAt`.
|
|
73
|
+
- `isDunningLadderExhausted(cycle, cadence?)` on the retry ladder. It holds when
|
|
74
|
+
a cycle is `payment_failed`, has nothing scheduled, and its attempt is past
|
|
75
|
+
`maxRetryAttempts(cadence)`.
|
|
76
|
+
- `pauseSubscriptionForExpiredDunning` and
|
|
77
|
+
`resumeSubscriptionFromExpiredDunning`. The resume is the customer's recovery
|
|
78
|
+
from the non-payment pause: it takes an optional replacement method, skips the
|
|
79
|
+
uncollected cycle, and restarts the schedule like a win-back.
|
|
80
|
+
- `recordPaymentFailure` takes an optional `cadence`, so a deployment ladder
|
|
81
|
+
reaches the schedule and the exhaustion check alike. Callers that pass none
|
|
82
|
+
keep the shipped ladder.
|
|
40
83
|
- `recordPaymentFailure` accepts an optional neutral `failureClass` and passes it
|
|
41
84
|
to the canonical retry-ladder decision. Existing callers remain
|
|
42
85
|
source-compatible. The shipped terminating-class set is exactly
|
package/README.md
CHANGED
|
@@ -58,9 +58,30 @@ Focused proofs are `npm run api:check`, `npm run release:check`, and
|
|
|
58
58
|
`npm run test:consumer`. The root test script skips this suite. A `preview`
|
|
59
59
|
install is evaluation, not a supported install.
|
|
60
60
|
|
|
61
|
+
Repository neutrality checks reuse the existing source scanner through
|
|
62
|
+
[the tree counting interface](./scripts/neutrality-tree-counts.ts). This
|
|
63
|
+
process interface reads JSON containing source paths, contents and a policy
|
|
64
|
+
from standard input, then emits finding counts for each path. Shell sources
|
|
65
|
+
retain the scanner's existing shell handling. The interface adds no package
|
|
66
|
+
export and changes no matcher or kernel behavior.
|
|
67
|
+
|
|
68
|
+
The repository checker also calls the UI neutrality counting process with
|
|
69
|
+
`--counts-json` to count its existing patterns over the supplied sources. Running that checker without
|
|
70
|
+
arguments still scans the UI source directory and refuses any forbidden hit.
|
|
71
|
+
Published Tree CI runs the repository ratchet in required `self-check` and its
|
|
72
|
+
CLI refusal tests separately in required `test`; unrestricted root diagnostics
|
|
73
|
+
do not replace either gate. The independent core CI step retains this package's
|
|
74
|
+
complete verification when root diagnostics are red.
|
|
75
|
+
|
|
76
|
+
Together these interfaces let the repository measure existing findings without
|
|
77
|
+
making one package import another package's internals. They provide source
|
|
78
|
+
neutrality evidence, not package activation or consumer compatibility evidence.
|
|
79
|
+
|
|
61
80
|
## Kernel Documentation
|
|
62
81
|
|
|
63
82
|
- [`docs/SUBSCRIPTION_ENGINE.md`](docs/SUBSCRIPTION_ENGINE.md) — what the
|
|
64
83
|
`./subscription` kernel owns, its deterministic-clock contract, the
|
|
65
84
|
late-payment cycle-shift rule and its monotonic clamp, and how a host
|
|
66
85
|
application consumes it.
|
|
86
|
+
|
|
87
|
+
<!-- openlup-doc-impact {"unit":"core","digest":"sha256-c190852df0084211fd02f2d72c4abeaacfa238d45e0a86eaa30ae456cb8bb830","reason":"The core manifest and its lockfile change only the lockstep version from 0.7.0 to 0.9.0. Exports, package smoke, publication settings and the development-preview maturity described here are unchanged."} -->
|
|
@@ -96,11 +96,9 @@ export declare const companyIdentitySourceSchema: z.ZodObject<{
|
|
|
96
96
|
observedAt: z.ZodString;
|
|
97
97
|
evidenceHash: z.ZodNullable<z.ZodString>;
|
|
98
98
|
}, z.core.$strict>;
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* may reconcile against a registry. Parsing normalizes country to upper-case and
|
|
103
|
-
* identifier kind to lower-case. */
|
|
99
|
+
/** Lookup coordinates with optional manual identity for registry reconciliation.
|
|
100
|
+
* Country is an open ISO-3166 alpha-2 code. Parsing uppercases country and
|
|
101
|
+
* lowercases identifier kind; national tax-id rules belong to extensions. */
|
|
104
102
|
/** @beta */
|
|
105
103
|
export declare const companyIdentityLookupRequestSchema: z.ZodPipe<z.ZodObject<{
|
|
106
104
|
country: z.ZodString;
|
|
@@ -212,6 +210,8 @@ interface NormalizableCompanyIdentityLookupRequest {
|
|
|
212
210
|
purpose: CompanyIdentityPurpose;
|
|
213
211
|
manualCompany?: Partial<CompanyIdentityCompany>;
|
|
214
212
|
}
|
|
213
|
+
/** @beta */
|
|
214
|
+
export declare function normalizeCompanyIdentityLookupRequest(request: NormalizableCompanyIdentityLookupRequest): NormalizableCompanyIdentityLookupRequest;
|
|
215
215
|
/** True when the company has the minimum fields needed to render/persist a full
|
|
216
216
|
* billing identity: legal name plus a complete registered address. Used to gate
|
|
217
217
|
* whether a `partial` lookup is usable for its purpose. */
|
|
@@ -96,11 +96,9 @@ export const companyIdentitySourceSchema = z
|
|
|
96
96
|
evidenceHash: z.string().trim().min(8).max(160).nullable(),
|
|
97
97
|
})
|
|
98
98
|
.strict();
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* may reconcile against a registry. Parsing normalizes country to upper-case and
|
|
103
|
-
* identifier kind to lower-case. */
|
|
99
|
+
/** Lookup coordinates with optional manual identity for registry reconciliation.
|
|
100
|
+
* Country is an open ISO-3166 alpha-2 code. Parsing uppercases country and
|
|
101
|
+
* lowercases identifier kind; national tax-id rules belong to extensions. */
|
|
104
102
|
/** @beta */
|
|
105
103
|
export const companyIdentityLookupRequestSchema = z
|
|
106
104
|
.object({
|
|
@@ -131,7 +129,8 @@ export const companyIdentityLookupResponseSchema = z
|
|
|
131
129
|
warnings: z.array(z.string().trim().min(1).max(120)).default([]),
|
|
132
130
|
})
|
|
133
131
|
.strict();
|
|
134
|
-
|
|
132
|
+
/** @beta */
|
|
133
|
+
export function normalizeCompanyIdentityLookupRequest(request) {
|
|
135
134
|
const country = String(request.country).trim().toUpperCase();
|
|
136
135
|
const identifierKind = String(request.identifierKind).trim().toLowerCase();
|
|
137
136
|
const identifierValue = request.identifierValue.trim();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @beta */
|
|
2
|
-
export { COMPANY_IDENTITY_CONTRACT_VERSION, companyIdentityAddressSchema, companyIdentityCompanySchema, companyIdentityIdentifierKindSchema, companyIdentityLookupRequestSchema, companyIdentityLookupResponseSchema, companyIdentityLookupStatusSchema, companyIdentityPurposeSchema, companyIdentitySourceSchema, companyIdentitySourceStatusSchema, companyIdentityVerificationLevelSchema, isCompleteCompanyIdentity, } from "./contracts.js";
|
|
2
|
+
export { COMPANY_IDENTITY_CONTRACT_VERSION, companyIdentityAddressSchema, companyIdentityCompanySchema, companyIdentityIdentifierKindSchema, companyIdentityLookupRequestSchema, companyIdentityLookupResponseSchema, companyIdentityLookupStatusSchema, companyIdentityPurposeSchema, companyIdentitySourceSchema, companyIdentitySourceStatusSchema, companyIdentityVerificationLevelSchema, isCompleteCompanyIdentity, normalizeCompanyIdentityLookupRequest, } from "./contracts.js";
|
|
3
3
|
/** @beta */
|
|
4
4
|
export type { CompanyIdentityCompany, CompanyIdentityLookupRequest, CompanyIdentityLookupResponse, CompanyIdentityPurpose, CompanyIdentitySource, CompanyIdentitySourceStatus, CompanyIdentityVerificationLevel, } from "./contracts.js";
|
|
5
5
|
/** @beta */
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** @beta */
|
|
2
|
-
export { COMPANY_IDENTITY_CONTRACT_VERSION, companyIdentityAddressSchema, companyIdentityCompanySchema, companyIdentityIdentifierKindSchema, companyIdentityLookupRequestSchema, companyIdentityLookupResponseSchema, companyIdentityLookupStatusSchema, companyIdentityPurposeSchema, companyIdentitySourceSchema, companyIdentitySourceStatusSchema, companyIdentityVerificationLevelSchema, isCompleteCompanyIdentity, } from "./contracts.js";
|
|
2
|
+
export { COMPANY_IDENTITY_CONTRACT_VERSION, companyIdentityAddressSchema, companyIdentityCompanySchema, companyIdentityIdentifierKindSchema, companyIdentityLookupRequestSchema, companyIdentityLookupResponseSchema, companyIdentityLookupStatusSchema, companyIdentityPurposeSchema, companyIdentitySourceSchema, companyIdentitySourceStatusSchema, companyIdentityVerificationLevelSchema, isCompleteCompanyIdentity, normalizeCompanyIdentityLookupRequest, } from "./contracts.js";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Subscription cycle hardening helpers — the canonical retry ladder a renewal
|
|
3
3
|
* payment failure follows.
|
|
4
4
|
*
|
|
5
|
-
* This module is the SINGLE authority on the ladder. It answers
|
|
5
|
+
* This module is the SINGLE authority on the ladder. It answers four questions:
|
|
6
6
|
*
|
|
7
7
|
* - `nextRetryAttemptAt(failedAt, attemptNumber, cadence?, failureClass?)` —
|
|
8
8
|
* when the next charge is due, or `null` when no next charge is due.
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* — whether a failed payment may move into `retry_scheduled`.
|
|
11
11
|
* - `ladderTerminatedByClass(failureClass, cadence?)` — whether the REASON for
|
|
12
12
|
* the refusal, rather than the rung reached, ends the ladder.
|
|
13
|
+
* - `isDunningLadderExhausted(cycle, cadence?)` — whether a refused cycle has
|
|
14
|
+
* run past the last rung, the only non-payment reason to pause.
|
|
13
15
|
*
|
|
14
16
|
* TERMINATING, not capping. An attempt past the end of the ladder answers
|
|
15
17
|
* `null`, which is the pause signal; it does not answer "the last slot again".
|
|
@@ -70,3 +72,24 @@ export declare function nextRetryAttemptAt(failedAtIso: string, attemptNumber: n
|
|
|
70
72
|
export declare function shouldScheduleRetry(cycleStatus: CycleHandlerStatus, attemptNumber: number, cadence?: CycleRetryCadence, failureClass?: string | null): boolean;
|
|
71
73
|
/** @beta */
|
|
72
74
|
export declare function maxRetryAttempts(cadence?: CycleRetryCadence): number;
|
|
75
|
+
/**
|
|
76
|
+
* Whether a refused cycle has EXHAUSTED the ladder: it failed, nothing is
|
|
77
|
+
* scheduled after it, and the attempt that failed sits past the last rung.
|
|
78
|
+
*
|
|
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.
|
|
88
|
+
*
|
|
89
|
+
* @beta
|
|
90
|
+
*/
|
|
91
|
+
export declare function isDunningLadderExhausted(cycle: {
|
|
92
|
+
readonly status: CycleHandlerStatus;
|
|
93
|
+
readonly retryAttempt: number;
|
|
94
|
+
readonly nextRetryAt: string | null;
|
|
95
|
+
}, cadence?: CycleRetryCadence): boolean;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Subscription cycle hardening helpers — the canonical retry ladder a renewal
|
|
3
3
|
* payment failure follows.
|
|
4
4
|
*
|
|
5
|
-
* This module is the SINGLE authority on the ladder. It answers
|
|
5
|
+
* This module is the SINGLE authority on the ladder. It answers four questions:
|
|
6
6
|
*
|
|
7
7
|
* - `nextRetryAttemptAt(failedAt, attemptNumber, cadence?, failureClass?)` —
|
|
8
8
|
* when the next charge is due, or `null` when no next charge is due.
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* — whether a failed payment may move into `retry_scheduled`.
|
|
11
11
|
* - `ladderTerminatedByClass(failureClass, cadence?)` — whether the REASON for
|
|
12
12
|
* the refusal, rather than the rung reached, ends the ladder.
|
|
13
|
+
* - `isDunningLadderExhausted(cycle, cadence?)` — whether a refused cycle has
|
|
14
|
+
* run past the last rung, the only non-payment reason to pause.
|
|
13
15
|
*
|
|
14
16
|
* TERMINATING, not capping. An attempt past the end of the ladder answers
|
|
15
17
|
* `null`, which is the pause signal; it does not answer "the last slot again".
|
|
@@ -93,3 +95,24 @@ export function shouldScheduleRetry(cycleStatus, attemptNumber, cadence = DEFAUL
|
|
|
93
95
|
export function maxRetryAttempts(cadence = DEFAULT_CYCLE_RETRY_CADENCE) {
|
|
94
96
|
return cadence.backoffHours.length;
|
|
95
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether a refused cycle has EXHAUSTED the ladder: it failed, nothing is
|
|
100
|
+
* scheduled after it, and the attempt that failed sits past the last rung.
|
|
101
|
+
*
|
|
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.
|
|
111
|
+
*
|
|
112
|
+
* @beta
|
|
113
|
+
*/
|
|
114
|
+
export function isDunningLadderExhausted(cycle, cadence = DEFAULT_CYCLE_RETRY_CADENCE) {
|
|
115
|
+
return (cycle.status === "payment_failed" &&
|
|
116
|
+
cycle.nextRetryAt === null &&
|
|
117
|
+
cycle.retryAttempt > maxRetryAttempts(cadence));
|
|
118
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { SubscriptionLineSnapshot } from "./types.js";
|
|
2
|
-
import
|
|
1
|
+
import type { SubscriptionLineSnapshot, SubscriptionStatus } from "./types.js";
|
|
2
|
+
import { type CycleRetryCadence } from "./cycleHardening.js";
|
|
3
|
+
import type { EngineCycle, EngineSubscription, SubscriptionEngineResult, SubscriptionMutationResult } from "./subscriptionEngineTypes.js";
|
|
3
4
|
/** @beta */
|
|
4
5
|
export declare function swapTemplateLine(input: {
|
|
5
6
|
subscription: EngineSubscription;
|
|
@@ -50,3 +51,100 @@ export declare function completeSubscription(input: {
|
|
|
50
51
|
subscription: EngineSubscription;
|
|
51
52
|
now: string;
|
|
52
53
|
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
54
|
+
/**
|
|
55
|
+
* Win-back: the owner reactivates their own cancelled subscription.
|
|
56
|
+
*
|
|
57
|
+
* Mirrors the `cancelled -> active` edge of the managed SQL guard
|
|
58
|
+
* `public.subscription_guard_status_transition` and the customer `reactivate`
|
|
59
|
+
* action behind it. A reactivation charges again, so it needs a stored payment
|
|
60
|
+
* method, and it re-arms the next cycle {@link RESTART_LEAD_DAYS} out rather
|
|
61
|
+
* than leaving a stale instant that would make the renewal due immediately.
|
|
62
|
+
* The SQL action also clears the end instant and cancellation reason, requires
|
|
63
|
+
* the customer's confirmation of the charge timing, refuses while a cycle is
|
|
64
|
+
* locked and bumps the template version; those durable duties stay with the
|
|
65
|
+
* host.
|
|
66
|
+
*
|
|
67
|
+
* @beta
|
|
68
|
+
*/
|
|
69
|
+
export declare function reactivateSubscription(input: {
|
|
70
|
+
subscription: EngineSubscription;
|
|
71
|
+
now: string;
|
|
72
|
+
reason?: string;
|
|
73
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
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`.
|
|
88
|
+
*
|
|
89
|
+
* @beta
|
|
90
|
+
*/
|
|
91
|
+
export declare function pauseSubscriptionForExpiredDunning(input: {
|
|
92
|
+
subscription: EngineSubscription;
|
|
93
|
+
cycle: EngineCycle;
|
|
94
|
+
now: string;
|
|
95
|
+
cadence?: CycleRetryCadence;
|
|
96
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
97
|
+
/**
|
|
98
|
+
* Customer-initiated recovery out of the non-payment pause.
|
|
99
|
+
*
|
|
100
|
+
* Mirrors the managed SQL `subscription_resume_after_expired_dunning`: the
|
|
101
|
+
* subscription must be paused and the cycle it passes must be the refused cycle
|
|
102
|
+
* that exhausted the ladder (the caller passes the LATEST such cycle, as the SQL
|
|
103
|
+
* boundary reads the latest dunning case). A new payment method, when given,
|
|
104
|
+
* replaces the stored one; without any method the resume is refused. Whether
|
|
105
|
+
* the method is chargeable unattended is the host's check, as it is in SQL.
|
|
106
|
+
* The given cycle becomes `skipped` rather than being charged late; the SQL
|
|
107
|
+
* boundary skips every uncollected cycle, so a host that holds more than one
|
|
108
|
+
* must skip the rest itself. The next cycle is re-armed
|
|
109
|
+
* {@link RESTART_LEAD_DAYS} out.
|
|
110
|
+
*
|
|
111
|
+
* @beta
|
|
112
|
+
*/
|
|
113
|
+
export declare function resumeSubscriptionFromExpiredDunning(input: {
|
|
114
|
+
subscription: EngineSubscription;
|
|
115
|
+
cycle: EngineCycle;
|
|
116
|
+
now: string;
|
|
117
|
+
paymentMethodRef?: string | null;
|
|
118
|
+
paymentMethodKind?: string | null;
|
|
119
|
+
cadence?: CycleRetryCadence;
|
|
120
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult & {
|
|
121
|
+
cycle: EngineCycle;
|
|
122
|
+
}>;
|
|
123
|
+
/**
|
|
124
|
+
* How far out a restarted subscription's next cycle is re-armed, in days. Both
|
|
125
|
+
* managed SQL restart paths (the customer `reactivate` action and
|
|
126
|
+
* `subscription_resume_after_expired_dunning`) spell it `interval '2 days'`.
|
|
127
|
+
*
|
|
128
|
+
* @beta
|
|
129
|
+
*/
|
|
130
|
+
export declare const RESTART_LEAD_DAYS = 2;
|
|
131
|
+
/**
|
|
132
|
+
* The engine's subscription status matrix: every edge a status may take, and
|
|
133
|
+
* nothing else.
|
|
134
|
+
*
|
|
135
|
+
* It mirrors the managed SQL guard `public.subscription_guard_status_transition`
|
|
136
|
+
* over the four statuses the engine manages. The guard's remaining edges belong
|
|
137
|
+
* to the activation flow the engine never drives (`pending_activation` ->
|
|
138
|
+
* `active` / `activation_failed` / `cancelled`, and the audited expired-checkout
|
|
139
|
+
* reopen `cancelled` -> `pending_activation`). `completed` is terminal on both
|
|
140
|
+
* sides. `cancelled` -> `active` is the owner's win-back.
|
|
141
|
+
*
|
|
142
|
+
* 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}.
|
|
145
|
+
*
|
|
146
|
+
* @beta
|
|
147
|
+
*/
|
|
148
|
+
export declare const SUBSCRIPTION_STATUS_TRANSITIONS: Readonly<Record<SubscriptionStatus, readonly SubscriptionStatus[]>>;
|
|
149
|
+
/** @beta */
|
|
150
|
+
export declare function canTransitionSubscriptionStatus(from: SubscriptionStatus, to: SubscriptionStatus): boolean;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isEditWindowOpen } from "./subscriptionEngineCore.js";
|
|
2
|
+
import { DEFAULT_CYCLE_RETRY_CADENCE, isDunningLadderExhausted, } from "./cycleHardening.js";
|
|
2
3
|
import { addCadenceDays, cloneTemplate, failure, makeEvent, parseIso, statusPatch, success, touchSubscription, validateTemplate, } from "./subscriptionEngineUtils.js";
|
|
3
4
|
/** @beta */
|
|
4
5
|
export function swapTemplateLine(input) {
|
|
@@ -127,7 +128,11 @@ export function pauseSubscription(input) {
|
|
|
127
128
|
}
|
|
128
129
|
/** @beta */
|
|
129
130
|
export function resumeSubscription(input) {
|
|
130
|
-
|
|
131
|
+
// Resume leaves `paused` only. Leaving `cancelled` is a win-back and goes
|
|
132
|
+
// through reactivateSubscription, which re-arms the schedule. A subscription
|
|
133
|
+
// the exhausted ladder paused must resume through
|
|
134
|
+
// resumeSubscriptionFromExpiredDunning, which skips the uncollected cycle.
|
|
135
|
+
return changeSubscriptionStatus(input.subscription, "active", input.now, "subscription.resumed", {}, ["paused"]);
|
|
131
136
|
}
|
|
132
137
|
/** @beta */
|
|
133
138
|
export function cancelSubscription(input) {
|
|
@@ -139,6 +144,171 @@ export function cancelSubscription(input) {
|
|
|
139
144
|
export function completeSubscription(input) {
|
|
140
145
|
return changeSubscriptionStatus(input.subscription, "completed", input.now, "subscription.completed", {});
|
|
141
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Win-back: the owner reactivates their own cancelled subscription.
|
|
149
|
+
*
|
|
150
|
+
* Mirrors the `cancelled -> active` edge of the managed SQL guard
|
|
151
|
+
* `public.subscription_guard_status_transition` and the customer `reactivate`
|
|
152
|
+
* action behind it. A reactivation charges again, so it needs a stored payment
|
|
153
|
+
* method, and it re-arms the next cycle {@link RESTART_LEAD_DAYS} out rather
|
|
154
|
+
* than leaving a stale instant that would make the renewal due immediately.
|
|
155
|
+
* The SQL action also clears the end instant and cancellation reason, requires
|
|
156
|
+
* the customer's confirmation of the charge timing, refuses while a cycle is
|
|
157
|
+
* locked and bumps the template version; those durable duties stay with the
|
|
158
|
+
* host.
|
|
159
|
+
*
|
|
160
|
+
* @beta
|
|
161
|
+
*/
|
|
162
|
+
export function reactivateSubscription(input) {
|
|
163
|
+
const now = parseIso(input.now);
|
|
164
|
+
if (!now)
|
|
165
|
+
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
166
|
+
if (input.subscription.status !== "cancelled") {
|
|
167
|
+
return failure("invalid_transition", `Cannot reactivate a subscription that is ${input.subscription.status}; only cancelled subscriptions can be won back`);
|
|
168
|
+
}
|
|
169
|
+
if (!input.subscription.paymentMethodRef) {
|
|
170
|
+
return failure("missing_payment_method", "reactivation requires a stored payment method");
|
|
171
|
+
}
|
|
172
|
+
const nextCycleAt = restartNextCycleAt(input.subscription, now);
|
|
173
|
+
if (!nextCycleAt)
|
|
174
|
+
return failure("invalid_timestamp", "restart exceeds the supported timestamp range");
|
|
175
|
+
return restart({
|
|
176
|
+
subscription: input.subscription,
|
|
177
|
+
now,
|
|
178
|
+
nextCycleAt,
|
|
179
|
+
idempotencyPrefix: `${input.subscription.id}:reactivated:${now.toISOString()}`,
|
|
180
|
+
payload: { reactivatedFrom: "cancelled", reason: input.reason ?? null },
|
|
181
|
+
leadingEvents: [],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
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`.
|
|
198
|
+
*
|
|
199
|
+
* @beta
|
|
200
|
+
*/
|
|
201
|
+
export function pauseSubscriptionForExpiredDunning(input) {
|
|
202
|
+
const now = parseIso(input.now);
|
|
203
|
+
if (!now)
|
|
204
|
+
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
205
|
+
const mismatch = cycleOwnershipError(input.subscription, input.cycle);
|
|
206
|
+
if (mismatch)
|
|
207
|
+
return mismatch;
|
|
208
|
+
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: [] });
|
|
213
|
+
}
|
|
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
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Customer-initiated recovery out of the non-payment pause.
|
|
235
|
+
*
|
|
236
|
+
* Mirrors the managed SQL `subscription_resume_after_expired_dunning`: the
|
|
237
|
+
* subscription must be paused and the cycle it passes must be the refused cycle
|
|
238
|
+
* that exhausted the ladder (the caller passes the LATEST such cycle, as the SQL
|
|
239
|
+
* boundary reads the latest dunning case). A new payment method, when given,
|
|
240
|
+
* replaces the stored one; without any method the resume is refused. Whether
|
|
241
|
+
* the method is chargeable unattended is the host's check, as it is in SQL.
|
|
242
|
+
* The given cycle becomes `skipped` rather than being charged late; the SQL
|
|
243
|
+
* boundary skips every uncollected cycle, so a host that holds more than one
|
|
244
|
+
* must skip the rest itself. The next cycle is re-armed
|
|
245
|
+
* {@link RESTART_LEAD_DAYS} out.
|
|
246
|
+
*
|
|
247
|
+
* @beta
|
|
248
|
+
*/
|
|
249
|
+
export function resumeSubscriptionFromExpiredDunning(input) {
|
|
250
|
+
const now = parseIso(input.now);
|
|
251
|
+
if (!now)
|
|
252
|
+
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
253
|
+
if (input.subscription.status !== "paused") {
|
|
254
|
+
return failure("invalid_transition", `Cannot resume a ${input.subscription.status} subscription from expired dunning; it must be paused`);
|
|
255
|
+
}
|
|
256
|
+
const mismatch = cycleOwnershipError(input.subscription, input.cycle);
|
|
257
|
+
if (mismatch)
|
|
258
|
+
return mismatch;
|
|
259
|
+
if (!isDunningLadderExhausted(input.cycle, input.cadence ?? DEFAULT_CYCLE_RETRY_CADENCE)) {
|
|
260
|
+
return failure("dunning_not_exhausted", "the cycle did not exhaust the retry ladder");
|
|
261
|
+
}
|
|
262
|
+
const replacementRef = input.paymentMethodRef?.trim() ? input.paymentMethodRef : null;
|
|
263
|
+
const method = replacementRef
|
|
264
|
+
? {
|
|
265
|
+
paymentMethodRef: replacementRef,
|
|
266
|
+
paymentMethodKind: input.paymentMethodKind?.trim() || input.subscription.paymentMethodKind,
|
|
267
|
+
}
|
|
268
|
+
: {};
|
|
269
|
+
const withMethod = { ...input.subscription, ...method };
|
|
270
|
+
if (!withMethod.paymentMethodRef) {
|
|
271
|
+
return failure("missing_payment_method", "resuming requires a chargeable payment method");
|
|
272
|
+
}
|
|
273
|
+
const nextCycleAt = restartNextCycleAt(withMethod, now);
|
|
274
|
+
if (!nextCycleAt)
|
|
275
|
+
return failure("invalid_timestamp", "restart exceeds the supported timestamp range");
|
|
276
|
+
const cycle = { ...input.cycle, status: "skipped", nextRetryAt: null };
|
|
277
|
+
const restarted = restart({
|
|
278
|
+
subscription: withMethod,
|
|
279
|
+
now,
|
|
280
|
+
nextCycleAt,
|
|
281
|
+
idempotencyPrefix: `${input.cycle.engineIdempotencyKey}:resumed_after_expired_dunning`,
|
|
282
|
+
payload: {
|
|
283
|
+
resumedAfterExpiredDunning: true,
|
|
284
|
+
skippedCycleNumber: cycle.cycleNumber,
|
|
285
|
+
},
|
|
286
|
+
leadingEvents: [
|
|
287
|
+
makeEvent({
|
|
288
|
+
eventType: "subscription.cycle_skipped",
|
|
289
|
+
subscription: withMethod,
|
|
290
|
+
cycleNumber: cycle.cycleNumber,
|
|
291
|
+
idempotencyKey: `${input.cycle.engineIdempotencyKey}:cycle_skipped`,
|
|
292
|
+
occurredAt: now.toISOString(),
|
|
293
|
+
payload: {
|
|
294
|
+
skippedScheduledAt: cycle.scheduledAt,
|
|
295
|
+
reason: "subscription_resumed_after_expired_dunning",
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
if (restarted.ok === false)
|
|
301
|
+
return restarted;
|
|
302
|
+
return success({ ...restarted.value, cycle });
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* How far out a restarted subscription's next cycle is re-armed, in days. Both
|
|
306
|
+
* managed SQL restart paths (the customer `reactivate` action and
|
|
307
|
+
* `subscription_resume_after_expired_dunning`) spell it `interval '2 days'`.
|
|
308
|
+
*
|
|
309
|
+
* @beta
|
|
310
|
+
*/
|
|
311
|
+
export const RESTART_LEAD_DAYS = 2;
|
|
142
312
|
function updateTemplate(input) {
|
|
143
313
|
const now = parseIso(input.now);
|
|
144
314
|
if (!now)
|
|
@@ -164,11 +334,12 @@ function updateTemplate(input) {
|
|
|
164
334
|
],
|
|
165
335
|
});
|
|
166
336
|
}
|
|
167
|
-
function changeSubscriptionStatus(subscription, status, nowIso, eventType, payload) {
|
|
337
|
+
function changeSubscriptionStatus(subscription, status, nowIso, eventType, payload, allowedFrom) {
|
|
168
338
|
const now = parseIso(nowIso);
|
|
169
339
|
if (!now)
|
|
170
340
|
return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
171
|
-
if (!
|
|
341
|
+
if ((allowedFrom !== undefined && !allowedFrom.includes(subscription.status)) ||
|
|
342
|
+
!canTransitionSubscriptionStatus(subscription.status, status)) {
|
|
172
343
|
return failure("invalid_transition", `Cannot move subscription from ${subscription.status} to ${status}`);
|
|
173
344
|
}
|
|
174
345
|
const next = touchSubscription(subscription, now.toISOString(), statusPatch(status));
|
|
@@ -185,12 +356,78 @@ function changeSubscriptionStatus(subscription, status, nowIso, eventType, paylo
|
|
|
185
356
|
],
|
|
186
357
|
});
|
|
187
358
|
}
|
|
188
|
-
function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
359
|
+
function restart(input) {
|
|
360
|
+
if (!canTransitionSubscriptionStatus(input.subscription.status, "active")) {
|
|
361
|
+
return failure("invalid_transition", `Cannot move subscription from ${input.subscription.status} to active`);
|
|
362
|
+
}
|
|
363
|
+
const occurredAt = input.now.toISOString();
|
|
364
|
+
const subscription = touchSubscription(input.subscription, occurredAt, {
|
|
365
|
+
...statusPatch("active"),
|
|
366
|
+
nextCycleAt: input.nextCycleAt,
|
|
367
|
+
});
|
|
368
|
+
const events = [
|
|
369
|
+
...input.leadingEvents,
|
|
370
|
+
makeEvent({
|
|
371
|
+
eventType: "subscription.resumed",
|
|
372
|
+
subscription,
|
|
373
|
+
idempotencyKey: `${input.idempotencyPrefix}:resumed`,
|
|
374
|
+
occurredAt,
|
|
375
|
+
payload: { ...input.payload, nextCycleAt: input.nextCycleAt },
|
|
376
|
+
}),
|
|
377
|
+
];
|
|
378
|
+
if (input.nextCycleAt !== input.subscription.nextCycleAt) {
|
|
379
|
+
events.push(makeEvent({
|
|
380
|
+
eventType: "subscription.next_cycle_at_updated",
|
|
381
|
+
subscription,
|
|
382
|
+
idempotencyKey: `${input.idempotencyPrefix}:next_cycle_at_updated`,
|
|
383
|
+
occurredAt,
|
|
384
|
+
payload: { previousNextCycleAt: input.subscription.nextCycleAt, nextCycleAt: input.nextCycleAt },
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
return success({ subscription, events });
|
|
388
|
+
}
|
|
389
|
+
// A restart re-arms the schedule RESTART_LEAD_DAYS out, but never earlier than
|
|
390
|
+
// the instant already stored: the same monotonic clamp recordPaymentSuccess
|
|
391
|
+
// applies, so a restart cannot pull a charge into a window already paid for.
|
|
392
|
+
// The managed SQL restart paths assign `now + 2 days` without the clamp, so
|
|
393
|
+
// the two differ exactly when the stored instant is later than that: a win-back
|
|
394
|
+
// of a subscription paid through a later date, or a schedule skipped or slid
|
|
395
|
+
// while the ladder was still running. There SQL charges earlier; this does not.
|
|
396
|
+
function restartNextCycleAt(subscription, now) {
|
|
397
|
+
return new Date(Math.max(Date.parse(subscription.nextCycleAt) || 0, now.getTime() + RESTART_LEAD_DAYS * 24 * 60 * 60 * 1000)).toJSON();
|
|
398
|
+
}
|
|
399
|
+
function cycleOwnershipError(subscription, cycle) {
|
|
400
|
+
if (cycle.subscriptionId === subscription.id)
|
|
401
|
+
return null;
|
|
402
|
+
return failure("invalid_transition", "the cycle belongs to a different subscription");
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The engine's subscription status matrix: every edge a status may take, and
|
|
406
|
+
* nothing else.
|
|
407
|
+
*
|
|
408
|
+
* It mirrors the managed SQL guard `public.subscription_guard_status_transition`
|
|
409
|
+
* over the four statuses the engine manages. The guard's remaining edges belong
|
|
410
|
+
* to the activation flow the engine never drives (`pending_activation` ->
|
|
411
|
+
* `active` / `activation_failed` / `cancelled`, and the audited expired-checkout
|
|
412
|
+
* reopen `cancelled` -> `pending_activation`). `completed` is terminal on both
|
|
413
|
+
* sides. `cancelled` -> `active` is the owner's win-back.
|
|
414
|
+
*
|
|
415
|
+
* 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}.
|
|
418
|
+
*
|
|
419
|
+
* @beta
|
|
420
|
+
*/
|
|
421
|
+
export const SUBSCRIPTION_STATUS_TRANSITIONS = Object.freeze({
|
|
422
|
+
active: Object.freeze(["paused", "cancelled", "completed"]),
|
|
423
|
+
paused: Object.freeze(["active", "cancelled"]),
|
|
424
|
+
cancelled: Object.freeze(["active"]),
|
|
425
|
+
completed: Object.freeze([]),
|
|
426
|
+
});
|
|
427
|
+
/** @beta */
|
|
428
|
+
export function canTransitionSubscriptionStatus(from, to) {
|
|
429
|
+
// Own keys only: a status read from storage is an untyped string, and
|
|
430
|
+
// "constructor" or "__proto__" must answer false rather than throw.
|
|
431
|
+
return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_STATUS_TRANSITIONS, from) &&
|
|
432
|
+
SUBSCRIPTION_STATUS_TRANSITIONS[from].includes(to);
|
|
196
433
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CycleRetryCadence } from "./cycleHardening.js";
|
|
1
2
|
import type { CycleMutationResult, EngineCycle, EngineEvent, EngineSubscription, SubscriptionEngineResult } from "./subscriptionEngineTypes.js";
|
|
2
3
|
/** @beta */
|
|
3
4
|
export declare function recordPaymentSuccess(input: {
|
|
@@ -16,4 +17,9 @@ export declare function recordPaymentFailure(input: {
|
|
|
16
17
|
failedAt: string;
|
|
17
18
|
reason: string;
|
|
18
19
|
failureClass?: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* The deployment's ladder. Pass the same cadence to
|
|
22
|
+
* `pauseSubscriptionForExpiredDunning` so both agree on exhaustion.
|
|
23
|
+
*/
|
|
24
|
+
cadence?: CycleRetryCadence;
|
|
19
25
|
}): SubscriptionEngineResult<CycleMutationResult>;
|
|
@@ -94,7 +94,7 @@ export function recordPaymentFailure(input) {
|
|
|
94
94
|
}
|
|
95
95
|
const retryAttempt = input.cycle.retryAttempt + 1;
|
|
96
96
|
// The ladder owns its terminal decision.
|
|
97
|
-
const nextRetryAt = nextRetryAttemptAt(failedAt.toISOString(), retryAttempt,
|
|
97
|
+
const nextRetryAt = nextRetryAttemptAt(failedAt.toISOString(), retryAttempt, input.cadence, input.failureClass);
|
|
98
98
|
const status = nextRetryAt ? "retry_scheduled" : "payment_failed";
|
|
99
99
|
const cycle = {
|
|
100
100
|
...input.cycle,
|
|
@@ -4,7 +4,7 @@ export declare const DEFAULT_EDIT_WINDOW_HOURS = 72;
|
|
|
4
4
|
/** @beta */
|
|
5
5
|
export declare const DEFAULT_TIMEZONE = "UTC";
|
|
6
6
|
/** @beta */
|
|
7
|
-
export type SubscriptionEngineErrorCode = "invalid_timestamp" | "invalid_template" | "inactive_subscription" | "edit_window_closed" | "line_not_found" | "duplicate_line" | "invalid_slide_target" | "invalid_transition" | "terminal_cycle";
|
|
7
|
+
export type SubscriptionEngineErrorCode = "invalid_timestamp" | "invalid_template" | "inactive_subscription" | "edit_window_closed" | "line_not_found" | "duplicate_line" | "invalid_slide_target" | "invalid_transition" | "terminal_cycle" | "missing_payment_method" | "dunning_not_exhausted";
|
|
8
8
|
/** @beta */
|
|
9
9
|
export type SubscriptionEngineResult<T> = {
|
|
10
10
|
ok: true;
|
package/package.json
CHANGED
package/release-gates.json
CHANGED
|
@@ -209,9 +209,9 @@
|
|
|
209
209
|
}
|
|
210
210
|
},
|
|
211
211
|
"pack": {
|
|
212
|
-
"$raiseReason": "2026-09-
|
|
213
|
-
"maxPackedBytes":
|
|
214
|
-
"maxUnpackedBytes":
|
|
212
|
+
"$raiseReason": "2026-09-27, owner-approved: the subscription status matrix, the win-back and the exhausted-ladder pause and resume add four functions, one predicate and two constants with their JSDoc, including the host duties the managed SQL restarts keep, JavaScript and declaration outputs, plus their changelog entry. Measured, not estimated: clean origin/main packs 122809 packed bytes, 534452 unpacked bytes and 192 files; this change packs about 130.3 thousand packed and 569.7 thousand unpacked bytes (rounded, because this note's own text moves the last digits) and 192 files. Packed allowance increases by 8192 bytes and unpacked by 38912 bytes; maxFiles stays 192. The package export map, dependencies and all other release gates are unchanged; the ./subscription entry gains named exports. The previous raise, 2026-09-11 and owner-approved, moved packed by 2048 and unpacked by 6144 bytes for checkout refusal recovery.",
|
|
213
|
+
"maxPackedBytes": 131072,
|
|
214
|
+
"maxUnpackedBytes": 573440,
|
|
215
215
|
"maxFiles": 192,
|
|
216
216
|
"requiredFiles": [
|
|
217
217
|
"CHANGELOG.md",
|
|
@@ -106,11 +106,9 @@ export const companyIdentitySourceSchema = z
|
|
|
106
106
|
})
|
|
107
107
|
.strict();
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* may reconcile against a registry. Parsing normalizes country to upper-case and
|
|
113
|
-
* identifier kind to lower-case. */
|
|
109
|
+
/** Lookup coordinates with optional manual identity for registry reconciliation.
|
|
110
|
+
* Country is an open ISO-3166 alpha-2 code. Parsing uppercases country and
|
|
111
|
+
* lowercases identifier kind; national tax-id rules belong to extensions. */
|
|
114
112
|
/** @beta */
|
|
115
113
|
export const companyIdentityLookupRequestSchema = z
|
|
116
114
|
.object({
|
|
@@ -166,7 +164,8 @@ interface NormalizableCompanyIdentityLookupRequest {
|
|
|
166
164
|
manualCompany?: Partial<CompanyIdentityCompany>;
|
|
167
165
|
}
|
|
168
166
|
|
|
169
|
-
|
|
167
|
+
/** @beta */
|
|
168
|
+
export function normalizeCompanyIdentityLookupRequest(
|
|
170
169
|
request: NormalizableCompanyIdentityLookupRequest,
|
|
171
170
|
): NormalizableCompanyIdentityLookupRequest {
|
|
172
171
|
const country = String(request.country).trim().toUpperCase();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Subscription cycle hardening helpers — the canonical retry ladder a renewal
|
|
3
3
|
* payment failure follows.
|
|
4
4
|
*
|
|
5
|
-
* This module is the SINGLE authority on the ladder. It answers
|
|
5
|
+
* This module is the SINGLE authority on the ladder. It answers four questions:
|
|
6
6
|
*
|
|
7
7
|
* - `nextRetryAttemptAt(failedAt, attemptNumber, cadence?, failureClass?)` —
|
|
8
8
|
* when the next charge is due, or `null` when no next charge is due.
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* — whether a failed payment may move into `retry_scheduled`.
|
|
11
11
|
* - `ladderTerminatedByClass(failureClass, cadence?)` — whether the REASON for
|
|
12
12
|
* the refusal, rather than the rung reached, ends the ladder.
|
|
13
|
+
* - `isDunningLadderExhausted(cycle, cadence?)` — whether a refused cycle has
|
|
14
|
+
* run past the last rung, the only non-payment reason to pause.
|
|
13
15
|
*
|
|
14
16
|
* TERMINATING, not capping. An attempt past the end of the ladder answers
|
|
15
17
|
* `null`, which is the pause signal; it does not answer "the last slot again".
|
|
@@ -139,3 +141,34 @@ export function maxRetryAttempts(
|
|
|
139
141
|
): number {
|
|
140
142
|
return cadence.backoffHours.length;
|
|
141
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether a refused cycle has EXHAUSTED the ladder: it failed, nothing is
|
|
147
|
+
* scheduled after it, and the attempt that failed sits past the last rung.
|
|
148
|
+
*
|
|
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.
|
|
158
|
+
*
|
|
159
|
+
* @beta
|
|
160
|
+
*/
|
|
161
|
+
export function isDunningLadderExhausted(
|
|
162
|
+
cycle: {
|
|
163
|
+
readonly status: CycleHandlerStatus;
|
|
164
|
+
readonly retryAttempt: number;
|
|
165
|
+
readonly nextRetryAt: string | null;
|
|
166
|
+
},
|
|
167
|
+
cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
|
|
168
|
+
): boolean {
|
|
169
|
+
return (
|
|
170
|
+
cycle.status === "payment_failed" &&
|
|
171
|
+
cycle.nextRetryAt === null &&
|
|
172
|
+
cycle.retryAttempt > maxRetryAttempts(cadence)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { SubscriptionEngineEventType, SubscriptionLineSnapshot, SubscriptionStatus } from "./types.js";
|
|
2
2
|
import { isEditWindowOpen } from "./subscriptionEngineCore.js";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_CYCLE_RETRY_CADENCE,
|
|
5
|
+
isDunningLadderExhausted,
|
|
6
|
+
type CycleRetryCadence,
|
|
7
|
+
} from "./cycleHardening.js";
|
|
3
8
|
import type {
|
|
9
|
+
EngineCycle,
|
|
10
|
+
EngineEvent,
|
|
4
11
|
EngineSubscription,
|
|
5
12
|
SubscriptionEngineResult,
|
|
6
13
|
SubscriptionMutationResult,
|
|
@@ -184,7 +191,11 @@ export function resumeSubscription(input: {
|
|
|
184
191
|
subscription: EngineSubscription;
|
|
185
192
|
now: string;
|
|
186
193
|
}): SubscriptionEngineResult<SubscriptionMutationResult> {
|
|
187
|
-
|
|
194
|
+
// Resume leaves `paused` only. Leaving `cancelled` is a win-back and goes
|
|
195
|
+
// through reactivateSubscription, which re-arms the schedule. A subscription
|
|
196
|
+
// the exhausted ladder paused must resume through
|
|
197
|
+
// resumeSubscriptionFromExpiredDunning, which skips the uncollected cycle.
|
|
198
|
+
return changeSubscriptionStatus(input.subscription, "active", input.now, "subscription.resumed", {}, ["paused"]);
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
/** @beta */
|
|
@@ -206,6 +217,198 @@ export function completeSubscription(input: {
|
|
|
206
217
|
return changeSubscriptionStatus(input.subscription, "completed", input.now, "subscription.completed", {});
|
|
207
218
|
}
|
|
208
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Win-back: the owner reactivates their own cancelled subscription.
|
|
222
|
+
*
|
|
223
|
+
* Mirrors the `cancelled -> active` edge of the managed SQL guard
|
|
224
|
+
* `public.subscription_guard_status_transition` and the customer `reactivate`
|
|
225
|
+
* action behind it. A reactivation charges again, so it needs a stored payment
|
|
226
|
+
* method, and it re-arms the next cycle {@link RESTART_LEAD_DAYS} out rather
|
|
227
|
+
* than leaving a stale instant that would make the renewal due immediately.
|
|
228
|
+
* The SQL action also clears the end instant and cancellation reason, requires
|
|
229
|
+
* the customer's confirmation of the charge timing, refuses while a cycle is
|
|
230
|
+
* locked and bumps the template version; those durable duties stay with the
|
|
231
|
+
* host.
|
|
232
|
+
*
|
|
233
|
+
* @beta
|
|
234
|
+
*/
|
|
235
|
+
export function reactivateSubscription(input: {
|
|
236
|
+
subscription: EngineSubscription;
|
|
237
|
+
now: string;
|
|
238
|
+
reason?: string;
|
|
239
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult> {
|
|
240
|
+
const now = parseIso(input.now);
|
|
241
|
+
if (!now) return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
242
|
+
if (input.subscription.status !== "cancelled") {
|
|
243
|
+
return failure(
|
|
244
|
+
"invalid_transition",
|
|
245
|
+
`Cannot reactivate a subscription that is ${input.subscription.status}; only cancelled subscriptions can be won back`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (!input.subscription.paymentMethodRef) {
|
|
249
|
+
return failure("missing_payment_method", "reactivation requires a stored payment method");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const nextCycleAt = restartNextCycleAt(input.subscription, now);
|
|
253
|
+
if (!nextCycleAt) return failure("invalid_timestamp", "restart exceeds the supported timestamp range");
|
|
254
|
+
|
|
255
|
+
return restart({
|
|
256
|
+
subscription: input.subscription,
|
|
257
|
+
now,
|
|
258
|
+
nextCycleAt,
|
|
259
|
+
idempotencyPrefix: `${input.subscription.id}:reactivated:${now.toISOString()}`,
|
|
260
|
+
payload: { reactivatedFrom: "cancelled", reason: input.reason ?? null },
|
|
261
|
+
leadingEvents: [],
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
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`.
|
|
279
|
+
*
|
|
280
|
+
* @beta
|
|
281
|
+
*/
|
|
282
|
+
export function pauseSubscriptionForExpiredDunning(input: {
|
|
283
|
+
subscription: EngineSubscription;
|
|
284
|
+
cycle: EngineCycle;
|
|
285
|
+
now: string;
|
|
286
|
+
cadence?: CycleRetryCadence;
|
|
287
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult> {
|
|
288
|
+
const now = parseIso(input.now);
|
|
289
|
+
if (!now) return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
290
|
+
const mismatch = cycleOwnershipError(input.subscription, input.cycle);
|
|
291
|
+
if (mismatch) return mismatch;
|
|
292
|
+
if (!isDunningLadderExhausted(input.cycle, input.cadence ?? DEFAULT_CYCLE_RETRY_CADENCE)) {
|
|
293
|
+
return failure(
|
|
294
|
+
"dunning_not_exhausted",
|
|
295
|
+
"only a cycle that exhausted the retry ladder may pause a subscription for non-payment",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
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
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Customer-initiated recovery out of the non-payment pause.
|
|
324
|
+
*
|
|
325
|
+
* Mirrors the managed SQL `subscription_resume_after_expired_dunning`: the
|
|
326
|
+
* subscription must be paused and the cycle it passes must be the refused cycle
|
|
327
|
+
* that exhausted the ladder (the caller passes the LATEST such cycle, as the SQL
|
|
328
|
+
* boundary reads the latest dunning case). A new payment method, when given,
|
|
329
|
+
* replaces the stored one; without any method the resume is refused. Whether
|
|
330
|
+
* the method is chargeable unattended is the host's check, as it is in SQL.
|
|
331
|
+
* The given cycle becomes `skipped` rather than being charged late; the SQL
|
|
332
|
+
* boundary skips every uncollected cycle, so a host that holds more than one
|
|
333
|
+
* must skip the rest itself. The next cycle is re-armed
|
|
334
|
+
* {@link RESTART_LEAD_DAYS} out.
|
|
335
|
+
*
|
|
336
|
+
* @beta
|
|
337
|
+
*/
|
|
338
|
+
export function resumeSubscriptionFromExpiredDunning(input: {
|
|
339
|
+
subscription: EngineSubscription;
|
|
340
|
+
cycle: EngineCycle;
|
|
341
|
+
now: string;
|
|
342
|
+
paymentMethodRef?: string | null;
|
|
343
|
+
paymentMethodKind?: string | null;
|
|
344
|
+
cadence?: CycleRetryCadence;
|
|
345
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult & { cycle: EngineCycle }> {
|
|
346
|
+
const now = parseIso(input.now);
|
|
347
|
+
if (!now) return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
348
|
+
if (input.subscription.status !== "paused") {
|
|
349
|
+
return failure(
|
|
350
|
+
"invalid_transition",
|
|
351
|
+
`Cannot resume a ${input.subscription.status} subscription from expired dunning; it must be paused`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
const mismatch = cycleOwnershipError(input.subscription, input.cycle);
|
|
355
|
+
if (mismatch) return mismatch;
|
|
356
|
+
if (!isDunningLadderExhausted(input.cycle, input.cadence ?? DEFAULT_CYCLE_RETRY_CADENCE)) {
|
|
357
|
+
return failure("dunning_not_exhausted", "the cycle did not exhaust the retry ladder");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const replacementRef = input.paymentMethodRef?.trim() ? input.paymentMethodRef : null;
|
|
361
|
+
const method: Partial<EngineSubscription> = replacementRef
|
|
362
|
+
? {
|
|
363
|
+
paymentMethodRef: replacementRef,
|
|
364
|
+
paymentMethodKind: input.paymentMethodKind?.trim() || input.subscription.paymentMethodKind,
|
|
365
|
+
}
|
|
366
|
+
: {};
|
|
367
|
+
const withMethod = { ...input.subscription, ...method };
|
|
368
|
+
if (!withMethod.paymentMethodRef) {
|
|
369
|
+
return failure("missing_payment_method", "resuming requires a chargeable payment method");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const nextCycleAt = restartNextCycleAt(withMethod, now);
|
|
373
|
+
if (!nextCycleAt) return failure("invalid_timestamp", "restart exceeds the supported timestamp range");
|
|
374
|
+
|
|
375
|
+
const cycle: EngineCycle = { ...input.cycle, status: "skipped", nextRetryAt: null };
|
|
376
|
+
const restarted = restart({
|
|
377
|
+
subscription: withMethod,
|
|
378
|
+
now,
|
|
379
|
+
nextCycleAt,
|
|
380
|
+
idempotencyPrefix: `${input.cycle.engineIdempotencyKey}:resumed_after_expired_dunning`,
|
|
381
|
+
payload: {
|
|
382
|
+
resumedAfterExpiredDunning: true,
|
|
383
|
+
skippedCycleNumber: cycle.cycleNumber,
|
|
384
|
+
},
|
|
385
|
+
leadingEvents: [
|
|
386
|
+
makeEvent({
|
|
387
|
+
eventType: "subscription.cycle_skipped",
|
|
388
|
+
subscription: withMethod,
|
|
389
|
+
cycleNumber: cycle.cycleNumber,
|
|
390
|
+
idempotencyKey: `${input.cycle.engineIdempotencyKey}:cycle_skipped`,
|
|
391
|
+
occurredAt: now.toISOString(),
|
|
392
|
+
payload: {
|
|
393
|
+
skippedScheduledAt: cycle.scheduledAt,
|
|
394
|
+
reason: "subscription_resumed_after_expired_dunning",
|
|
395
|
+
},
|
|
396
|
+
}),
|
|
397
|
+
],
|
|
398
|
+
});
|
|
399
|
+
if (restarted.ok === false) return restarted;
|
|
400
|
+
return success({ ...restarted.value, cycle });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* How far out a restarted subscription's next cycle is re-armed, in days. Both
|
|
405
|
+
* managed SQL restart paths (the customer `reactivate` action and
|
|
406
|
+
* `subscription_resume_after_expired_dunning`) spell it `interval '2 days'`.
|
|
407
|
+
*
|
|
408
|
+
* @beta
|
|
409
|
+
*/
|
|
410
|
+
export const RESTART_LEAD_DAYS = 2;
|
|
411
|
+
|
|
209
412
|
function updateTemplate(input: {
|
|
210
413
|
subscription: EngineSubscription;
|
|
211
414
|
template: EngineSubscription["template"];
|
|
@@ -245,10 +448,14 @@ function changeSubscriptionStatus(
|
|
|
245
448
|
nowIso: string,
|
|
246
449
|
eventType: SubscriptionEngineEventType,
|
|
247
450
|
payload: Record<string, unknown>,
|
|
451
|
+
allowedFrom?: readonly SubscriptionStatus[],
|
|
248
452
|
): SubscriptionEngineResult<SubscriptionMutationResult> {
|
|
249
453
|
const now = parseIso(nowIso);
|
|
250
454
|
if (!now) return failure("invalid_timestamp", "now must be a valid ISO timestamp");
|
|
251
|
-
if (
|
|
455
|
+
if (
|
|
456
|
+
(allowedFrom !== undefined && !allowedFrom.includes(subscription.status)) ||
|
|
457
|
+
!canTransitionSubscriptionStatus(subscription.status, status)
|
|
458
|
+
) {
|
|
252
459
|
return failure(
|
|
253
460
|
"invalid_transition",
|
|
254
461
|
`Cannot move subscription from ${subscription.status} to ${status}`,
|
|
@@ -270,13 +477,98 @@ function changeSubscriptionStatus(
|
|
|
270
477
|
});
|
|
271
478
|
}
|
|
272
479
|
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
480
|
+
function restart(input: {
|
|
481
|
+
subscription: EngineSubscription;
|
|
482
|
+
now: Date;
|
|
483
|
+
nextCycleAt: string;
|
|
484
|
+
idempotencyPrefix: string;
|
|
485
|
+
payload: Record<string, unknown>;
|
|
486
|
+
leadingEvents: EngineEvent[];
|
|
487
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult> {
|
|
488
|
+
if (!canTransitionSubscriptionStatus(input.subscription.status, "active")) {
|
|
489
|
+
return failure("invalid_transition", `Cannot move subscription from ${input.subscription.status} to active`);
|
|
490
|
+
}
|
|
491
|
+
const occurredAt = input.now.toISOString();
|
|
492
|
+
const subscription = touchSubscription(input.subscription, occurredAt, {
|
|
493
|
+
...statusPatch("active"),
|
|
494
|
+
nextCycleAt: input.nextCycleAt,
|
|
495
|
+
});
|
|
496
|
+
const events = [
|
|
497
|
+
...input.leadingEvents,
|
|
498
|
+
makeEvent({
|
|
499
|
+
eventType: "subscription.resumed",
|
|
500
|
+
subscription,
|
|
501
|
+
idempotencyKey: `${input.idempotencyPrefix}:resumed`,
|
|
502
|
+
occurredAt,
|
|
503
|
+
payload: { ...input.payload, nextCycleAt: input.nextCycleAt },
|
|
504
|
+
}),
|
|
505
|
+
];
|
|
506
|
+
if (input.nextCycleAt !== input.subscription.nextCycleAt) {
|
|
507
|
+
events.push(
|
|
508
|
+
makeEvent({
|
|
509
|
+
eventType: "subscription.next_cycle_at_updated",
|
|
510
|
+
subscription,
|
|
511
|
+
idempotencyKey: `${input.idempotencyPrefix}:next_cycle_at_updated`,
|
|
512
|
+
occurredAt,
|
|
513
|
+
payload: { previousNextCycleAt: input.subscription.nextCycleAt, nextCycleAt: input.nextCycleAt },
|
|
514
|
+
}),
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
return success({ subscription, events });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// A restart re-arms the schedule RESTART_LEAD_DAYS out, but never earlier than
|
|
521
|
+
// the instant already stored: the same monotonic clamp recordPaymentSuccess
|
|
522
|
+
// applies, so a restart cannot pull a charge into a window already paid for.
|
|
523
|
+
// The managed SQL restart paths assign `now + 2 days` without the clamp, so
|
|
524
|
+
// the two differ exactly when the stored instant is later than that: a win-back
|
|
525
|
+
// of a subscription paid through a later date, or a schedule skipped or slid
|
|
526
|
+
// while the ladder was still running. There SQL charges earlier; this does not.
|
|
527
|
+
function restartNextCycleAt(subscription: EngineSubscription, now: Date): string | null {
|
|
528
|
+
return new Date(Math.max(
|
|
529
|
+
Date.parse(subscription.nextCycleAt) || 0,
|
|
530
|
+
now.getTime() + RESTART_LEAD_DAYS * 24 * 60 * 60 * 1000,
|
|
531
|
+
)).toJSON();
|
|
532
|
+
}
|
|
280
533
|
|
|
281
|
-
|
|
534
|
+
function cycleOwnershipError(
|
|
535
|
+
subscription: EngineSubscription,
|
|
536
|
+
cycle: EngineCycle,
|
|
537
|
+
): SubscriptionEngineResult<never> | null {
|
|
538
|
+
if (cycle.subscriptionId === subscription.id) return null;
|
|
539
|
+
return failure("invalid_transition", "the cycle belongs to a different subscription");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The engine's subscription status matrix: every edge a status may take, and
|
|
544
|
+
* nothing else.
|
|
545
|
+
*
|
|
546
|
+
* It mirrors the managed SQL guard `public.subscription_guard_status_transition`
|
|
547
|
+
* over the four statuses the engine manages. The guard's remaining edges belong
|
|
548
|
+
* to the activation flow the engine never drives (`pending_activation` ->
|
|
549
|
+
* `active` / `activation_failed` / `cancelled`, and the audited expired-checkout
|
|
550
|
+
* reopen `cancelled` -> `pending_activation`). `completed` is terminal on both
|
|
551
|
+
* sides. `cancelled` -> `active` is the owner's win-back.
|
|
552
|
+
*
|
|
553
|
+
* 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}.
|
|
556
|
+
*
|
|
557
|
+
* @beta
|
|
558
|
+
*/
|
|
559
|
+
export const SUBSCRIPTION_STATUS_TRANSITIONS: Readonly<
|
|
560
|
+
Record<SubscriptionStatus, readonly SubscriptionStatus[]>
|
|
561
|
+
> = Object.freeze({
|
|
562
|
+
active: Object.freeze(["paused", "cancelled", "completed"] as const),
|
|
563
|
+
paused: Object.freeze(["active", "cancelled"] as const),
|
|
564
|
+
cancelled: Object.freeze(["active"] as const),
|
|
565
|
+
completed: Object.freeze([] as const),
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
/** @beta */
|
|
569
|
+
export function canTransitionSubscriptionStatus(from: SubscriptionStatus, to: SubscriptionStatus): boolean {
|
|
570
|
+
// Own keys only: a status read from storage is an untyped string, and
|
|
571
|
+
// "constructor" or "__proto__" must answer false rather than throw.
|
|
572
|
+
return Object.prototype.hasOwnProperty.call(SUBSCRIPTION_STATUS_TRANSITIONS, from) &&
|
|
573
|
+
SUBSCRIPTION_STATUS_TRANSITIONS[from].includes(to);
|
|
282
574
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { nextRetryAttemptAt } from "./cycleHardening.js";
|
|
1
|
+
import { nextRetryAttemptAt, type CycleRetryCadence } from "./cycleHardening.js";
|
|
2
2
|
import type { SubscriptionCycleStatus } from "./types.js";
|
|
3
3
|
import type {
|
|
4
4
|
CycleMutationResult,
|
|
@@ -124,6 +124,11 @@ export function recordPaymentFailure(input: {
|
|
|
124
124
|
failedAt: string;
|
|
125
125
|
reason: string;
|
|
126
126
|
failureClass?: string | null;
|
|
127
|
+
/**
|
|
128
|
+
* The deployment's ladder. Pass the same cadence to
|
|
129
|
+
* `pauseSubscriptionForExpiredDunning` so both agree on exhaustion.
|
|
130
|
+
*/
|
|
131
|
+
cadence?: CycleRetryCadence;
|
|
127
132
|
}): SubscriptionEngineResult<CycleMutationResult> {
|
|
128
133
|
const failedAt = parseIso(input.failedAt);
|
|
129
134
|
if (!failedAt) return failure("invalid_timestamp", "failedAt must be a valid ISO timestamp");
|
|
@@ -136,7 +141,7 @@ export function recordPaymentFailure(input: {
|
|
|
136
141
|
const nextRetryAt = nextRetryAttemptAt(
|
|
137
142
|
failedAt.toISOString(),
|
|
138
143
|
retryAttempt,
|
|
139
|
-
|
|
144
|
+
input.cadence,
|
|
140
145
|
input.failureClass,
|
|
141
146
|
);
|
|
142
147
|
const status: SubscriptionCycleStatus = nextRetryAt ? "retry_scheduled" : "payment_failed";
|
|
@@ -20,7 +20,9 @@ export type SubscriptionEngineErrorCode =
|
|
|
20
20
|
| "duplicate_line"
|
|
21
21
|
| "invalid_slide_target"
|
|
22
22
|
| "invalid_transition"
|
|
23
|
-
| "terminal_cycle"
|
|
23
|
+
| "terminal_cycle"
|
|
24
|
+
| "missing_payment_method"
|
|
25
|
+
| "dunning_not_exhausted";
|
|
24
26
|
|
|
25
27
|
/** @beta */
|
|
26
28
|
export type SubscriptionEngineResult<T> =
|