@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.
Files changed (192) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/CODE_OF_CONDUCT.md +39 -0
  3. package/CONTRIBUTING.md +34 -0
  4. package/LICENSE +204 -0
  5. package/MAINTAINERS.md +33 -0
  6. package/README.md +85 -3
  7. package/SECURITY.md +29 -0
  8. package/dist/bundle/contracts.d.ts +9 -0
  9. package/dist/bundle/contracts.js +1 -0
  10. package/dist/bundle/ports.d.ts +41 -0
  11. package/dist/bundle/ports.js +1 -0
  12. package/dist/catalog/identifiers.d.ts +31 -0
  13. package/dist/catalog/identifiers.js +46 -0
  14. package/dist/catalog/index.d.ts +2 -0
  15. package/dist/catalog/index.js +2 -0
  16. package/dist/checkout/idempotency.d.ts +29 -0
  17. package/dist/checkout/idempotency.js +37 -0
  18. package/dist/checkout/index.d.ts +2 -0
  19. package/dist/checkout/index.js +2 -0
  20. package/dist/company-identity/contracts.d.ts +220 -0
  21. package/dist/company-identity/contracts.js +167 -0
  22. package/dist/company-identity/index.d.ts +6 -0
  23. package/dist/company-identity/index.js +2 -0
  24. package/dist/company-identity/ports.d.ts +13 -0
  25. package/dist/company-identity/ports.js +1 -0
  26. package/dist/fulfillment/facts.d.ts +103 -0
  27. package/dist/fulfillment/facts.js +195 -0
  28. package/dist/fulfillment/index.d.ts +4 -0
  29. package/dist/fulfillment/index.js +2 -0
  30. package/dist/inventory/atp.d.ts +172 -0
  31. package/dist/inventory/atp.js +211 -0
  32. package/dist/inventory/index.d.ts +2 -0
  33. package/dist/inventory/index.js +2 -0
  34. package/dist/marketing/research/contracts.d.ts +75 -0
  35. package/dist/marketing/research/contracts.js +60 -0
  36. package/dist/marketing/research/index.d.ts +4 -0
  37. package/dist/marketing/research/index.js +4 -0
  38. package/dist/marketing/research/ports.d.ts +15 -0
  39. package/dist/marketing/research/ports.js +1 -0
  40. package/dist/partners/index.d.ts +4 -0
  41. package/dist/partners/index.js +4 -0
  42. package/dist/partners/ports.d.ts +12 -0
  43. package/dist/partners/ports.js +1 -0
  44. package/dist/partners/status.d.ts +48 -0
  45. package/dist/partners/status.js +35 -0
  46. package/dist/payment/executionBaseContracts.d.ts +81 -0
  47. package/dist/payment/executionBaseContracts.js +8 -0
  48. package/dist/payment/index.d.ts +36 -0
  49. package/dist/payment/index.js +18 -0
  50. package/dist/payment/methodLifecycle.d.ts +91 -0
  51. package/dist/payment/methodLifecycle.js +76 -0
  52. package/dist/payment/paymentControlTypes.d.ts +135 -0
  53. package/dist/payment/paymentControlTypes.js +110 -0
  54. package/dist/payment/paymentFailureTaxonomy.d.ts +80 -0
  55. package/dist/payment/paymentFailureTaxonomy.js +155 -0
  56. package/dist/payment/paymentFailureTaxonomyContracts.d.ts +106 -0
  57. package/dist/payment/paymentFailureTaxonomyContracts.js +177 -0
  58. package/dist/payment/paymentRecovery.d.ts +26 -0
  59. package/dist/payment/paymentRecovery.js +73 -0
  60. package/dist/payment/paymentRecoveryContracts.d.ts +53 -0
  61. package/dist/payment/paymentRecoveryContracts.js +10 -0
  62. package/dist/payment/paymentStateMachine.d.ts +28 -0
  63. package/dist/payment/paymentStateMachine.js +184 -0
  64. package/dist/payment/providerAttemptIdempotency.d.ts +23 -0
  65. package/dist/payment/providerAttemptIdempotency.js +31 -0
  66. package/dist/payment/providerCapability.d.ts +148 -0
  67. package/dist/payment/providerCapability.js +13 -0
  68. package/dist/platform-runtime/contracts.d.ts +9 -0
  69. package/dist/platform-runtime/contracts.js +13 -0
  70. package/dist/platform-runtime/index.d.ts +10 -0
  71. package/dist/platform-runtime/index.js +4 -0
  72. package/dist/platform-runtime/platformKernel.d.ts +44 -0
  73. package/dist/platform-runtime/platformKernel.js +39 -0
  74. package/dist/platform-runtime/ports.d.ts +100 -0
  75. package/dist/platform-runtime/ports.js +1 -0
  76. package/dist/pricing/index.d.ts +8 -0
  77. package/dist/pricing/index.js +8 -0
  78. package/dist/pricing/ports.d.ts +22 -0
  79. package/dist/pricing/ports.js +7 -0
  80. package/dist/pricing/pricingBreakdown.d.ts +86 -0
  81. package/dist/pricing/pricingBreakdown.js +155 -0
  82. package/dist/pricing/targetPriceAllocation.d.ts +97 -0
  83. package/dist/pricing/targetPriceAllocation.js +202 -0
  84. package/dist/pricing/types.d.ts +32 -0
  85. package/dist/pricing/types.js +4 -0
  86. package/dist/promo/adjustmentEngine.d.ts +4 -0
  87. package/dist/promo/adjustmentEngine.js +194 -0
  88. package/dist/promo/adjustmentTypes.d.ts +103 -0
  89. package/dist/promo/adjustmentTypes.js +13 -0
  90. package/dist/promo/index.d.ts +8 -0
  91. package/dist/promo/index.js +8 -0
  92. package/dist/promo/promoEvaluator.d.ts +25 -0
  93. package/dist/promo/promoEvaluator.js +135 -0
  94. package/dist/promo/types.d.ts +86 -0
  95. package/dist/promo/types.js +12 -0
  96. package/dist/risk/core/evaluator.d.ts +3 -0
  97. package/dist/risk/core/evaluator.js +32 -0
  98. package/dist/risk/core/rules.d.ts +3 -0
  99. package/dist/risk/core/rules.js +118 -0
  100. package/dist/risk/core/types.d.ts +65 -0
  101. package/dist/risk/core/types.js +1 -0
  102. package/dist/risk/index.d.ts +8 -0
  103. package/dist/risk/index.js +8 -0
  104. package/dist/risk/types.d.ts +32 -0
  105. package/dist/risk/types.js +43 -0
  106. package/dist/shipping/index.d.ts +4 -0
  107. package/dist/shipping/index.js +4 -0
  108. package/dist/shipping/shippingResolver.d.ts +26 -0
  109. package/dist/shipping/shippingResolver.js +88 -0
  110. package/dist/shipping/types.d.ts +45 -0
  111. package/dist/shipping/types.js +4 -0
  112. package/dist/subscription/cycleHardening.d.ts +95 -0
  113. package/dist/subscription/cycleHardening.js +118 -0
  114. package/dist/subscription/index.d.ts +14 -0
  115. package/dist/subscription/index.js +14 -0
  116. package/dist/subscription/subscriptionEngineCore.d.ts +13 -0
  117. package/dist/subscription/subscriptionEngineCore.js +145 -0
  118. package/dist/subscription/subscriptionEngineLifecycle.d.ts +150 -0
  119. package/dist/subscription/subscriptionEngineLifecycle.js +433 -0
  120. package/dist/subscription/subscriptionEnginePayment.d.ts +25 -0
  121. package/dist/subscription/subscriptionEnginePayment.js +127 -0
  122. package/dist/subscription/subscriptionEngineTypes.d.ts +94 -0
  123. package/dist/subscription/subscriptionEngineTypes.js +4 -0
  124. package/dist/subscription/subscriptionEngineUtils.d.ts +32 -0
  125. package/dist/subscription/subscriptionEngineUtils.js +74 -0
  126. package/dist/subscription/types.d.ts +58 -0
  127. package/dist/subscription/types.js +47 -0
  128. package/dist/testing.d.ts +48 -0
  129. package/dist/testing.js +105 -0
  130. package/package.json +215 -4
  131. package/release-gates.json +232 -0
  132. package/src/bundle/contracts.ts +15 -0
  133. package/src/bundle/ports.ts +32 -0
  134. package/src/catalog/identifiers.ts +50 -0
  135. package/src/catalog/index.ts +2 -0
  136. package/src/checkout/idempotency.ts +49 -0
  137. package/src/checkout/index.ts +2 -0
  138. package/src/company-identity/contracts.ts +208 -0
  139. package/src/company-identity/index.ts +28 -0
  140. package/src/company-identity/ports.ts +19 -0
  141. package/src/fulfillment/facts.ts +300 -0
  142. package/src/fulfillment/index.ts +34 -0
  143. package/src/inventory/atp.ts +249 -0
  144. package/src/inventory/index.ts +2 -0
  145. package/src/marketing/research/contracts.ts +91 -0
  146. package/src/marketing/research/index.ts +4 -0
  147. package/src/marketing/research/ports.ts +26 -0
  148. package/src/partners/index.ts +4 -0
  149. package/src/partners/ports.ts +18 -0
  150. package/src/partners/status.ts +49 -0
  151. package/src/payment/executionBaseContracts.ts +93 -0
  152. package/src/payment/index.ts +128 -0
  153. package/src/payment/methodLifecycle.ts +130 -0
  154. package/src/payment/paymentControlTypes.ts +197 -0
  155. package/src/payment/paymentFailureTaxonomy.ts +201 -0
  156. package/src/payment/paymentFailureTaxonomyContracts.ts +228 -0
  157. package/src/payment/paymentRecovery.ts +95 -0
  158. package/src/payment/paymentRecoveryContracts.ts +61 -0
  159. package/src/payment/paymentStateMachine.ts +263 -0
  160. package/src/payment/providerAttemptIdempotency.ts +57 -0
  161. package/src/payment/providerCapability.ts +172 -0
  162. package/src/platform-runtime/contracts.ts +33 -0
  163. package/src/platform-runtime/index.ts +36 -0
  164. package/src/platform-runtime/platformKernel.ts +86 -0
  165. package/src/platform-runtime/ports.ts +88 -0
  166. package/src/pricing/index.ts +8 -0
  167. package/src/pricing/ports.ts +27 -0
  168. package/src/pricing/pricingBreakdown.ts +243 -0
  169. package/src/pricing/targetPriceAllocation.ts +293 -0
  170. package/src/pricing/types.ts +36 -0
  171. package/src/promo/adjustmentEngine.ts +253 -0
  172. package/src/promo/adjustmentTypes.ts +94 -0
  173. package/src/promo/index.ts +8 -0
  174. package/src/promo/promoEvaluator.ts +137 -0
  175. package/src/promo/types.ts +97 -0
  176. package/src/risk/core/evaluator.ts +41 -0
  177. package/src/risk/core/rules.ts +128 -0
  178. package/src/risk/core/types.ts +73 -0
  179. package/src/risk/index.ts +8 -0
  180. package/src/risk/types.ts +60 -0
  181. package/src/shipping/index.ts +4 -0
  182. package/src/shipping/shippingResolver.ts +101 -0
  183. package/src/shipping/types.ts +49 -0
  184. package/src/subscription/cycleHardening.ts +174 -0
  185. package/src/subscription/index.ts +14 -0
  186. package/src/subscription/subscriptionEngineCore.ts +181 -0
  187. package/src/subscription/subscriptionEngineLifecycle.ts +574 -0
  188. package/src/subscription/subscriptionEnginePayment.ts +179 -0
  189. package/src/subscription/subscriptionEngineTypes.ts +114 -0
  190. package/src/subscription/subscriptionEngineUtils.ts +110 -0
  191. package/src/subscription/types.ts +104 -0
  192. 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;