@openlup/core 0.0.0 → 0.7.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 +225 -0
- package/CODE_OF_CONDUCT.md +39 -0
- package/CONTRIBUTING.md +34 -0
- package/LICENSE +204 -0
- package/MAINTAINERS.md +33 -0
- package/README.md +85 -3
- package/SECURITY.md +29 -0
- package/dist/bundle/contracts.d.ts +9 -0
- package/dist/bundle/contracts.js +1 -0
- package/dist/bundle/ports.d.ts +41 -0
- package/dist/bundle/ports.js +1 -0
- package/dist/catalog/identifiers.d.ts +31 -0
- package/dist/catalog/identifiers.js +46 -0
- package/dist/catalog/index.d.ts +2 -0
- package/dist/catalog/index.js +2 -0
- package/dist/checkout/idempotency.d.ts +29 -0
- package/dist/checkout/idempotency.js +37 -0
- package/dist/checkout/index.d.ts +2 -0
- package/dist/checkout/index.js +2 -0
- package/dist/company-identity/contracts.d.ts +220 -0
- package/dist/company-identity/contracts.js +167 -0
- package/dist/company-identity/index.d.ts +6 -0
- package/dist/company-identity/index.js +2 -0
- package/dist/company-identity/ports.d.ts +13 -0
- package/dist/company-identity/ports.js +1 -0
- package/dist/fulfillment/facts.d.ts +103 -0
- package/dist/fulfillment/facts.js +195 -0
- package/dist/fulfillment/index.d.ts +4 -0
- package/dist/fulfillment/index.js +2 -0
- package/dist/inventory/atp.d.ts +172 -0
- package/dist/inventory/atp.js +211 -0
- package/dist/inventory/index.d.ts +2 -0
- package/dist/inventory/index.js +2 -0
- package/dist/marketing/research/contracts.d.ts +75 -0
- package/dist/marketing/research/contracts.js +60 -0
- package/dist/marketing/research/index.d.ts +4 -0
- package/dist/marketing/research/index.js +4 -0
- package/dist/marketing/research/ports.d.ts +15 -0
- package/dist/marketing/research/ports.js +1 -0
- package/dist/partners/index.d.ts +4 -0
- package/dist/partners/index.js +4 -0
- package/dist/partners/ports.d.ts +12 -0
- package/dist/partners/ports.js +1 -0
- package/dist/partners/status.d.ts +48 -0
- package/dist/partners/status.js +35 -0
- package/dist/payment/executionBaseContracts.d.ts +81 -0
- package/dist/payment/executionBaseContracts.js +8 -0
- package/dist/payment/index.d.ts +36 -0
- package/dist/payment/index.js +18 -0
- package/dist/payment/methodLifecycle.d.ts +91 -0
- package/dist/payment/methodLifecycle.js +76 -0
- package/dist/payment/paymentControlTypes.d.ts +135 -0
- package/dist/payment/paymentControlTypes.js +110 -0
- package/dist/payment/paymentFailureTaxonomy.d.ts +80 -0
- package/dist/payment/paymentFailureTaxonomy.js +155 -0
- package/dist/payment/paymentFailureTaxonomyContracts.d.ts +106 -0
- package/dist/payment/paymentFailureTaxonomyContracts.js +177 -0
- package/dist/payment/paymentRecovery.d.ts +26 -0
- package/dist/payment/paymentRecovery.js +73 -0
- package/dist/payment/paymentRecoveryContracts.d.ts +53 -0
- package/dist/payment/paymentRecoveryContracts.js +10 -0
- package/dist/payment/paymentStateMachine.d.ts +28 -0
- package/dist/payment/paymentStateMachine.js +184 -0
- package/dist/payment/providerAttemptIdempotency.d.ts +23 -0
- package/dist/payment/providerAttemptIdempotency.js +31 -0
- package/dist/payment/providerCapability.d.ts +148 -0
- package/dist/payment/providerCapability.js +13 -0
- package/dist/platform-runtime/contracts.d.ts +9 -0
- package/dist/platform-runtime/contracts.js +13 -0
- package/dist/platform-runtime/index.d.ts +10 -0
- package/dist/platform-runtime/index.js +4 -0
- package/dist/platform-runtime/platformKernel.d.ts +44 -0
- package/dist/platform-runtime/platformKernel.js +39 -0
- package/dist/platform-runtime/ports.d.ts +100 -0
- package/dist/platform-runtime/ports.js +1 -0
- package/dist/pricing/index.d.ts +8 -0
- package/dist/pricing/index.js +8 -0
- package/dist/pricing/ports.d.ts +22 -0
- package/dist/pricing/ports.js +7 -0
- package/dist/pricing/pricingBreakdown.d.ts +86 -0
- package/dist/pricing/pricingBreakdown.js +155 -0
- package/dist/pricing/targetPriceAllocation.d.ts +97 -0
- package/dist/pricing/targetPriceAllocation.js +202 -0
- package/dist/pricing/types.d.ts +32 -0
- package/dist/pricing/types.js +4 -0
- package/dist/promo/adjustmentEngine.d.ts +4 -0
- package/dist/promo/adjustmentEngine.js +194 -0
- package/dist/promo/adjustmentTypes.d.ts +103 -0
- package/dist/promo/adjustmentTypes.js +13 -0
- package/dist/promo/index.d.ts +8 -0
- package/dist/promo/index.js +8 -0
- package/dist/promo/promoEvaluator.d.ts +25 -0
- package/dist/promo/promoEvaluator.js +135 -0
- package/dist/promo/types.d.ts +86 -0
- package/dist/promo/types.js +12 -0
- package/dist/risk/core/evaluator.d.ts +3 -0
- package/dist/risk/core/evaluator.js +32 -0
- package/dist/risk/core/rules.d.ts +3 -0
- package/dist/risk/core/rules.js +118 -0
- package/dist/risk/core/types.d.ts +65 -0
- package/dist/risk/core/types.js +1 -0
- package/dist/risk/index.d.ts +8 -0
- package/dist/risk/index.js +8 -0
- package/dist/risk/types.d.ts +32 -0
- package/dist/risk/types.js +43 -0
- package/dist/shipping/index.d.ts +4 -0
- package/dist/shipping/index.js +4 -0
- package/dist/shipping/shippingResolver.d.ts +26 -0
- package/dist/shipping/shippingResolver.js +88 -0
- package/dist/shipping/types.d.ts +45 -0
- package/dist/shipping/types.js +4 -0
- package/dist/subscription/cycleHardening.d.ts +95 -0
- package/dist/subscription/cycleHardening.js +118 -0
- package/dist/subscription/index.d.ts +14 -0
- package/dist/subscription/index.js +14 -0
- package/dist/subscription/subscriptionEngineCore.d.ts +13 -0
- package/dist/subscription/subscriptionEngineCore.js +145 -0
- package/dist/subscription/subscriptionEngineLifecycle.d.ts +150 -0
- package/dist/subscription/subscriptionEngineLifecycle.js +433 -0
- package/dist/subscription/subscriptionEnginePayment.d.ts +25 -0
- package/dist/subscription/subscriptionEnginePayment.js +127 -0
- package/dist/subscription/subscriptionEngineTypes.d.ts +94 -0
- package/dist/subscription/subscriptionEngineTypes.js +4 -0
- package/dist/subscription/subscriptionEngineUtils.d.ts +32 -0
- package/dist/subscription/subscriptionEngineUtils.js +74 -0
- package/dist/subscription/types.d.ts +58 -0
- package/dist/subscription/types.js +47 -0
- package/dist/testing.d.ts +48 -0
- package/dist/testing.js +105 -0
- package/package.json +215 -4
- package/release-gates.json +232 -0
- package/src/bundle/contracts.ts +15 -0
- package/src/bundle/ports.ts +32 -0
- package/src/catalog/identifiers.ts +50 -0
- package/src/catalog/index.ts +2 -0
- package/src/checkout/idempotency.ts +49 -0
- package/src/checkout/index.ts +2 -0
- package/src/company-identity/contracts.ts +208 -0
- package/src/company-identity/index.ts +28 -0
- package/src/company-identity/ports.ts +19 -0
- package/src/fulfillment/facts.ts +300 -0
- package/src/fulfillment/index.ts +34 -0
- package/src/inventory/atp.ts +249 -0
- package/src/inventory/index.ts +2 -0
- package/src/marketing/research/contracts.ts +91 -0
- package/src/marketing/research/index.ts +4 -0
- package/src/marketing/research/ports.ts +26 -0
- package/src/partners/index.ts +4 -0
- package/src/partners/ports.ts +18 -0
- package/src/partners/status.ts +49 -0
- package/src/payment/executionBaseContracts.ts +93 -0
- package/src/payment/index.ts +128 -0
- package/src/payment/methodLifecycle.ts +130 -0
- package/src/payment/paymentControlTypes.ts +197 -0
- package/src/payment/paymentFailureTaxonomy.ts +201 -0
- package/src/payment/paymentFailureTaxonomyContracts.ts +228 -0
- package/src/payment/paymentRecovery.ts +95 -0
- package/src/payment/paymentRecoveryContracts.ts +61 -0
- package/src/payment/paymentStateMachine.ts +263 -0
- package/src/payment/providerAttemptIdempotency.ts +57 -0
- package/src/payment/providerCapability.ts +172 -0
- package/src/platform-runtime/contracts.ts +33 -0
- package/src/platform-runtime/index.ts +36 -0
- package/src/platform-runtime/platformKernel.ts +86 -0
- package/src/platform-runtime/ports.ts +88 -0
- package/src/pricing/index.ts +8 -0
- package/src/pricing/ports.ts +27 -0
- package/src/pricing/pricingBreakdown.ts +243 -0
- package/src/pricing/targetPriceAllocation.ts +293 -0
- package/src/pricing/types.ts +36 -0
- package/src/promo/adjustmentEngine.ts +253 -0
- package/src/promo/adjustmentTypes.ts +94 -0
- package/src/promo/index.ts +8 -0
- package/src/promo/promoEvaluator.ts +137 -0
- package/src/promo/types.ts +97 -0
- package/src/risk/core/evaluator.ts +41 -0
- package/src/risk/core/rules.ts +128 -0
- package/src/risk/core/types.ts +73 -0
- package/src/risk/index.ts +8 -0
- package/src/risk/types.ts +60 -0
- package/src/shipping/index.ts +4 -0
- package/src/shipping/shippingResolver.ts +101 -0
- package/src/shipping/types.ts +49 -0
- package/src/subscription/cycleHardening.ts +174 -0
- package/src/subscription/index.ts +14 -0
- package/src/subscription/subscriptionEngineCore.ts +181 -0
- package/src/subscription/subscriptionEngineLifecycle.ts +574 -0
- package/src/subscription/subscriptionEnginePayment.ts +179 -0
- package/src/subscription/subscriptionEngineTypes.ts +114 -0
- package/src/subscription/subscriptionEngineUtils.ts +110 -0
- package/src/subscription/types.ts +104 -0
- package/src/testing.ts +169 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription cycle hardening helpers — the canonical retry ladder a renewal
|
|
3
|
+
* payment failure follows.
|
|
4
|
+
*
|
|
5
|
+
* This module is the SINGLE authority on the ladder. It answers four questions:
|
|
6
|
+
*
|
|
7
|
+
* - `nextRetryAttemptAt(failedAt, attemptNumber, cadence?, failureClass?)` —
|
|
8
|
+
* when the next charge is due, or `null` when no next charge is due.
|
|
9
|
+
* - `shouldScheduleRetry(cycleStatus, attemptNumber, cadence?, failureClass?)`
|
|
10
|
+
* — whether a failed payment may move into `retry_scheduled`.
|
|
11
|
+
* - `ladderTerminatedByClass(failureClass, cadence?)` — whether the REASON for
|
|
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.
|
|
15
|
+
*
|
|
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".
|
|
18
|
+
* Callers therefore never need a `attempt <= maxRetryAttempts()` guard of their
|
|
19
|
+
* own — asking for the next slot IS the decision, and a second copy of the
|
|
20
|
+
* budget check is exactly how two rails drift apart.
|
|
21
|
+
*
|
|
22
|
+
* The cadence is injectable so a deployment can publish its own ladder, and the
|
|
23
|
+
* default is frozen so nobody can mutate the shipped one in place.
|
|
24
|
+
*
|
|
25
|
+
* All helpers are PURE — they never schedule local jobs, never touch payment
|
|
26
|
+
* adapters, never persist a cycle.
|
|
27
|
+
*/
|
|
28
|
+
import { PAYMENT_FAILURE_CLASSES, failureClassDecision, } from "../payment/paymentFailureTaxonomyContracts.js";
|
|
29
|
+
/** @beta */
|
|
30
|
+
export const DEFAULT_CYCLE_RETRY_CADENCE = Object.freeze({
|
|
31
|
+
backoffHours: Object.freeze([24, 72, 168]), // 1d, 3d, 7d
|
|
32
|
+
// ONE member. `hard_do_not_retry` is the taxonomy's statement that the issuer
|
|
33
|
+
// refused the INSTRUMENT rather than this charge, so no rung below can change
|
|
34
|
+
// the answer and three more attempts only promise the customer eleven days of
|
|
35
|
+
// charges that cannot land. Every other class keeps the full cadence. Its SQL
|
|
36
|
+
// twin is `v_terminating_classes` in the canonical apply body;
|
|
37
|
+
// scripts/retry-ladder-terminating-classes.test.ts reads that literal out of
|
|
38
|
+
// the latest declaring migration and fails if the two sets ever differ.
|
|
39
|
+
terminatingFailureClasses: Object.freeze(["hard_do_not_retry"]),
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Whether the class of a refusal, rather than the rung it reached, ends the
|
|
43
|
+
* ladder.
|
|
44
|
+
*
|
|
45
|
+
* Fails OPEN in three separate ways, because a wrong `true` here withholds a
|
|
46
|
+
* charge the customer expected while a wrong `false` merely retries as before:
|
|
47
|
+
* an absent class does not terminate, a class the cadence does not list does not
|
|
48
|
+
* terminate, and a listed class the taxonomy does not recognise does not
|
|
49
|
+
* terminate. The last check is why a typo in configuration cannot stop a ladder.
|
|
50
|
+
*
|
|
51
|
+
* The final word belongs to {@link failureClassDecision}, not to the list: a
|
|
52
|
+
* cadence that lists a class the decision table still permits retrying is
|
|
53
|
+
* incoherent, and the table wins. That keeps the schedule from ever contradicting
|
|
54
|
+
* the kernel that already ruled on whether the instrument may be charged again.
|
|
55
|
+
*
|
|
56
|
+
* @beta
|
|
57
|
+
*/
|
|
58
|
+
export function ladderTerminatedByClass(failureClass, cadence = DEFAULT_CYCLE_RETRY_CADENCE) {
|
|
59
|
+
if (failureClass === null || failureClass === undefined)
|
|
60
|
+
return false;
|
|
61
|
+
if (!(cadence.terminatingFailureClasses ?? []).includes(failureClass))
|
|
62
|
+
return false;
|
|
63
|
+
if (!PAYMENT_FAILURE_CLASSES.includes(failureClass))
|
|
64
|
+
return false;
|
|
65
|
+
return !failureClassDecision(failureClass).retryAllowed;
|
|
66
|
+
}
|
|
67
|
+
/** @beta */
|
|
68
|
+
export function nextRetryAttemptAt(failedAtIso, attemptNumber, cadence = DEFAULT_CYCLE_RETRY_CADENCE, failureClass) {
|
|
69
|
+
if (ladderTerminatedByClass(failureClass, cadence))
|
|
70
|
+
return null;
|
|
71
|
+
const hours = cadence.backoffHours[attemptNumber - 1];
|
|
72
|
+
// Covers both ends at once: an attempt below 1 and an attempt past the last
|
|
73
|
+
// ladder slot are equally "no next charge is due".
|
|
74
|
+
if (attemptNumber < 1 || hours === undefined)
|
|
75
|
+
return null;
|
|
76
|
+
const failedAt = new Date(failedAtIso);
|
|
77
|
+
if (Number.isNaN(failedAt.getTime()))
|
|
78
|
+
return null;
|
|
79
|
+
const next = new Date(failedAt.getTime() + hours * 60 * 60 * 1000);
|
|
80
|
+
return next.toISOString();
|
|
81
|
+
}
|
|
82
|
+
/** @beta */
|
|
83
|
+
export function shouldScheduleRetry(cycleStatus, attemptNumber, cadence = DEFAULT_CYCLE_RETRY_CADENCE, failureClass) {
|
|
84
|
+
if (cycleStatus !== "payment_failed")
|
|
85
|
+
return false;
|
|
86
|
+
if (attemptNumber < 1)
|
|
87
|
+
return false;
|
|
88
|
+
// Asked and answered by the same predicate the schedule uses, so this helper
|
|
89
|
+
// cannot say "schedule one" about a refusal for which the ladder answers none.
|
|
90
|
+
if (ladderTerminatedByClass(failureClass, cadence))
|
|
91
|
+
return false;
|
|
92
|
+
return attemptNumber <= maxRetryAttempts(cadence);
|
|
93
|
+
}
|
|
94
|
+
/** @beta */
|
|
95
|
+
export function maxRetryAttempts(cadence = DEFAULT_CYCLE_RETRY_CADENCE) {
|
|
96
|
+
return cadence.backoffHours.length;
|
|
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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** @beta */
|
|
2
|
+
export * from "./cycleHardening.js";
|
|
3
|
+
/** @beta */
|
|
4
|
+
export * from "./subscriptionEngineCore.js";
|
|
5
|
+
/** @beta */
|
|
6
|
+
export * from "./subscriptionEngineLifecycle.js";
|
|
7
|
+
/** @beta */
|
|
8
|
+
export * from "./subscriptionEnginePayment.js";
|
|
9
|
+
/** @beta */
|
|
10
|
+
export * from "./subscriptionEngineTypes.js";
|
|
11
|
+
/** @beta */
|
|
12
|
+
export * from "./subscriptionEngineUtils.js";
|
|
13
|
+
/** @beta */
|
|
14
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** @beta */
|
|
2
|
+
export * from "./cycleHardening.js";
|
|
3
|
+
/** @beta */
|
|
4
|
+
export * from "./subscriptionEngineCore.js";
|
|
5
|
+
/** @beta */
|
|
6
|
+
export * from "./subscriptionEngineLifecycle.js";
|
|
7
|
+
/** @beta */
|
|
8
|
+
export * from "./subscriptionEnginePayment.js";
|
|
9
|
+
/** @beta */
|
|
10
|
+
export * from "./subscriptionEngineTypes.js";
|
|
11
|
+
/** @beta */
|
|
12
|
+
export * from "./subscriptionEngineUtils.js";
|
|
13
|
+
/** @beta */
|
|
14
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CreateInitialSubscriptionCheckoutInput, CycleMutationResult, EngineSubscription, InitialSubscriptionCheckoutModel, PlanCycleInput, SubscriptionEngineResult } from "./subscriptionEngineTypes.js";
|
|
2
|
+
/** @beta */
|
|
3
|
+
export declare function createInitialSubscriptionCheckoutModel(input: CreateInitialSubscriptionCheckoutInput): SubscriptionEngineResult<InitialSubscriptionCheckoutModel>;
|
|
4
|
+
/** @beta */
|
|
5
|
+
export declare function planSubscriptionCycle(input: PlanCycleInput): SubscriptionEngineResult<CycleMutationResult>;
|
|
6
|
+
/** @beta */
|
|
7
|
+
export declare function findDueSubscriptions(subscriptions: EngineSubscription[], nowIso: string): EngineSubscription[];
|
|
8
|
+
/** @beta */
|
|
9
|
+
export declare function isRenewalDue(subscription: EngineSubscription, nowIso: string): boolean;
|
|
10
|
+
/** @beta */
|
|
11
|
+
export declare function editCutoffAt(subscription: EngineSubscription): string | null;
|
|
12
|
+
/** @beta */
|
|
13
|
+
export declare function isEditWindowOpen(subscription: EngineSubscription, nowIso: string): boolean;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { DEFAULT_EDIT_WINDOW_HOURS, DEFAULT_TIMEZONE } from "./subscriptionEngineTypes.js";
|
|
2
|
+
import { cloneRecord, cloneTemplate, failure, makeEvent, parseIso, success, validateTemplate, } from "./subscriptionEngineUtils.js";
|
|
3
|
+
/** @beta */
|
|
4
|
+
export function createInitialSubscriptionCheckoutModel(input) {
|
|
5
|
+
const now = parseIso(input.now);
|
|
6
|
+
const firstCycleAt = parseIso(input.firstCycleAt);
|
|
7
|
+
if (!now || !firstCycleAt) {
|
|
8
|
+
return failure("invalid_timestamp", "now and firstCycleAt must be valid ISO timestamps");
|
|
9
|
+
}
|
|
10
|
+
const template = cloneTemplate(input.template);
|
|
11
|
+
const templateError = validateTemplate(template);
|
|
12
|
+
if (templateError)
|
|
13
|
+
return templateError;
|
|
14
|
+
const subscription = {
|
|
15
|
+
id: input.subscriptionId,
|
|
16
|
+
clientRef: input.clientRef,
|
|
17
|
+
status: "active",
|
|
18
|
+
template,
|
|
19
|
+
templateVersion: 1,
|
|
20
|
+
nextCycleAt: firstCycleAt.toISOString(),
|
|
21
|
+
paymentMethodRef: input.paymentMethodRef ?? null,
|
|
22
|
+
paymentMethodKind: input.paymentMethodKind ?? null,
|
|
23
|
+
timezone: input.timezone ?? DEFAULT_TIMEZONE,
|
|
24
|
+
createdAt: now.toISOString(),
|
|
25
|
+
updatedAt: now.toISOString(),
|
|
26
|
+
};
|
|
27
|
+
const planned = planSubscriptionCycle({
|
|
28
|
+
subscription,
|
|
29
|
+
cycleNumber: 1,
|
|
30
|
+
now: now.toISOString(),
|
|
31
|
+
pricingSnapshot: input.pricingSnapshot,
|
|
32
|
+
idempotencyKey: input.idempotencyKey,
|
|
33
|
+
});
|
|
34
|
+
if (planned.ok === false) {
|
|
35
|
+
return failure(planned.error.code, planned.error.message);
|
|
36
|
+
}
|
|
37
|
+
return success({
|
|
38
|
+
subscription,
|
|
39
|
+
cycle: planned.value.cycle,
|
|
40
|
+
events: [
|
|
41
|
+
makeEvent({
|
|
42
|
+
eventType: "subscription.created",
|
|
43
|
+
subscription,
|
|
44
|
+
idempotencyKey: `${input.idempotencyKey}:subscription.created`,
|
|
45
|
+
occurredAt: now.toISOString(),
|
|
46
|
+
payload: { clientRef: input.clientRef, templateVersion: 1 },
|
|
47
|
+
}),
|
|
48
|
+
...planned.value.events,
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** @beta */
|
|
53
|
+
export function planSubscriptionCycle(input) {
|
|
54
|
+
const now = parseIso(input.now);
|
|
55
|
+
const scheduledAt = parseIso(input.subscription.nextCycleAt);
|
|
56
|
+
if (!now || !scheduledAt) {
|
|
57
|
+
return failure("invalid_timestamp", "now and subscription.nextCycleAt must be valid ISO timestamps");
|
|
58
|
+
}
|
|
59
|
+
if (input.subscription.status !== "active") {
|
|
60
|
+
return failure("inactive_subscription", "only active subscriptions can generate cycles");
|
|
61
|
+
}
|
|
62
|
+
const templateSnapshot = cloneTemplate(input.subscription.template);
|
|
63
|
+
const hasPaymentMethod = Boolean(input.subscription.paymentMethodRef);
|
|
64
|
+
const status = hasPaymentMethod ? "payment_pending" : "payment_failed";
|
|
65
|
+
const failureReason = hasPaymentMethod ? null : "missing_payment_method";
|
|
66
|
+
const cycle = {
|
|
67
|
+
subscriptionId: input.subscription.id,
|
|
68
|
+
cycleNumber: input.cycleNumber,
|
|
69
|
+
status,
|
|
70
|
+
scheduledAt: scheduledAt.toISOString(),
|
|
71
|
+
paidAt: null,
|
|
72
|
+
templateVersion: input.subscription.templateVersion,
|
|
73
|
+
templateSnapshot,
|
|
74
|
+
pricingSnapshot: cloneRecord(input.pricingSnapshot),
|
|
75
|
+
paymentMethodRef: input.subscription.paymentMethodRef,
|
|
76
|
+
retryAttempt: 0,
|
|
77
|
+
nextRetryAt: null,
|
|
78
|
+
failureReason,
|
|
79
|
+
engineIdempotencyKey: input.idempotencyKey,
|
|
80
|
+
};
|
|
81
|
+
const events = [
|
|
82
|
+
makeEvent({
|
|
83
|
+
eventType: "subscription.cycle_planned",
|
|
84
|
+
subscription: input.subscription,
|
|
85
|
+
cycleNumber: input.cycleNumber,
|
|
86
|
+
idempotencyKey: `${input.idempotencyKey}:cycle_planned`,
|
|
87
|
+
occurredAt: now.toISOString(),
|
|
88
|
+
payload: {
|
|
89
|
+
scheduledAt: cycle.scheduledAt,
|
|
90
|
+
templateVersion: cycle.templateVersion,
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
];
|
|
94
|
+
events.push(hasPaymentMethod
|
|
95
|
+
? makeEvent({
|
|
96
|
+
eventType: "subscription.payment_requested",
|
|
97
|
+
subscription: input.subscription,
|
|
98
|
+
cycleNumber: input.cycleNumber,
|
|
99
|
+
idempotencyKey: `${input.idempotencyKey}:payment_requested`,
|
|
100
|
+
occurredAt: now.toISOString(),
|
|
101
|
+
payload: { paymentMethodRef: input.subscription.paymentMethodRef },
|
|
102
|
+
})
|
|
103
|
+
: makeEvent({
|
|
104
|
+
eventType: "subscription.payment_failed",
|
|
105
|
+
subscription: input.subscription,
|
|
106
|
+
cycleNumber: input.cycleNumber,
|
|
107
|
+
idempotencyKey: `${input.idempotencyKey}:missing_payment_method`,
|
|
108
|
+
occurredAt: now.toISOString(),
|
|
109
|
+
payload: { reason: failureReason },
|
|
110
|
+
}));
|
|
111
|
+
return success({ cycle, events });
|
|
112
|
+
}
|
|
113
|
+
/** @beta */
|
|
114
|
+
export function findDueSubscriptions(subscriptions, nowIso) {
|
|
115
|
+
return subscriptions.filter((subscription) => isRenewalDue(subscription, nowIso));
|
|
116
|
+
}
|
|
117
|
+
/** @beta */
|
|
118
|
+
export function isRenewalDue(subscription, nowIso) {
|
|
119
|
+
if (subscription.status !== "active")
|
|
120
|
+
return false;
|
|
121
|
+
const now = parseIso(nowIso);
|
|
122
|
+
const nextCycleAt = parseIso(subscription.nextCycleAt);
|
|
123
|
+
if (!now || !nextCycleAt)
|
|
124
|
+
return false;
|
|
125
|
+
return nextCycleAt.getTime() <= now.getTime();
|
|
126
|
+
}
|
|
127
|
+
/** @beta */
|
|
128
|
+
export function editCutoffAt(subscription) {
|
|
129
|
+
const nextCycleAt = parseIso(subscription.nextCycleAt);
|
|
130
|
+
if (!nextCycleAt)
|
|
131
|
+
return null;
|
|
132
|
+
const editWindowHours = subscription.template.edit_window_hours ?? DEFAULT_EDIT_WINDOW_HOURS;
|
|
133
|
+
return new Date(nextCycleAt.getTime() - editWindowHours * 60 * 60 * 1000).toISOString();
|
|
134
|
+
}
|
|
135
|
+
/** @beta */
|
|
136
|
+
export function isEditWindowOpen(subscription, nowIso) {
|
|
137
|
+
if (subscription.status !== "active")
|
|
138
|
+
return false;
|
|
139
|
+
const now = parseIso(nowIso);
|
|
140
|
+
const cutoff = editCutoffAt(subscription);
|
|
141
|
+
const cutoffAt = cutoff ? parseIso(cutoff) : null;
|
|
142
|
+
if (!now || !cutoffAt)
|
|
143
|
+
return false;
|
|
144
|
+
return now.getTime() < cutoffAt.getTime();
|
|
145
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
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";
|
|
4
|
+
/** @beta */
|
|
5
|
+
export declare function swapTemplateLine(input: {
|
|
6
|
+
subscription: EngineSubscription;
|
|
7
|
+
fromVariantId: string;
|
|
8
|
+
toVariantId: string;
|
|
9
|
+
now: string;
|
|
10
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
11
|
+
/** @beta */
|
|
12
|
+
export declare function addPermanentAddon(input: {
|
|
13
|
+
subscription: EngineSubscription;
|
|
14
|
+
line: Omit<SubscriptionLineSnapshot, "is_addon" | "sort_order"> & {
|
|
15
|
+
sort_order?: number;
|
|
16
|
+
};
|
|
17
|
+
now: string;
|
|
18
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
19
|
+
/** @beta */
|
|
20
|
+
export declare function skipNextCycle(input: {
|
|
21
|
+
subscription: EngineSubscription;
|
|
22
|
+
now: string;
|
|
23
|
+
reason?: string;
|
|
24
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
25
|
+
/** @beta */
|
|
26
|
+
export declare function slideNextCycle(input: {
|
|
27
|
+
subscription: EngineSubscription;
|
|
28
|
+
newNextCycleAt: string;
|
|
29
|
+
now: string;
|
|
30
|
+
reason?: string;
|
|
31
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
32
|
+
/** @beta */
|
|
33
|
+
export declare function pauseSubscription(input: {
|
|
34
|
+
subscription: EngineSubscription;
|
|
35
|
+
now: string;
|
|
36
|
+
reason?: string;
|
|
37
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
38
|
+
/** @beta */
|
|
39
|
+
export declare function resumeSubscription(input: {
|
|
40
|
+
subscription: EngineSubscription;
|
|
41
|
+
now: string;
|
|
42
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
43
|
+
/** @beta */
|
|
44
|
+
export declare function cancelSubscription(input: {
|
|
45
|
+
subscription: EngineSubscription;
|
|
46
|
+
now: string;
|
|
47
|
+
reason?: string;
|
|
48
|
+
}): SubscriptionEngineResult<SubscriptionMutationResult>;
|
|
49
|
+
/** @beta */
|
|
50
|
+
export declare function completeSubscription(input: {
|
|
51
|
+
subscription: EngineSubscription;
|
|
52
|
+
now: string;
|
|
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;
|