@openlup/core 0.0.0 → 0.6.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 +184 -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 +64 -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 +168 -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 +72 -0
  113. package/dist/subscription/cycleHardening.js +95 -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 +52 -0
  119. package/dist/subscription/subscriptionEngineLifecycle.js +196 -0
  120. package/dist/subscription/subscriptionEnginePayment.d.ts +19 -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 +209 -0
  139. package/src/company-identity/index.ts +27 -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 +141 -0
  185. package/src/subscription/index.ts +14 -0
  186. package/src/subscription/subscriptionEngineCore.ts +181 -0
  187. package/src/subscription/subscriptionEngineLifecycle.ts +282 -0
  188. package/src/subscription/subscriptionEnginePayment.ts +174 -0
  189. package/src/subscription/subscriptionEngineTypes.ts +112 -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,181 @@
1
+ import type { SubscriptionCycleStatus } from "./types.js";
2
+ import type {
3
+ CreateInitialSubscriptionCheckoutInput,
4
+ CycleMutationResult,
5
+ EngineSubscription,
6
+ InitialSubscriptionCheckoutModel,
7
+ PlanCycleInput,
8
+ SubscriptionEngineResult,
9
+ } from "./subscriptionEngineTypes.js";
10
+ import { DEFAULT_EDIT_WINDOW_HOURS, DEFAULT_TIMEZONE } from "./subscriptionEngineTypes.js";
11
+ import {
12
+ cloneRecord,
13
+ cloneTemplate,
14
+ failure,
15
+ makeEvent,
16
+ parseIso,
17
+ success,
18
+ validateTemplate,
19
+ } from "./subscriptionEngineUtils.js";
20
+
21
+ /** @beta */
22
+ export function createInitialSubscriptionCheckoutModel(
23
+ input: CreateInitialSubscriptionCheckoutInput,
24
+ ): SubscriptionEngineResult<InitialSubscriptionCheckoutModel> {
25
+ const now = parseIso(input.now);
26
+ const firstCycleAt = parseIso(input.firstCycleAt);
27
+ if (!now || !firstCycleAt) {
28
+ return failure("invalid_timestamp", "now and firstCycleAt must be valid ISO timestamps");
29
+ }
30
+
31
+ const template = cloneTemplate(input.template);
32
+ const templateError = validateTemplate(template);
33
+ if (templateError) return templateError;
34
+
35
+ const subscription: EngineSubscription = {
36
+ id: input.subscriptionId,
37
+ clientRef: input.clientRef,
38
+ status: "active",
39
+ template,
40
+ templateVersion: 1,
41
+ nextCycleAt: firstCycleAt.toISOString(),
42
+ paymentMethodRef: input.paymentMethodRef ?? null,
43
+ paymentMethodKind: input.paymentMethodKind ?? null,
44
+ timezone: input.timezone ?? DEFAULT_TIMEZONE,
45
+ createdAt: now.toISOString(),
46
+ updatedAt: now.toISOString(),
47
+ };
48
+
49
+ const planned = planSubscriptionCycle({
50
+ subscription,
51
+ cycleNumber: 1,
52
+ now: now.toISOString(),
53
+ pricingSnapshot: input.pricingSnapshot,
54
+ idempotencyKey: input.idempotencyKey,
55
+ });
56
+ if (planned.ok === false) {
57
+ return failure(planned.error.code, planned.error.message);
58
+ }
59
+
60
+ return success({
61
+ subscription,
62
+ cycle: planned.value.cycle,
63
+ events: [
64
+ makeEvent({
65
+ eventType: "subscription.created",
66
+ subscription,
67
+ idempotencyKey: `${input.idempotencyKey}:subscription.created`,
68
+ occurredAt: now.toISOString(),
69
+ payload: { clientRef: input.clientRef, templateVersion: 1 },
70
+ }),
71
+ ...planned.value.events,
72
+ ],
73
+ });
74
+ }
75
+
76
+ /** @beta */
77
+ export function planSubscriptionCycle(
78
+ input: PlanCycleInput,
79
+ ): SubscriptionEngineResult<CycleMutationResult> {
80
+ const now = parseIso(input.now);
81
+ const scheduledAt = parseIso(input.subscription.nextCycleAt);
82
+ if (!now || !scheduledAt) {
83
+ return failure("invalid_timestamp", "now and subscription.nextCycleAt must be valid ISO timestamps");
84
+ }
85
+
86
+ if (input.subscription.status !== "active") {
87
+ return failure("inactive_subscription", "only active subscriptions can generate cycles");
88
+ }
89
+
90
+ const templateSnapshot = cloneTemplate(input.subscription.template);
91
+ const hasPaymentMethod = Boolean(input.subscription.paymentMethodRef);
92
+ const status: SubscriptionCycleStatus = hasPaymentMethod ? "payment_pending" : "payment_failed";
93
+ const failureReason = hasPaymentMethod ? null : "missing_payment_method";
94
+
95
+ const cycle = {
96
+ subscriptionId: input.subscription.id,
97
+ cycleNumber: input.cycleNumber,
98
+ status,
99
+ scheduledAt: scheduledAt.toISOString(),
100
+ paidAt: null,
101
+ templateVersion: input.subscription.templateVersion,
102
+ templateSnapshot,
103
+ pricingSnapshot: cloneRecord(input.pricingSnapshot),
104
+ paymentMethodRef: input.subscription.paymentMethodRef,
105
+ retryAttempt: 0,
106
+ nextRetryAt: null,
107
+ failureReason,
108
+ engineIdempotencyKey: input.idempotencyKey,
109
+ };
110
+
111
+ const events = [
112
+ makeEvent({
113
+ eventType: "subscription.cycle_planned",
114
+ subscription: input.subscription,
115
+ cycleNumber: input.cycleNumber,
116
+ idempotencyKey: `${input.idempotencyKey}:cycle_planned`,
117
+ occurredAt: now.toISOString(),
118
+ payload: {
119
+ scheduledAt: cycle.scheduledAt,
120
+ templateVersion: cycle.templateVersion,
121
+ },
122
+ }),
123
+ ];
124
+
125
+ events.push(
126
+ hasPaymentMethod
127
+ ? makeEvent({
128
+ eventType: "subscription.payment_requested",
129
+ subscription: input.subscription,
130
+ cycleNumber: input.cycleNumber,
131
+ idempotencyKey: `${input.idempotencyKey}:payment_requested`,
132
+ occurredAt: now.toISOString(),
133
+ payload: { paymentMethodRef: input.subscription.paymentMethodRef },
134
+ })
135
+ : makeEvent({
136
+ eventType: "subscription.payment_failed",
137
+ subscription: input.subscription,
138
+ cycleNumber: input.cycleNumber,
139
+ idempotencyKey: `${input.idempotencyKey}:missing_payment_method`,
140
+ occurredAt: now.toISOString(),
141
+ payload: { reason: failureReason },
142
+ }),
143
+ );
144
+
145
+ return success({ cycle, events });
146
+ }
147
+
148
+ /** @beta */
149
+ export function findDueSubscriptions(
150
+ subscriptions: EngineSubscription[],
151
+ nowIso: string,
152
+ ): EngineSubscription[] {
153
+ return subscriptions.filter((subscription) => isRenewalDue(subscription, nowIso));
154
+ }
155
+
156
+ /** @beta */
157
+ export function isRenewalDue(subscription: EngineSubscription, nowIso: string): boolean {
158
+ if (subscription.status !== "active") return false;
159
+ const now = parseIso(nowIso);
160
+ const nextCycleAt = parseIso(subscription.nextCycleAt);
161
+ if (!now || !nextCycleAt) return false;
162
+ return nextCycleAt.getTime() <= now.getTime();
163
+ }
164
+
165
+ /** @beta */
166
+ export function editCutoffAt(subscription: EngineSubscription): string | null {
167
+ const nextCycleAt = parseIso(subscription.nextCycleAt);
168
+ if (!nextCycleAt) return null;
169
+ const editWindowHours = subscription.template.edit_window_hours ?? DEFAULT_EDIT_WINDOW_HOURS;
170
+ return new Date(nextCycleAt.getTime() - editWindowHours * 60 * 60 * 1000).toISOString();
171
+ }
172
+
173
+ /** @beta */
174
+ export function isEditWindowOpen(subscription: EngineSubscription, nowIso: string): boolean {
175
+ if (subscription.status !== "active") return false;
176
+ const now = parseIso(nowIso);
177
+ const cutoff = editCutoffAt(subscription);
178
+ const cutoffAt = cutoff ? parseIso(cutoff) : null;
179
+ if (!now || !cutoffAt) return false;
180
+ return now.getTime() < cutoffAt.getTime();
181
+ }
@@ -0,0 +1,282 @@
1
+ import type { SubscriptionEngineEventType, SubscriptionLineSnapshot, SubscriptionStatus } from "./types.js";
2
+ import { isEditWindowOpen } from "./subscriptionEngineCore.js";
3
+ import type {
4
+ EngineSubscription,
5
+ SubscriptionEngineResult,
6
+ SubscriptionMutationResult,
7
+ } from "./subscriptionEngineTypes.js";
8
+ import {
9
+ addCadenceDays,
10
+ cloneTemplate,
11
+ failure,
12
+ makeEvent,
13
+ parseIso,
14
+ statusPatch,
15
+ success,
16
+ touchSubscription,
17
+ validateTemplate,
18
+ } from "./subscriptionEngineUtils.js";
19
+
20
+ /** @beta */
21
+ export function swapTemplateLine(input: {
22
+ subscription: EngineSubscription;
23
+ fromVariantId: string;
24
+ toVariantId: string;
25
+ now: string;
26
+ }): SubscriptionEngineResult<SubscriptionMutationResult> {
27
+ if (!isEditWindowOpen(input.subscription, input.now)) {
28
+ return failure("edit_window_closed", "template swaps are locked after the edit cutoff");
29
+ }
30
+
31
+ if (input.subscription.template.lines.some((line) => line.variant_id === input.toVariantId)) {
32
+ return failure("duplicate_line", "target variant is already present in the template");
33
+ }
34
+
35
+ let replaced = false;
36
+ const lines = input.subscription.template.lines.map((line) => {
37
+ if (line.variant_id !== input.fromVariantId) return { ...line };
38
+ replaced = true;
39
+ return { ...line, variant_id: input.toVariantId };
40
+ });
41
+
42
+ if (!replaced) {
43
+ return failure("line_not_found", "source variant is not present in the template");
44
+ }
45
+
46
+ return updateTemplate({
47
+ subscription: input.subscription,
48
+ template: { ...cloneTemplate(input.subscription.template), lines },
49
+ now: input.now,
50
+ eventType: "subscription.template_updated",
51
+ payload: { operation: "swap", fromVariantId: input.fromVariantId, toVariantId: input.toVariantId },
52
+ });
53
+ }
54
+
55
+ /** @beta */
56
+ export function addPermanentAddon(input: {
57
+ subscription: EngineSubscription;
58
+ line: Omit<SubscriptionLineSnapshot, "is_addon" | "sort_order"> & { sort_order?: number };
59
+ now: string;
60
+ }): SubscriptionEngineResult<SubscriptionMutationResult> {
61
+ if (!isEditWindowOpen(input.subscription, input.now)) {
62
+ return failure("edit_window_closed", "template add-ons are locked after the edit cutoff");
63
+ }
64
+
65
+ if (input.subscription.template.lines.some((line) => line.variant_id === input.line.variant_id)) {
66
+ return failure("duplicate_line", "add-on variant is already present in the template");
67
+ }
68
+
69
+ const nextSortOrder =
70
+ input.line.sort_order ??
71
+ Math.max(0, ...input.subscription.template.lines.map((line) => line.sort_order)) + 1;
72
+ const template = cloneTemplate(input.subscription.template);
73
+ template.lines = [
74
+ ...template.lines,
75
+ {
76
+ variant_id: input.line.variant_id,
77
+ qty: input.line.qty,
78
+ sort_order: nextSortOrder,
79
+ is_addon: true,
80
+ },
81
+ ];
82
+
83
+ return updateTemplate({
84
+ subscription: input.subscription,
85
+ template,
86
+ now: input.now,
87
+ eventType: "subscription.template_updated",
88
+ payload: { operation: "add_permanent_addon", variantId: input.line.variant_id },
89
+ });
90
+ }
91
+
92
+ /** @beta */
93
+ export function skipNextCycle(input: {
94
+ subscription: EngineSubscription;
95
+ now: string;
96
+ reason?: string;
97
+ }): SubscriptionEngineResult<SubscriptionMutationResult> {
98
+ const now = parseIso(input.now);
99
+ if (!now) return failure("invalid_timestamp", "now must be a valid ISO timestamp");
100
+ if (input.subscription.status !== "active") {
101
+ return failure("inactive_subscription", "only active subscriptions can skip cycles");
102
+ }
103
+
104
+ const nextCycleAt = addCadenceDays(input.subscription.nextCycleAt, input.subscription.template.cadence_days);
105
+ if (!nextCycleAt) return failure("invalid_timestamp", "subscription.nextCycleAt must be a valid ISO timestamp");
106
+
107
+ const subscription = touchSubscription(input.subscription, input.now, { nextCycleAt });
108
+
109
+ return success({
110
+ subscription,
111
+ events: [
112
+ makeEvent({
113
+ eventType: "subscription.cycle_skipped",
114
+ subscription: input.subscription,
115
+ idempotencyKey: `${input.subscription.id}:${input.subscription.nextCycleAt}:cycle_skipped`,
116
+ occurredAt: now.toISOString(),
117
+ payload: { skippedScheduledAt: input.subscription.nextCycleAt, reason: input.reason ?? null },
118
+ }),
119
+ makeEvent({
120
+ eventType: "subscription.next_cycle_at_updated",
121
+ subscription,
122
+ idempotencyKey: `${subscription.id}:${subscription.nextCycleAt}:next_cycle_at_updated`,
123
+ occurredAt: now.toISOString(),
124
+ payload: { nextCycleAt },
125
+ }),
126
+ ],
127
+ });
128
+ }
129
+
130
+ /** @beta */
131
+ export function slideNextCycle(input: {
132
+ subscription: EngineSubscription;
133
+ newNextCycleAt: string;
134
+ now: string;
135
+ reason?: string;
136
+ }): SubscriptionEngineResult<SubscriptionMutationResult> {
137
+ const now = parseIso(input.now);
138
+ const newNextCycleAt = parseIso(input.newNextCycleAt);
139
+ if (!now || !newNextCycleAt) {
140
+ return failure("invalid_timestamp", "now and newNextCycleAt must be valid ISO timestamps");
141
+ }
142
+ if (input.subscription.status !== "active") {
143
+ return failure("inactive_subscription", "only active subscriptions can slide cycles");
144
+ }
145
+ if (newNextCycleAt.getTime() <= now.getTime()) {
146
+ return failure("invalid_slide_target", "newNextCycleAt must be in the future");
147
+ }
148
+
149
+ const subscription = touchSubscription(input.subscription, now.toISOString(), {
150
+ nextCycleAt: newNextCycleAt.toISOString(),
151
+ });
152
+
153
+ return success({
154
+ subscription,
155
+ events: [
156
+ makeEvent({
157
+ eventType: "subscription.next_cycle_at_updated",
158
+ subscription,
159
+ idempotencyKey: `${subscription.id}:${subscription.nextCycleAt}:slide`,
160
+ occurredAt: now.toISOString(),
161
+ payload: {
162
+ previousNextCycleAt: input.subscription.nextCycleAt,
163
+ nextCycleAt: subscription.nextCycleAt,
164
+ reason: input.reason ?? null,
165
+ },
166
+ }),
167
+ ],
168
+ });
169
+ }
170
+
171
+ /** @beta */
172
+ export function pauseSubscription(input: {
173
+ subscription: EngineSubscription;
174
+ now: string;
175
+ reason?: string;
176
+ }): SubscriptionEngineResult<SubscriptionMutationResult> {
177
+ return changeSubscriptionStatus(input.subscription, "paused", input.now, "subscription.paused", {
178
+ reason: input.reason ?? null,
179
+ });
180
+ }
181
+
182
+ /** @beta */
183
+ export function resumeSubscription(input: {
184
+ subscription: EngineSubscription;
185
+ now: string;
186
+ }): SubscriptionEngineResult<SubscriptionMutationResult> {
187
+ return changeSubscriptionStatus(input.subscription, "active", input.now, "subscription.resumed", {});
188
+ }
189
+
190
+ /** @beta */
191
+ export function cancelSubscription(input: {
192
+ subscription: EngineSubscription;
193
+ now: string;
194
+ reason?: string;
195
+ }): SubscriptionEngineResult<SubscriptionMutationResult> {
196
+ return changeSubscriptionStatus(input.subscription, "cancelled", input.now, "subscription.cancelled", {
197
+ reason: input.reason ?? null,
198
+ });
199
+ }
200
+
201
+ /** @beta */
202
+ export function completeSubscription(input: {
203
+ subscription: EngineSubscription;
204
+ now: string;
205
+ }): SubscriptionEngineResult<SubscriptionMutationResult> {
206
+ return changeSubscriptionStatus(input.subscription, "completed", input.now, "subscription.completed", {});
207
+ }
208
+
209
+ function updateTemplate(input: {
210
+ subscription: EngineSubscription;
211
+ template: EngineSubscription["template"];
212
+ now: string;
213
+ eventType: SubscriptionEngineEventType;
214
+ payload: Record<string, unknown>;
215
+ }): SubscriptionEngineResult<SubscriptionMutationResult> {
216
+ const now = parseIso(input.now);
217
+ if (!now) return failure("invalid_timestamp", "now must be a valid ISO timestamp");
218
+
219
+ const template = cloneTemplate(input.template);
220
+ const templateError = validateTemplate(template);
221
+ if (templateError) return templateError;
222
+
223
+ const subscription = touchSubscription(input.subscription, now.toISOString(), {
224
+ template,
225
+ templateVersion: input.subscription.templateVersion + 1,
226
+ });
227
+
228
+ return success({
229
+ subscription,
230
+ events: [
231
+ makeEvent({
232
+ eventType: input.eventType,
233
+ subscription,
234
+ idempotencyKey: `${subscription.id}:template:${subscription.templateVersion}`,
235
+ occurredAt: now.toISOString(),
236
+ payload: { ...input.payload, templateVersion: subscription.templateVersion },
237
+ }),
238
+ ],
239
+ });
240
+ }
241
+
242
+ function changeSubscriptionStatus(
243
+ subscription: EngineSubscription,
244
+ status: SubscriptionStatus,
245
+ nowIso: string,
246
+ eventType: SubscriptionEngineEventType,
247
+ payload: Record<string, unknown>,
248
+ ): SubscriptionEngineResult<SubscriptionMutationResult> {
249
+ const now = parseIso(nowIso);
250
+ if (!now) return failure("invalid_timestamp", "now must be a valid ISO timestamp");
251
+ if (!canTransitionSubscriptionStatus(subscription.status, status)) {
252
+ return failure(
253
+ "invalid_transition",
254
+ `Cannot move subscription from ${subscription.status} to ${status}`,
255
+ );
256
+ }
257
+
258
+ const next = touchSubscription(subscription, now.toISOString(), statusPatch(status));
259
+ return success({
260
+ subscription: next,
261
+ events: [
262
+ makeEvent({
263
+ eventType,
264
+ subscription: next,
265
+ idempotencyKey: `${next.id}:${eventType}:${now.toISOString()}`,
266
+ occurredAt: now.toISOString(),
267
+ payload,
268
+ }),
269
+ ],
270
+ });
271
+ }
272
+
273
+ function canTransitionSubscriptionStatus(from: SubscriptionStatus, to: SubscriptionStatus): boolean {
274
+ const transitions: Record<SubscriptionStatus, readonly SubscriptionStatus[]> = {
275
+ active: ["paused", "cancelled", "completed"],
276
+ paused: ["active", "cancelled"],
277
+ cancelled: [],
278
+ completed: [],
279
+ };
280
+
281
+ return transitions[from].includes(to);
282
+ }
@@ -0,0 +1,174 @@
1
+ import { nextRetryAttemptAt } from "./cycleHardening.js";
2
+ import type { SubscriptionCycleStatus } from "./types.js";
3
+ import type {
4
+ CycleMutationResult,
5
+ EngineCycle,
6
+ EngineEvent,
7
+ EngineSubscription,
8
+ SubscriptionEngineResult,
9
+ } from "./subscriptionEngineTypes.js";
10
+ import {
11
+ addCadenceDays,
12
+ failure,
13
+ isTerminalCycle,
14
+ makeEvent,
15
+ parseIso,
16
+ success,
17
+ touchSubscription,
18
+ } from "./subscriptionEngineUtils.js";
19
+
20
+ const DAY_MS = 24 * 60 * 60 * 1000;
21
+
22
+ /** @beta */
23
+ export function recordPaymentSuccess(input: {
24
+ subscription: EngineSubscription;
25
+ cycle: EngineCycle;
26
+ paidAt: string;
27
+ recordedAt: string;
28
+ }): SubscriptionEngineResult<{
29
+ subscription: EngineSubscription;
30
+ cycle: EngineCycle;
31
+ events: EngineEvent[];
32
+ }> {
33
+ const paidAt = parseIso(input.paidAt);
34
+ if (!paidAt) return failure("invalid_timestamp", "paidAt must be a valid ISO timestamp");
35
+ const recordedAt = parseIso(input.recordedAt);
36
+ if (!recordedAt) return failure("invalid_timestamp", "recordedAt must be a valid ISO timestamp");
37
+ if (isTerminalCycle(input.cycle)) {
38
+ return failure("terminal_cycle", "terminal cycles cannot be paid");
39
+ }
40
+
41
+ // The next cycle is one cadence past the CYCLE'S OWN scheduled instant, not past
42
+ // the moment the money arrived: a late payment must not silently shorten the
43
+ // interval the customer bought. The whole days the payment was late are then
44
+ // ADDED, because a delivery that arrives late shifts the whole subscription
45
+ // later rather than letting the next one stack up behind it.
46
+ //
47
+ // Lateness is measured to the EARLIER of the payment instant and the instant we
48
+ // recorded it, so a provider timestamp from the future cannot inflate the shift
49
+ // and push the next charge out past what the customer actually owes.
50
+ //
51
+ // ⛔ The outer Math.max is the load-bearing part and is NOT an optimisation.
52
+ // It is a monotonic clamp: `nextCycleAt` may only ever move LATER, never
53
+ // earlier. Without it, a replayed webhook, an out-of-order settlement, or a
54
+ // manual correction applied against an older cycle could compute an EARLIER
55
+ // instant and pull a charge forward into a window the subscriber has already
56
+ // been billed for — the one failure mode this rail must never have. The same
57
+ // clamp is spelled `GREATEST` in the SQL delivery-alignment rail for exactly
58
+ // this reason; the two are one rule with two spellings.
59
+ //
60
+ // `Date.parse(...) || 0` is deliberate rather than defensive noise: a
61
+ // subscription whose stored instant is unreadable clamps against the epoch,
62
+ // which yields the computed instant, instead of poisoning the whole expression
63
+ // with NaN and returning an invalid date. Cadence keeps its own call so its own
64
+ // validity guard still fires and still reports the right error, and `toJSON()`
65
+ // answers null past the representable range rather than throwing, which is why
66
+ // the shift is range-checked below instead of trusted.
67
+ const onTimeNextCycleAt = addCadenceDays(input.cycle.scheduledAt, input.cycle.templateSnapshot.cadence_days);
68
+ if (!onTimeNextCycleAt) return failure("invalid_timestamp", "cycle.scheduledAt must be a valid ISO timestamp");
69
+
70
+ const shiftedByDays = Math.max(0, Math.floor(
71
+ (Math.min(paidAt.getTime(), recordedAt.getTime()) - Date.parse(input.cycle.scheduledAt)) / DAY_MS,
72
+ ));
73
+ const nextCycleAt = new Date(Math.max(
74
+ Date.parse(input.subscription.nextCycleAt) || 0,
75
+ Date.parse(onTimeNextCycleAt) + shiftedByDays * DAY_MS,
76
+ )).toJSON();
77
+ if (!nextCycleAt) {
78
+ return failure("invalid_timestamp", "payment shift exceeds the supported timestamp range");
79
+ }
80
+ const paidAtIso = paidAt.toISOString();
81
+
82
+ const cycle: EngineCycle = {
83
+ ...input.cycle,
84
+ status: "paid",
85
+ paidAt: paidAtIso,
86
+ retryAttempt: input.cycle.retryAttempt,
87
+ nextRetryAt: null,
88
+ failureReason: null,
89
+ };
90
+ const subscription = touchSubscription(input.subscription, recordedAt.toISOString(), { nextCycleAt });
91
+
92
+ return success({
93
+ subscription,
94
+ cycle,
95
+ events: [
96
+ makeEvent({
97
+ eventType: "subscription.cycle_paid",
98
+ subscription,
99
+ cycleNumber: cycle.cycleNumber,
100
+ idempotencyKey: `${cycle.engineIdempotencyKey}:cycle_paid`,
101
+ occurredAt: paidAtIso,
102
+ payload: { paidAt: cycle.paidAt, nextCycleAt },
103
+ }),
104
+ makeEvent({
105
+ eventType: "subscription.next_cycle_at_updated",
106
+ subscription,
107
+ cycleNumber: cycle.cycleNumber,
108
+ idempotencyKey: `${cycle.engineIdempotencyKey}:next_cycle_at_updated`,
109
+ occurredAt: paidAtIso,
110
+ payload: {
111
+ nextCycleAt,
112
+ basedOnScheduledAt: cycle.scheduledAt,
113
+ basedOnPaidAt: cycle.paidAt,
114
+ shiftedByDays,
115
+ },
116
+ }),
117
+ ],
118
+ });
119
+ }
120
+
121
+ /** @beta */
122
+ export function recordPaymentFailure(input: {
123
+ cycle: EngineCycle;
124
+ failedAt: string;
125
+ reason: string;
126
+ failureClass?: string | null;
127
+ }): SubscriptionEngineResult<CycleMutationResult> {
128
+ const failedAt = parseIso(input.failedAt);
129
+ if (!failedAt) return failure("invalid_timestamp", "failedAt must be a valid ISO timestamp");
130
+ if (isTerminalCycle(input.cycle)) {
131
+ return failure("terminal_cycle", "terminal cycles cannot be retried");
132
+ }
133
+
134
+ const retryAttempt = input.cycle.retryAttempt + 1;
135
+ // The ladder owns its terminal decision.
136
+ const nextRetryAt = nextRetryAttemptAt(
137
+ failedAt.toISOString(),
138
+ retryAttempt,
139
+ undefined,
140
+ input.failureClass,
141
+ );
142
+ const status: SubscriptionCycleStatus = nextRetryAt ? "retry_scheduled" : "payment_failed";
143
+ const cycle: EngineCycle = {
144
+ ...input.cycle,
145
+ status,
146
+ retryAttempt,
147
+ nextRetryAt,
148
+ failureReason: input.reason,
149
+ };
150
+
151
+ const events: EngineEvent[] = [
152
+ {
153
+ eventType: "subscription.payment_failed",
154
+ subscriptionId: cycle.subscriptionId,
155
+ cycleNumber: cycle.cycleNumber,
156
+ idempotencyKey: `${cycle.engineIdempotencyKey}:payment_failed:${retryAttempt}`,
157
+ occurredAt: failedAt.toISOString(),
158
+ payload: { reason: input.reason, retryAttempt },
159
+ },
160
+ ];
161
+
162
+ if (nextRetryAt) {
163
+ events.push({
164
+ eventType: "subscription.retry_scheduled",
165
+ subscriptionId: cycle.subscriptionId,
166
+ cycleNumber: cycle.cycleNumber,
167
+ idempotencyKey: `${cycle.engineIdempotencyKey}:retry_scheduled:${retryAttempt}`,
168
+ occurredAt: failedAt.toISOString(),
169
+ payload: { nextRetryAt, retryAttempt },
170
+ });
171
+ }
172
+
173
+ return success({ cycle, events });
174
+ }