@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,263 @@
1
+ import type {
2
+ CanonicalPaymentControlEvent,
3
+ PaymentAttemptAggregate,
4
+ PaymentAttemptStatus,
5
+ PaymentControlDecision,
6
+ PaymentControlFailureReason,
7
+ PaymentControlResult,
8
+ PaymentIntentAggregate,
9
+ PaymentIntentStatus,
10
+ } from "./paymentControlTypes.js";
11
+
12
+ const intentTransitions: Record<PaymentIntentStatus, readonly PaymentIntentStatus[]> = {
13
+ created: ["requires_action", "processing", "failed", "expired", "cancelled"],
14
+ requires_action: ["processing", "succeeded", "failed", "expired", "cancelled"],
15
+ processing: ["requires_action", "succeeded", "failed", "expired", "cancelled"],
16
+ failed: ["requires_action", "processing", "succeeded", "cancelled"],
17
+ expired: ["requires_action", "processing", "succeeded", "cancelled"],
18
+ cancelled: [],
19
+ succeeded: ["refunded", "partially_refunded", "disputed"],
20
+ partially_refunded: ["refunded", "disputed"],
21
+ refunded: ["disputed"],
22
+ disputed: [],
23
+ };
24
+
25
+ const attemptTransitions: Record<PaymentAttemptStatus, readonly PaymentAttemptStatus[]> = {
26
+ created: ["blocked_preflight", "sent_to_provider", "requires_action", "processing", "failed", "cancelled", "expired"],
27
+ blocked_preflight: ["failed", "cancelled", "expired"],
28
+ sent_to_provider: ["requires_action", "processing", "succeeded", "failed", "cancelled", "expired"],
29
+ requires_action: ["processing", "succeeded", "failed", "cancelled", "expired"],
30
+ processing: ["requires_action", "succeeded", "failed", "cancelled", "expired"],
31
+ failed: ["succeeded"],
32
+ cancelled: ["succeeded"],
33
+ expired: ["succeeded"],
34
+ succeeded: [],
35
+ };
36
+
37
+ function success<T>(value: T): PaymentControlResult<T> {
38
+ return { ok: true, value };
39
+ }
40
+
41
+ function failure(
42
+ reason: PaymentControlFailureReason,
43
+ message: string,
44
+ ): PaymentControlResult<never> {
45
+ return { ok: false, reason, message };
46
+ }
47
+
48
+ /** @beta */
49
+ export function canTransitionPaymentIntent(
50
+ from: PaymentIntentStatus,
51
+ to: PaymentIntentStatus,
52
+ ): boolean {
53
+ return from === to || intentTransitions[from].includes(to);
54
+ }
55
+
56
+ /** @beta */
57
+ export function canTransitionPaymentAttempt(
58
+ from: PaymentAttemptStatus,
59
+ to: PaymentAttemptStatus,
60
+ ): boolean {
61
+ return from === to || attemptTransitions[from].includes(to);
62
+ }
63
+
64
+ /** @beta */
65
+ export function transitionPaymentIntent(input: {
66
+ intent: PaymentIntentAggregate;
67
+ to: PaymentIntentStatus;
68
+ at: string;
69
+ failureReason?: string | null;
70
+ }): PaymentControlResult<PaymentIntentAggregate> {
71
+ if (!canTransitionPaymentIntent(input.intent.status, input.to)) {
72
+ return failure("invalid_transition", `Cannot move payment intent from ${input.intent.status} to ${input.to}`);
73
+ }
74
+
75
+ return success({
76
+ ...input.intent,
77
+ status: input.to,
78
+ updatedAt: input.at,
79
+ failureReason: input.failureReason ?? (input.to === "failed" ? input.intent.failureReason ?? "provider_failed" : null),
80
+ });
81
+ }
82
+
83
+ /** @beta */
84
+ export function transitionPaymentAttempt(input: {
85
+ attempt: PaymentAttemptAggregate;
86
+ to: PaymentAttemptStatus;
87
+ at: string;
88
+ providerAttemptId?: string | null;
89
+ nextActionKind?: string | null;
90
+ failureReason?: string | null;
91
+ }): PaymentControlResult<PaymentAttemptAggregate> {
92
+ if (!canTransitionPaymentAttempt(input.attempt.status, input.to)) {
93
+ return failure("invalid_transition", `Cannot move payment attempt from ${input.attempt.status} to ${input.to}`);
94
+ }
95
+
96
+ return success({
97
+ ...input.attempt,
98
+ status: input.to,
99
+ updatedAt: input.at,
100
+ providerAttemptId: input.providerAttemptId ?? input.attempt.providerAttemptId,
101
+ nextActionKind: input.nextActionKind ?? input.attempt.nextActionKind ?? null,
102
+ failureReason: input.failureReason ?? (input.to === "failed" ? input.attempt.failureReason ?? "provider_failed" : null),
103
+ });
104
+ }
105
+
106
+ function assertEventTargetsActiveAttempt(input: {
107
+ intent: PaymentIntentAggregate;
108
+ attempt: PaymentAttemptAggregate;
109
+ }): PaymentControlResult<void> {
110
+ if (input.attempt.intentId !== input.intent.id || input.intent.activeAttemptId !== input.attempt.id) {
111
+ return failure("wrong_attempt", "Provider event must target the active local payment attempt");
112
+ }
113
+ return success(undefined);
114
+ }
115
+
116
+ function assertMoneyMatches(input: {
117
+ intent: PaymentIntentAggregate;
118
+ event: CanonicalPaymentControlEvent;
119
+ exactAmount: boolean;
120
+ }): PaymentControlResult<void> {
121
+ if (input.event.currency && input.event.currency !== input.intent.currency) {
122
+ return failure("currency_mismatch", "Provider event currency does not match the local payment intent");
123
+ }
124
+
125
+ if (input.event.amountMinor === undefined) return success(undefined);
126
+ if (input.exactAmount && input.event.amountMinor !== input.intent.amountMinor) {
127
+ return failure("amount_mismatch", "Provider event amount does not match the local payment intent");
128
+ }
129
+ if (!input.exactAmount && (input.event.amountMinor < 1 || input.event.amountMinor > input.intent.amountMinor)) {
130
+ return failure("amount_mismatch", "Refund amount must be positive and not exceed the original payment amount");
131
+ }
132
+
133
+ return success(undefined);
134
+ }
135
+
136
+ function buildDecision(input: {
137
+ kind: PaymentControlDecision["kind"];
138
+ reason: string;
139
+ intent: PaymentIntentAggregate;
140
+ attempt: PaymentAttemptAggregate;
141
+ }): PaymentControlResult<PaymentControlDecision> {
142
+ return success(input);
143
+ }
144
+
145
+ /** @beta */
146
+ export function applyProviderPaymentEvent(input: {
147
+ intent: PaymentIntentAggregate;
148
+ attempt: PaymentAttemptAggregate;
149
+ event: CanonicalPaymentControlEvent;
150
+ seenProviderEventIds?: readonly string[];
151
+ }): PaymentControlResult<PaymentControlDecision> {
152
+ if (input.seenProviderEventIds?.includes(input.event.providerEventId)) {
153
+ return buildDecision({ kind: "noop", reason: "duplicate_provider_event", ...input });
154
+ }
155
+
156
+ const targetCheck = assertEventTargetsActiveAttempt(input);
157
+ if (targetCheck.ok === false) return failure(targetCheck.reason, targetCheck.message);
158
+
159
+ if (input.event.eventType === "payment.refunded") return applyRefundEvent(input);
160
+ if (input.event.eventType === "payment.disputed") return applyDisputeEvent(input);
161
+
162
+ const moneyCheck = assertMoneyMatches({ intent: input.intent, event: input.event, exactAmount: true });
163
+ if (moneyCheck.ok === false) return failure(moneyCheck.reason, moneyCheck.message);
164
+
165
+ if (input.event.eventType === "payment.succeeded") return applySuccessEvent(input);
166
+ if (input.event.eventType === "payment.failed") return applyFailureEvent(input);
167
+ return applyRequiresActionEvent(input);
168
+ }
169
+
170
+ function applyRequiresActionEvent(input: {
171
+ intent: PaymentIntentAggregate;
172
+ attempt: PaymentAttemptAggregate;
173
+ event: CanonicalPaymentControlEvent;
174
+ }): PaymentControlResult<PaymentControlDecision> {
175
+ const intent = transitionPaymentIntent({ intent: input.intent, to: "requires_action", at: input.event.occurredAt });
176
+ const attempt = transitionPaymentAttempt({
177
+ attempt: input.attempt,
178
+ to: "requires_action",
179
+ at: input.event.occurredAt,
180
+ providerAttemptId: input.event.providerPaymentId,
181
+ nextActionKind: input.event.nextActionKind ?? null,
182
+ });
183
+ if (intent.ok === false) return failure(intent.reason, intent.message);
184
+ if (attempt.ok === false) return failure(attempt.reason, attempt.message);
185
+ return buildDecision({ kind: "state_changed", reason: "provider_requires_action", intent: intent.value, attempt: attempt.value });
186
+ }
187
+
188
+ function applySuccessEvent(input: {
189
+ intent: PaymentIntentAggregate;
190
+ attempt: PaymentAttemptAggregate;
191
+ event: CanonicalPaymentControlEvent;
192
+ }): PaymentControlResult<PaymentControlDecision> {
193
+ if (["cancelled", "refunded", "partially_refunded", "disputed"].includes(input.intent.status)) {
194
+ return failure("terminal_intent", `Cannot apply provider success after intent status ${input.intent.status}`);
195
+ }
196
+ if (input.intent.status === "succeeded" && input.attempt.status === "succeeded") {
197
+ return buildDecision({ kind: "noop", reason: "already_succeeded", ...input });
198
+ }
199
+
200
+ const recovered = input.intent.status === "failed" || input.intent.status === "expired";
201
+ const intent = transitionPaymentIntent({ intent: input.intent, to: "succeeded", at: input.event.occurredAt });
202
+ const attempt = transitionPaymentAttempt({
203
+ attempt: input.attempt,
204
+ to: "succeeded",
205
+ at: input.event.occurredAt,
206
+ providerAttemptId: input.event.providerPaymentId,
207
+ failureReason: null,
208
+ });
209
+ if (intent.ok === false) return failure(intent.reason, intent.message);
210
+ if (attempt.ok === false) return failure(attempt.reason, attempt.message);
211
+ return buildDecision({
212
+ kind: recovered ? "late_success_recovered" : "state_changed",
213
+ reason: recovered ? "late_success_after_failed_or_expired" : "provider_succeeded",
214
+ intent: { ...intent.value, providerPaymentId: input.event.providerPaymentId },
215
+ attempt: attempt.value,
216
+ });
217
+ }
218
+
219
+ function applyFailureEvent(input: {
220
+ intent: PaymentIntentAggregate;
221
+ attempt: PaymentAttemptAggregate;
222
+ event: CanonicalPaymentControlEvent;
223
+ }): PaymentControlResult<PaymentControlDecision> {
224
+ if (["succeeded", "refunded", "partially_refunded", "disputed"].includes(input.intent.status)) {
225
+ return buildDecision({ kind: "noop", reason: "ignored_failure_after_success_or_terminal", ...input });
226
+ }
227
+
228
+ const reason = input.event.failureReason ?? "provider_failed";
229
+ const intent = transitionPaymentIntent({ intent: input.intent, to: "failed", at: input.event.occurredAt, failureReason: reason });
230
+ const attempt = transitionPaymentAttempt({ attempt: input.attempt, to: "failed", at: input.event.occurredAt, failureReason: reason });
231
+ if (intent.ok === false) return failure(intent.reason, intent.message);
232
+ if (attempt.ok === false) return failure(attempt.reason, attempt.message);
233
+ return buildDecision({ kind: "state_changed", reason: "provider_failed", intent: intent.value, attempt: attempt.value });
234
+ }
235
+
236
+ function applyRefundEvent(input: {
237
+ intent: PaymentIntentAggregate;
238
+ attempt: PaymentAttemptAggregate;
239
+ event: CanonicalPaymentControlEvent;
240
+ }): PaymentControlResult<PaymentControlDecision> {
241
+ if (!["succeeded", "partially_refunded", "refunded"].includes(input.intent.status)) {
242
+ return failure("refund_requires_success", "Refund event requires a previously succeeded local payment intent");
243
+ }
244
+ const moneyCheck = assertMoneyMatches({ intent: input.intent, event: input.event, exactAmount: false });
245
+ if (moneyCheck.ok === false) return failure(moneyCheck.reason, moneyCheck.message);
246
+ const status = input.event.amountMinor === input.intent.amountMinor ? "refunded" : "partially_refunded";
247
+ const intent = transitionPaymentIntent({ intent: input.intent, to: status, at: input.event.occurredAt });
248
+ if (intent.ok === false) return failure(intent.reason, intent.message);
249
+ return buildDecision({ kind: "state_changed", reason: "provider_refunded", intent: intent.value, attempt: input.attempt });
250
+ }
251
+
252
+ function applyDisputeEvent(input: {
253
+ intent: PaymentIntentAggregate;
254
+ attempt: PaymentAttemptAggregate;
255
+ event: CanonicalPaymentControlEvent;
256
+ }): PaymentControlResult<PaymentControlDecision> {
257
+ if (input.intent.status === "cancelled") {
258
+ return failure("terminal_intent", "Cannot dispute a cancelled local payment intent");
259
+ }
260
+ const intent = transitionPaymentIntent({ intent: input.intent, to: "disputed", at: input.event.occurredAt });
261
+ if (intent.ok === false) return failure(intent.reason, intent.message);
262
+ return buildDecision({ kind: "state_changed", reason: "provider_disputed", intent: intent.value, attempt: input.attempt });
263
+ }
@@ -0,0 +1,57 @@
1
+ /** @beta */
2
+ export interface ProviderAttemptIdentityInput {
3
+ namespace: string;
4
+ provider: string;
5
+ localExecutionIdempotencyKey: string;
6
+ paymentIntentId: string;
7
+ amountMinor: number;
8
+ currency: string;
9
+ mode: "one_time" | "subscription_cycle";
10
+ orderRef: string;
11
+ }
12
+
13
+ /** @beta */
14
+ export interface ProviderAttemptIdentity {
15
+ providerIdempotencyKey: string;
16
+ providerRequestFingerprint: string;
17
+ }
18
+
19
+ /** @beta */
20
+ export function buildProviderAttemptIdentity(
21
+ input: ProviderAttemptIdentityInput,
22
+ ): ProviderAttemptIdentity {
23
+ const normalizedNamespace = normalizeSegment(input.namespace);
24
+ const normalizedProvider = normalizeSegment(input.provider);
25
+ const normalizedLocalKey = normalizeSegment(input.localExecutionIdempotencyKey);
26
+ const normalizedCurrency = input.currency.trim().toUpperCase();
27
+ const providerIdempotencyKey = [
28
+ normalizedNamespace,
29
+ normalizedProvider,
30
+ input.paymentIntentId,
31
+ normalizedLocalKey,
32
+ ].join(":");
33
+
34
+ return {
35
+ providerIdempotencyKey,
36
+ providerRequestFingerprint: [
37
+ normalizedProvider,
38
+ input.paymentIntentId,
39
+ input.amountMinor.toString(),
40
+ normalizedCurrency,
41
+ input.mode,
42
+ input.orderRef,
43
+ ].join("|"),
44
+ };
45
+ }
46
+
47
+ /** @beta */
48
+ export function isProviderAttemptReplaySafe(input: {
49
+ existingFingerprint: string;
50
+ nextFingerprint: string;
51
+ }): boolean {
52
+ return input.existingFingerprint === input.nextFingerprint;
53
+ }
54
+
55
+ function normalizeSegment(value: string): string {
56
+ return value.trim().toLowerCase().replace(/[^a-z0-9_.:-]+/g, "_");
57
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Payment provider capability contracts.
3
+ *
4
+ * Renewal and dunning orchestration must decide what it can do with a stored
5
+ * consent by reading CAPABILITIES, never by comparing a provider identity
6
+ * string. This module is the neutral vocabulary those decisions are expressed
7
+ * in; the values live in each adapter, so nothing here names a provider.
8
+ *
9
+ * Every field exists because a live branch consumes it. Capabilities without a
10
+ * consumer (capture flows, status reads, limits) are deliberately absent and
11
+ * arrive with the code that needs them.
12
+ */
13
+
14
+ /**
15
+ * Why a stored consent cannot back an unattended (off-session) charge.
16
+ *
17
+ * Neutral by construction: a provider-native refusal code never reaches a
18
+ * caller through this contract, so the orchestrator can route a blocked
19
+ * renewal into customer repair without learning provider vocabulary.
20
+ */
21
+ export type UnattendedChargeBlockReason =
22
+ // The consent was registered under a model that cannot be charged
23
+ // unattended — including the case where no model was recorded at all, which
24
+ // fails closed rather than guessing one.
25
+ "mandate_model_unsupported_for_unattended_charge";
26
+
27
+ /**
28
+ * The stored consent evidence a capability judges, as read from local records
29
+ * rather than from a provider call. Optional everywhere: a provider whose
30
+ * consent carries no model simply ignores the field.
31
+ */
32
+ export interface StoredMandateSnapshot {
33
+ /** Recurring/mandate model recorded when the payer gave consent. */
34
+ recurringModel?: string | null;
35
+ }
36
+
37
+ /** The verdict on whether a stored consent may be charged unattended. */
38
+ export interface UnattendedMandateAssessment {
39
+ chargeable: boolean;
40
+ blockReason?: UnattendedChargeBlockReason;
41
+ }
42
+
43
+ /**
44
+ * What an unattended charge request must carry about the payer.
45
+ *
46
+ * Models the two payer-shaped branches on the renewal path: whether the
47
+ * request needs a payer block at all, and whether the customer reference may
48
+ * fall back to the stored contact email when no provider customer exists.
49
+ */
50
+ export interface PayerContextRequirements {
51
+ requiresPayerBlock: boolean;
52
+ customerRefFallsBackToContactEmail: boolean;
53
+ }
54
+
55
+ /**
56
+ * What the stored-method health check must see before a charge is attempted.
57
+ *
58
+ * Models the per-provider preconditions the payment-method lifecycle applies
59
+ * to a stored method; the lifecycle keeps owning the reason vocabulary it
60
+ * reports, this contract owns only the expectations.
61
+ */
62
+ export interface PaymentMethodHealthExpectations {
63
+ /** A provider-side customer reference is mandatory for a charge. */
64
+ requiresCustomerRef: boolean;
65
+ /** The stored method must be of exactly this kind, or null when any kind charges. */
66
+ requiredMethodKind: string | null;
67
+ /** A stored payer contact (email) is mandatory for a charge. */
68
+ requiresPayerContact: boolean;
69
+ }
70
+
71
+ /**
72
+ * How a customer-present client must drive one method-capture flow.
73
+ *
74
+ * Closed on purpose: a client can only offer a flow it knows how to drive, so a
75
+ * provider that invents a handoff no client implements must widen this union
76
+ * together with the surface that renders it, rather than have a repair page
77
+ * present a control that leads nowhere.
78
+ */
79
+ export type PaymentMethodCaptureHandoff =
80
+ // The client mounts provider-supplied fields bound to a per-attempt secret,
81
+ // or the payer types a short-lived authorisation code into the client's own
82
+ // form. Line comments on purpose: a member-level doc comment makes the
83
+ // extractor emit a trailing-whitespace alias line the diff guard refuses.
84
+ "embedded_client_secret" | "payer_supplied_code";
85
+
86
+ /**
87
+ * One way a payer can hand this provider a reusable payment consent while
88
+ * present at the keyboard — the repair path out of a failed unattended charge.
89
+ *
90
+ * `kind` is neutral vocabulary the renderer may label and key on; it is never a
91
+ * provider identity. A declared flow is an offer of capability, not a promise
92
+ * that any given client implements it: the client filters by `handoff`.
93
+ */
94
+ export interface PaymentMethodCaptureFlow {
95
+ kind: string;
96
+ handoff: PaymentMethodCaptureHandoff;
97
+ }
98
+
99
+ /**
100
+ * Where a terminal outcome becomes visible on a rail, and how long the absence
101
+ * of one is still normal.
102
+ *
103
+ * Reconciliation cannot tell "still running" from "stuck" by polling alone: a
104
+ * rail that answers non-terminally forever looks identical to a healthy slow
105
+ * one. Declaring the shape makes silence measurable per rail instead of
106
+ * guessed at globally, and makes the question something a new adapter must
107
+ * answer up front rather than discover through an incident.
108
+ */
109
+ export interface TerminalOutcomeReporting {
110
+ /**
111
+ * Where the outcome lands. Line comments on purpose: a member-level doc
112
+ * comment makes the extractor emit a trailing-whitespace alias line the diff
113
+ * guard refuses.
114
+ */
115
+ // "status_field" - the readback's own status turns terminal.
116
+ // "attempt_records" - the status stays non-terminal and the outcome is
117
+ // reported per attempt, so reading only the status never terminalizes.
118
+ // "inbound_event" - only a pushed event carries the outcome, so a lost event
119
+ // means the readback never turns terminal at all.
120
+ kind: "status_field" | "attempt_records" | "inbound_event";
121
+ /**
122
+ * Minutes of non-terminal answers after which this rail is behaving
123
+ * abnormally and the attempt deserves a human, not another poll. This is a
124
+ * suspicion threshold, never a verdict: elapsed time is not evidence about
125
+ * money, so nothing may terminalize a charge from this value alone.
126
+ */
127
+ silenceBecomesSuspectAfterMinutes: number;
128
+ }
129
+
130
+ /**
131
+ * One provider's unattended-charge capabilities, published by its adapter.
132
+ *
133
+ * `providerKind` is the registry key only. Consumers must read the capability
134
+ * fields; branching on this value re-creates the coupling the contract exists
135
+ * to remove.
136
+ */
137
+ export interface PaymentProviderCapabilityDescriptor {
138
+ providerKind: string;
139
+ /**
140
+ * Every customer-present capture flow this provider can complete. Empty means
141
+ * a payer cannot repair this rail's consent without leaving the product.
142
+ */
143
+ captureFlows: readonly PaymentMethodCaptureFlow[];
144
+ /**
145
+ * The stored consent evidence must be loaded from local records before a
146
+ * charge is prepared. False means `assessMandate` decides without it, and the
147
+ * caller must not pay for the read.
148
+ */
149
+ requiresStoredMandateEvidence: boolean;
150
+ /** Judges stored consent evidence before any durable provider attempt is created. */
151
+ assessMandate(snapshot: StoredMandateSnapshot): UnattendedMandateAssessment;
152
+ /** The provider flow an unattended renewal charge must be dispatched under. */
153
+ unattendedChargeFlow: string;
154
+ payerContext: PayerContextRequirements;
155
+ methodHealth: PaymentMethodHealthExpectations;
156
+ /** How this rail reports a terminal outcome, and when its silence stops being normal. */
157
+ terminalOutcomeReporting: TerminalOutcomeReporting;
158
+ /**
159
+ * The stored consent belongs to one subscription rather than to the payer
160
+ * account, so persisting it from a webhook must use the subscription-scoped
161
+ * guarded write.
162
+ */
163
+ mandateUpsertIsSubscriptionScoped: boolean;
164
+ }
165
+
166
+ /** Lookup of published capabilities, keyed by provider kind. */
167
+ export interface PaymentProviderCapabilityRegistry {
168
+ /** The descriptor published for this kind, or null when the kind is unknown. */
169
+ get(providerKind: string): PaymentProviderCapabilityDescriptor | null;
170
+ /** Every published kind, for inventory and conformance proofs. */
171
+ kinds(): readonly string[];
172
+ }
@@ -0,0 +1,33 @@
1
+ // Platform-runtime shared contracts.
2
+ // Provider-neutral types only: no I/O, no concrete host/provider presets. Host
3
+ // composition and bundle catalogs stay downstream.
4
+
5
+ /** @beta */
6
+ export type PlatformBundleId = string;
7
+
8
+ /** Capability surfaces a runtime bundle can bind. */
9
+ /** @beta */
10
+ export type PlatformCapability =
11
+ | "http"
12
+ | "scheduler"
13
+ | "blob"
14
+ | "data"
15
+ | "migrations"
16
+ | "analytics"
17
+ | "transactional";
18
+
19
+ /** @beta */
20
+ export type PlatformBundleIdGuard<TId extends string = PlatformBundleId> = (value: unknown) => value is TId;
21
+
22
+ /** @beta */
23
+ export function createPlatformBundleIdGuard<const TIds extends readonly string[]>(
24
+ ids: TIds,
25
+ ): PlatformBundleIdGuard<TIds[number]> {
26
+ for (const id of ids) {
27
+ if (!id || id.trim() !== id) {
28
+ throw new Error("Platform bundle ids must be non-empty and trimmed");
29
+ }
30
+ }
31
+ const allowed = new Set<string>(ids);
32
+ return (value: unknown): value is TIds[number] => typeof value === "string" && allowed.has(value);
33
+ }
@@ -0,0 +1,36 @@
1
+ /** @beta */
2
+ export {
3
+ createPlatformBundleIdGuard,
4
+ } from "./contracts.js";
5
+ /** @beta */
6
+ export type {
7
+ PlatformBundleId,
8
+ PlatformBundleIdGuard,
9
+ PlatformCapability,
10
+ } from "./contracts.js";
11
+ /** @beta */
12
+ export {
13
+ createPlatformBundleRegistry,
14
+ platformEnvSchema,
15
+ } from "./platformKernel.js";
16
+ /** @beta */
17
+ export type {
18
+ CreatePlatformBundleRegistryInput,
19
+ PlatformBundleDescriptor,
20
+ PlatformBundleRegistry,
21
+ PlatformBundleReadiness,
22
+ PlatformEnv,
23
+ PlatformEnvInput,
24
+ } from "./platformKernel.js";
25
+ /** @beta */
26
+ export type {
27
+ AnalyticsPort,
28
+ BlobStoragePort,
29
+ DataGatewayPort,
30
+ HttpRuntimePort,
31
+ MigrationRunnerPort,
32
+ PlatformHttpRequest,
33
+ PlatformHttpResponse,
34
+ SchedulerPort,
35
+ TransactionalRuntimePort,
36
+ } from "./ports.js";
@@ -0,0 +1,86 @@
1
+ import { z } from "zod";
2
+
3
+ import { createPlatformBundleIdGuard, type PlatformBundleId, type PlatformCapability } from "./contracts.js";
4
+
5
+ /** @beta */
6
+ export interface PlatformBundleReadiness {
7
+ ok: boolean;
8
+ error?: string;
9
+ }
10
+
11
+ /** @beta */
12
+ export interface PlatformBundleDescriptor {
13
+ id: PlatformBundleId;
14
+ /** Declarative capability provider kind, bound by downstream composition. */
15
+ capabilities: Record<PlatformCapability, string>;
16
+ readReadiness(env: PlatformEnvInput): PlatformBundleReadiness;
17
+ }
18
+
19
+ /** @beta */
20
+ export type PlatformEnvInput = Record<string, string | undefined>;
21
+
22
+ /** @beta */
23
+ export const platformEnvSchema = z.object({
24
+ PLATFORM_BUNDLE: z.string().trim().min(1).optional(),
25
+ APP_BASE_URL: z.string().url().optional(),
26
+ });
27
+
28
+ /** @beta */
29
+ export type PlatformEnv = z.infer<typeof platformEnvSchema>;
30
+
31
+ /** @beta */
32
+ export interface PlatformBundleRegistry<TId extends string = PlatformBundleId> {
33
+ readonly defaultBundleId: TId;
34
+ readonly bundleIds: readonly TId[];
35
+ readonly descriptors: readonly PlatformBundleDescriptor[];
36
+ isBundleId(value: unknown): value is TId;
37
+ resolveBundleId(env: PlatformEnvInput): TId;
38
+ getBundleDescriptor(id: TId): PlatformBundleDescriptor;
39
+ }
40
+
41
+ /** @beta */
42
+ export interface CreatePlatformBundleRegistryInput<TId extends string = PlatformBundleId> {
43
+ descriptors: readonly PlatformBundleDescriptor[];
44
+ defaultBundleId: TId;
45
+ }
46
+
47
+ /** @beta */
48
+ export function createPlatformBundleRegistry<const TIds extends readonly string[]>(
49
+ input: {
50
+ descriptors: readonly (PlatformBundleDescriptor & { id: TIds[number] })[];
51
+ defaultBundleId: TIds[number];
52
+ },
53
+ ): PlatformBundleRegistry<TIds[number]> {
54
+ const bundleIds = input.descriptors.map((descriptor) => descriptor.id) as unknown as TIds;
55
+ const isBundleId = createPlatformBundleIdGuard(bundleIds);
56
+ const descriptorsById = new Map<TIds[number], PlatformBundleDescriptor>();
57
+
58
+ for (const descriptor of input.descriptors) {
59
+ if (descriptorsById.has(descriptor.id)) {
60
+ throw new Error(`Duplicate platform bundle descriptor: ${descriptor.id}`);
61
+ }
62
+ descriptorsById.set(descriptor.id, descriptor);
63
+ }
64
+
65
+ if (!descriptorsById.has(input.defaultBundleId)) {
66
+ throw new Error(`Default platform bundle is not declared: ${input.defaultBundleId}`);
67
+ }
68
+
69
+ return {
70
+ defaultBundleId: input.defaultBundleId,
71
+ bundleIds,
72
+ descriptors: input.descriptors,
73
+ isBundleId,
74
+ resolveBundleId(env) {
75
+ const raw = env.PLATFORM_BUNDLE;
76
+ return isBundleId(raw) ? raw : input.defaultBundleId;
77
+ },
78
+ getBundleDescriptor(id) {
79
+ const descriptor = descriptorsById.get(id);
80
+ if (!descriptor) {
81
+ throw new Error(`Unknown platform bundle descriptor: ${id}`);
82
+ }
83
+ return descriptor;
84
+ },
85
+ };
86
+ }