@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,128 @@
1
+ import type { RiskSeverity } from "../types.js";
2
+ import type {
3
+ RiskEvaluationInput,
4
+ RiskRuleMatch,
5
+ } from "./types.js";
6
+
7
+ const SEVERITY_WEIGHT: Record<RiskSeverity, number> = {
8
+ low: 1,
9
+ medium: 2,
10
+ high: 3,
11
+ critical: 4,
12
+ };
13
+
14
+ /** @beta */
15
+ export function collectRiskRuleMatches(input: RiskEvaluationInput): RiskRuleMatch[] {
16
+ const matches: RiskRuleMatch[] = [];
17
+ const add = (match: RiskRuleMatch) => matches.push(match);
18
+ const { checkout, payment, signals } = input;
19
+
20
+ if (signals.exactBlocklistMatches.length > 0) {
21
+ add({
22
+ code: "exact_blocklist_match",
23
+ score: 100,
24
+ severity: "critical",
25
+ evidence: { matchCount: signals.exactBlocklistMatches.length },
26
+ });
27
+ }
28
+ if (signals.recentCheckoutAttemptsByIdentity >= 3) {
29
+ add({
30
+ code: "velocity_checkout_attempts",
31
+ score: 35,
32
+ severity: "medium",
33
+ evidence: { attempts: signals.recentCheckoutAttemptsByIdentity, window: "1h" },
34
+ });
35
+ }
36
+ if (signals.distinctEmailsByIp24h >= 3) {
37
+ add({
38
+ code: "velocity_email_cluster",
39
+ score: 45,
40
+ severity: "high",
41
+ evidence: { distinctEmails: signals.distinctEmailsByIp24h, window: "24h" },
42
+ });
43
+ }
44
+ if (signals.discountedPaidOrders30d >= 2) {
45
+ add({
46
+ code: "velocity_discount_reuse",
47
+ score: 40,
48
+ severity: "medium",
49
+ evidence: { discountedPaidOrders: signals.discountedPaidOrders30d, window: "30d" },
50
+ });
51
+ }
52
+ if (signals.clientsSharingPaymentMethod7d >= 2) {
53
+ add({
54
+ code: "duplicate_payment_method",
55
+ score: 50,
56
+ severity: "high",
57
+ evidence: { clients: signals.clientsSharingPaymentMethod7d, window: "7d" },
58
+ });
59
+ }
60
+ if (signals.priorDisputes > 0) {
61
+ add({
62
+ code: "prior_dispute",
63
+ score: 80,
64
+ severity: "critical",
65
+ evidence: { disputes: signals.priorDisputes },
66
+ });
67
+ }
68
+ if (checkout?.checkoutKind === "subscription_initial" && checkout.promoCodeCount > 0 && signals.discountedPaidOrders30d > 0) {
69
+ add({
70
+ code: "subscription_promo_reuse",
71
+ score: 45,
72
+ severity: "high",
73
+ evidence: { promoCodeCount: checkout.promoCodeCount, priorDiscounts: signals.discountedPaidOrders30d },
74
+ });
75
+ }
76
+ if (checkout?.bundleDiscountApplied && signals.discountedPaidOrders30d > 0) {
77
+ add({
78
+ code: "bundle_discount_review",
79
+ score: 30,
80
+ severity: "medium",
81
+ evidence: { priorDiscounts: signals.discountedPaidOrders30d },
82
+ });
83
+ }
84
+ if (payment?.avsResult === "mismatch") {
85
+ add({ code: "avs_mismatch", score: 25, severity: "medium", evidence: { avsResult: "mismatch" } });
86
+ }
87
+ if (payment?.cvcResult === "mismatch") {
88
+ add({ code: "cvc_mismatch", score: 35, severity: "high", evidence: { cvcResult: "mismatch" } });
89
+ }
90
+ if (checkout && hasCountryMismatch(checkout.shippingCountry, checkout.billingCountry, checkout.ipCountry)) {
91
+ add({
92
+ code: "country_signal_mismatch",
93
+ score: 30,
94
+ severity: "medium",
95
+ evidence: {
96
+ shippingCountry: checkout.shippingCountry ?? null,
97
+ billingCountry: checkout.billingCountry ?? null,
98
+ ipCountry: checkout.ipCountry ?? null,
99
+ },
100
+ });
101
+ }
102
+ if (checkout && signals.highRiskCountries.some((country) => [checkout.shippingCountry, checkout.billingCountry, checkout.ipCountry].includes(country))) {
103
+ add({ code: "high_risk_country", score: 70, severity: "critical", evidence: { highRiskCountries: signals.highRiskCountries } });
104
+ }
105
+ if (payment?.localAmountMinor != null && payment.providerAmountMinor != null && payment.localAmountMinor !== payment.providerAmountMinor) {
106
+ add({
107
+ code: "payment_amount_mismatch",
108
+ score: 80,
109
+ severity: "critical",
110
+ evidence: { localAmountMinor: payment.localAmountMinor, providerAmountMinor: payment.providerAmountMinor },
111
+ });
112
+ }
113
+ if (payment?.localCurrency && payment.providerCurrency && payment.localCurrency !== payment.providerCurrency) {
114
+ add({
115
+ code: "payment_currency_mismatch",
116
+ score: 80,
117
+ severity: "critical",
118
+ evidence: { localCurrency: payment.localCurrency, providerCurrency: payment.providerCurrency },
119
+ });
120
+ }
121
+
122
+ return matches.sort((a, b) => b.score - a.score || SEVERITY_WEIGHT[b.severity] - SEVERITY_WEIGHT[a.severity] || a.code.localeCompare(b.code));
123
+ }
124
+
125
+ function hasCountryMismatch(...countries: Array<string | null | undefined>): boolean {
126
+ const present = countries.filter((country): country is string => Boolean(country));
127
+ return new Set(present).size > 1;
128
+ }
@@ -0,0 +1,73 @@
1
+ import type {
2
+ RiskDecision,
3
+ RiskEnforcementMode,
4
+ RiskReasonCode,
5
+ RiskSeverity,
6
+ } from "../types.js";
7
+
8
+ /** @beta */
9
+ export interface RiskCheckoutFacts {
10
+ checkoutKind: "one_time" | "subscription_initial";
11
+ totalMinor: number;
12
+ currency: string;
13
+ promoCodeCount: number;
14
+ bundleDiscountApplied: boolean;
15
+ shippingCountry?: string | null;
16
+ billingCountry?: string | null;
17
+ ipCountry?: string | null;
18
+ saveForFutureUse?: boolean;
19
+ }
20
+
21
+ /** @beta */
22
+ export interface RiskPaymentFacts {
23
+ resultStatus?: "succeeded" | "failed" | "refunded" | "partially_refunded" | "disputed" | null;
24
+ localAmountMinor?: number | null;
25
+ providerAmountMinor?: number | null;
26
+ localCurrency?: string | null;
27
+ providerCurrency?: string | null;
28
+ avsResult?: "match" | "mismatch" | "unavailable" | null;
29
+ cvcResult?: "match" | "mismatch" | "unavailable" | null;
30
+ }
31
+
32
+ /** @beta */
33
+ export interface RiskSignalFacts {
34
+ exactBlocklistMatches: Array<{ subjectKind: string; reasonCode?: string | null }>;
35
+ recentCheckoutAttemptsByIdentity: number;
36
+ distinctEmailsByIp24h: number;
37
+ discountedPaidOrders30d: number;
38
+ clientsSharingPaymentMethod7d: number;
39
+ priorDisputes: number;
40
+ highRiskCountries: string[];
41
+ }
42
+
43
+ /** @beta */
44
+ export interface RiskEvaluationInput {
45
+ source: "checkout" | "paid_order" | "payment_webhook" | "admin_replay";
46
+ mode: RiskEnforcementMode;
47
+ checkout?: RiskCheckoutFacts;
48
+ payment?: RiskPaymentFacts;
49
+ signals: RiskSignalFacts;
50
+ }
51
+
52
+ /** @beta */
53
+ export interface RiskRuleMatch {
54
+ code: RiskReasonCode;
55
+ score: number;
56
+ severity: RiskSeverity;
57
+ evidence: Record<string, unknown>;
58
+ }
59
+
60
+ /** @beta */
61
+ export interface RiskEvaluationOutput {
62
+ decision: RiskDecision;
63
+ score: number;
64
+ severity: RiskSeverity;
65
+ reasonCodes: RiskReasonCode[];
66
+ matchedRules: RiskRuleMatch[];
67
+ enforcement: {
68
+ mode: RiskEnforcementMode;
69
+ applied: boolean;
70
+ checkoutBlocked: boolean;
71
+ holdRequested: boolean;
72
+ };
73
+ }
@@ -0,0 +1,8 @@
1
+ /** @beta */
2
+ export * from "./types.js";
3
+ /** @beta */
4
+ export * from "./core/types.js";
5
+ /** @beta */
6
+ export * from "./core/rules.js";
7
+ /** @beta */
8
+ export * from "./core/evaluator.js";
@@ -0,0 +1,60 @@
1
+ /** @beta */
2
+ export const RISK_CONTRACT_VERSION = "risk.v1" as const;
3
+ /** @beta */
4
+ export const RISK_RULESET_VERSION = "risk-rules.v1" as const;
5
+
6
+ /** @beta */
7
+ export const RISK_DECISIONS = ["allow", "manual_review", "block"] as const;
8
+ /** @beta */
9
+ export const RISK_ENFORCEMENT_MODES = ["shadow", "hold", "checkout_blocklist"] as const;
10
+ /** @beta */
11
+ export const RISK_CASE_STATUSES = ["open", "in_review", "approved", "blocked", "escalated", "closed"] as const;
12
+ /** @beta */
13
+ export const RISK_CASE_DECISIONS = ["approve", "block", "escalate", "note"] as const;
14
+ /** @beta */
15
+ export const RISK_SEVERITIES = ["low", "medium", "high", "critical"] as const;
16
+ /** @beta */
17
+ export const RISK_SUBJECT_KINDS = [
18
+ "client",
19
+ "email",
20
+ "phone",
21
+ "ip",
22
+ "device",
23
+ "payment_method",
24
+ "shipping_address",
25
+ "billing_tax_id",
26
+ "identity_cluster",
27
+ ] as const;
28
+
29
+ /** @beta */
30
+ export const RISK_REASON_CODES = [
31
+ "exact_blocklist_match",
32
+ "velocity_checkout_attempts",
33
+ "velocity_email_cluster",
34
+ "velocity_discount_reuse",
35
+ "duplicate_payment_method",
36
+ "subscription_promo_reuse",
37
+ "bundle_discount_review",
38
+ "avs_mismatch",
39
+ "cvc_mismatch",
40
+ "country_signal_mismatch",
41
+ "high_risk_country",
42
+ "payment_amount_mismatch",
43
+ "payment_currency_mismatch",
44
+ "prior_dispute",
45
+ ] as const;
46
+
47
+ /** @beta */
48
+ export type RiskDecision = (typeof RISK_DECISIONS)[number];
49
+ /** @beta */
50
+ export type RiskEnforcementMode = (typeof RISK_ENFORCEMENT_MODES)[number];
51
+ /** @beta */
52
+ export type RiskCaseStatus = (typeof RISK_CASE_STATUSES)[number];
53
+ /** @beta */
54
+ export type RiskCaseDecision = (typeof RISK_CASE_DECISIONS)[number];
55
+ /** @beta */
56
+ export type RiskSeverity = (typeof RISK_SEVERITIES)[number];
57
+ /** @beta */
58
+ export type RiskSubjectKind = (typeof RISK_SUBJECT_KINDS)[number];
59
+ /** @beta */
60
+ export type RiskReasonCode = (typeof RISK_REASON_CODES)[number];
@@ -0,0 +1,4 @@
1
+ /** @beta */
2
+ export * from "./shippingResolver.js";
3
+ /** @beta */
4
+ export * from "./types.js";
@@ -0,0 +1,101 @@
1
+ import type {
2
+ ResolvedShipping,
3
+ ShippingResolverCart,
4
+ ShippingRule,
5
+ } from "./types.js";
6
+
7
+ /**
8
+ * Pure shipping resolver. It picks the most specific active shipping rule for a
9
+ * cart's region, currency, and cart mode, then computes the shipping amount and
10
+ * VAT-rate snapshot. Provider selection, pickup points, labels, and fulfillment
11
+ * side effects stay outside this package.
12
+ *
13
+ * Resolution order:
14
+ * 1. Filter by active + region + currency + validity window.
15
+ * 2. Prefer an exact cart-mode rule over an `any` fallback.
16
+ * 3. Within the same mode bucket, lower priority wins.
17
+ * 4. Return null when no rule matches; the host application owns fallback UI or
18
+ * operational errors.
19
+ *
20
+ * Amount:
21
+ * - A configured free threshold makes shipping free once the cart subtotal meets
22
+ * or exceeds it.
23
+ * - Otherwise, fixed_cost_minor is used; null means this rule is always free.
24
+ *
25
+ * VAT rate snapshot:
26
+ * - `fixed` uses the rule's vat_rate_bps.
27
+ * - `inherit_main_goods` uses the most common line VAT rate.
28
+ * - `highest_in_cart` uses the max line VAT rate as a defensive accounting rule.
29
+ */
30
+ /** @beta */
31
+ export function resolveShipping(
32
+ cart: ShippingResolverCart,
33
+ rules: ReadonlyArray<ShippingRule>,
34
+ now: string,
35
+ ): ResolvedShipping | null {
36
+ const eligible = rules
37
+ .filter((r) => r.active)
38
+ .filter((r) => r.region_code === cart.region_code)
39
+ .filter((r) => r.currency === cart.currency)
40
+ .filter((r) => r.valid_from <= now && (r.valid_to === null || r.valid_to >= now))
41
+ .filter((r) => r.mode_at_cart === cart.cart_mode || r.mode_at_cart === "any");
42
+
43
+ if (eligible.length === 0) return null;
44
+
45
+ const sorted = [...eligible].sort((a, b) => {
46
+ // Exact mode match beats the cross-mode fallback.
47
+ const aSpecific = a.mode_at_cart === cart.cart_mode ? 0 : 1;
48
+ const bSpecific = b.mode_at_cart === cart.cart_mode ? 0 : 1;
49
+ if (aSpecific !== bSpecific) return aSpecific - bSpecific;
50
+ return a.priority - b.priority;
51
+ });
52
+
53
+ const rule = sorted[0];
54
+ const amount = computeAmount(rule, cart);
55
+ const vatRate = resolveVatRate(rule, cart);
56
+
57
+ return {
58
+ amount_minor: amount,
59
+ carrier_kind: rule.carrier_kind,
60
+ delivery_estimate_days: rule.delivery_estimate_days,
61
+ applied_rule_id: rule.id,
62
+ applied_rule_name: rule.name,
63
+ vat_rate_bps: vatRate,
64
+ };
65
+ }
66
+
67
+ function computeAmount(rule: ShippingRule, cart: ShippingResolverCart): number {
68
+ if (rule.free_threshold_minor !== null && cart.subtotal_minor >= rule.free_threshold_minor) {
69
+ return 0;
70
+ }
71
+ return rule.fixed_cost_minor ?? 0;
72
+ }
73
+
74
+ function resolveVatRate(rule: ShippingRule, cart: ShippingResolverCart): number {
75
+ switch (rule.vat_rate_kind) {
76
+ case "fixed":
77
+ return rule.vat_rate_bps;
78
+ case "highest_in_cart":
79
+ return cart.line_vat_rates_bps.length === 0
80
+ ? rule.vat_rate_bps
81
+ : Math.max(...cart.line_vat_rates_bps);
82
+ case "inherit_main_goods": {
83
+ if (cart.line_vat_rates_bps.length === 0) return rule.vat_rate_bps;
84
+ const counts = new Map<number, number>();
85
+ for (const rate of cart.line_vat_rates_bps) {
86
+ counts.set(rate, (counts.get(rate) ?? 0) + 1);
87
+ }
88
+ let dominantRate = rule.vat_rate_bps;
89
+ let dominantCount = 0;
90
+ for (const [rate, count] of counts) {
91
+ if (count > dominantCount) {
92
+ dominantRate = rate;
93
+ dominantCount = count;
94
+ }
95
+ }
96
+ return dominantRate;
97
+ }
98
+ default:
99
+ return rule.vat_rate_bps;
100
+ }
101
+ }
@@ -0,0 +1,49 @@
1
+ /** @beta */
2
+ export const SHIPPING_MODE_AT_CART = ["one_time", "subscription", "any"] as const;
3
+ /** @beta */
4
+ export type ShippingModeAtCart = (typeof SHIPPING_MODE_AT_CART)[number];
5
+
6
+ /** @beta */
7
+ export const SHIPPING_VAT_RATE_KINDS = ["fixed", "inherit_main_goods", "highest_in_cart"] as const;
8
+ /** @beta */
9
+ export type ShippingVatRateKind = (typeof SHIPPING_VAT_RATE_KINDS)[number];
10
+
11
+ /** @beta */
12
+ export interface ShippingRule {
13
+ id: string;
14
+ name: string;
15
+ region_code: string;
16
+ currency: string;
17
+ mode_at_cart: ShippingModeAtCart;
18
+ carrier_kind: string;
19
+ free_threshold_minor: number | null;
20
+ fixed_cost_minor: number | null;
21
+ weight_threshold_g: number | null;
22
+ delivery_estimate_days: number | null;
23
+ priority: number;
24
+ vat_rate_bps: number;
25
+ vat_rate_kind: ShippingVatRateKind;
26
+ active: boolean;
27
+ valid_from: string;
28
+ valid_to: string | null;
29
+ }
30
+
31
+ /** @beta */
32
+ export interface ShippingResolverCart {
33
+ region_code: string;
34
+ currency: string;
35
+ cart_mode: "one_time" | "subscription";
36
+ subtotal_minor: number;
37
+ line_vat_rates_bps: ReadonlyArray<number>;
38
+ weight_total_g?: number;
39
+ }
40
+
41
+ /** @beta */
42
+ export interface ResolvedShipping {
43
+ amount_minor: number;
44
+ carrier_kind: string;
45
+ delivery_estimate_days: number | null;
46
+ applied_rule_id: string;
47
+ applied_rule_name: string;
48
+ vat_rate_bps: number;
49
+ }
@@ -0,0 +1,141 @@
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 three 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
+ *
14
+ * TERMINATING, not capping. An attempt past the end of the ladder answers
15
+ * `null`, which is the pause signal; it does not answer "the last slot again".
16
+ * Callers therefore never need a `attempt <= maxRetryAttempts()` guard of their
17
+ * own — asking for the next slot IS the decision, and a second copy of the
18
+ * budget check is exactly how two rails drift apart.
19
+ *
20
+ * The cadence is injectable so a deployment can publish its own ladder, and the
21
+ * default is frozen so nobody can mutate the shipped one in place.
22
+ *
23
+ * All helpers are PURE — they never schedule local jobs, never touch payment
24
+ * adapters, never persist a cycle.
25
+ */
26
+
27
+ import {
28
+ PAYMENT_FAILURE_CLASSES,
29
+ failureClassDecision,
30
+ type PaymentFailureClass,
31
+ } from "../payment/paymentFailureTaxonomyContracts.js";
32
+
33
+ /** @beta */
34
+ export interface CycleRetryCadence {
35
+ /**
36
+ * Hours after the failure at which each successive retry is due, in order.
37
+ * Its length IS the retry budget: `maxRetryAttempts()` reports it and
38
+ * `nextRetryAttemptAt` terminates past it.
39
+ */
40
+ readonly backoffHours: readonly number[];
41
+ /**
42
+ * Refusal classes for which this deployment publishes no ladder at all, named
43
+ * as {@link PaymentFailureClass} values. Typed as strings because the same set
44
+ * is read back out of a nullable `text` column written by an older deployment,
45
+ * and an unrecognised value must be ignored rather than crash a schedule.
46
+ *
47
+ * Omitting it means "no class ends the ladder early", which is the behaviour
48
+ * every deployment had before this field existed.
49
+ */
50
+ readonly terminatingFailureClasses?: readonly string[];
51
+ }
52
+
53
+ /** @beta */
54
+ export const DEFAULT_CYCLE_RETRY_CADENCE: CycleRetryCadence = Object.freeze({
55
+ backoffHours: Object.freeze([24, 72, 168]) as readonly number[], // 1d, 3d, 7d
56
+ // ONE member. `hard_do_not_retry` is the taxonomy's statement that the issuer
57
+ // refused the INSTRUMENT rather than this charge, so no rung below can change
58
+ // the answer and three more attempts only promise the customer eleven days of
59
+ // charges that cannot land. Every other class keeps the full cadence. Its SQL
60
+ // twin is `v_terminating_classes` in the canonical apply body;
61
+ // scripts/retry-ladder-terminating-classes.test.ts reads that literal out of
62
+ // the latest declaring migration and fails if the two sets ever differ.
63
+ terminatingFailureClasses: Object.freeze(["hard_do_not_retry"]) as readonly string[],
64
+ });
65
+
66
+ /** @beta */
67
+ export type CycleHandlerStatus =
68
+ | "planned"
69
+ | "payment_pending"
70
+ | "paid"
71
+ | "payment_failed"
72
+ | "retry_scheduled"
73
+ | "skipped"
74
+ | "cancelled";
75
+
76
+ /**
77
+ * Whether the class of a refusal, rather than the rung it reached, ends the
78
+ * ladder.
79
+ *
80
+ * Fails OPEN in three separate ways, because a wrong `true` here withholds a
81
+ * charge the customer expected while a wrong `false` merely retries as before:
82
+ * an absent class does not terminate, a class the cadence does not list does not
83
+ * terminate, and a listed class the taxonomy does not recognise does not
84
+ * terminate. The last check is why a typo in configuration cannot stop a ladder.
85
+ *
86
+ * The final word belongs to {@link failureClassDecision}, not to the list: a
87
+ * cadence that lists a class the decision table still permits retrying is
88
+ * incoherent, and the table wins. That keeps the schedule from ever contradicting
89
+ * the kernel that already ruled on whether the instrument may be charged again.
90
+ *
91
+ * @beta
92
+ */
93
+ export function ladderTerminatedByClass(
94
+ failureClass: string | null | undefined,
95
+ cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
96
+ ): boolean {
97
+ if (failureClass === null || failureClass === undefined) return false;
98
+ if (!(cadence.terminatingFailureClasses ?? []).includes(failureClass)) return false;
99
+ if (!(PAYMENT_FAILURE_CLASSES as readonly string[]).includes(failureClass)) return false;
100
+ return !failureClassDecision(failureClass as PaymentFailureClass).retryAllowed;
101
+ }
102
+
103
+ /** @beta */
104
+ export function nextRetryAttemptAt(
105
+ failedAtIso: string,
106
+ attemptNumber: number,
107
+ cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
108
+ failureClass?: string | null,
109
+ ): string | null {
110
+ if (ladderTerminatedByClass(failureClass, cadence)) return null;
111
+ const hours = cadence.backoffHours[attemptNumber - 1];
112
+ // Covers both ends at once: an attempt below 1 and an attempt past the last
113
+ // ladder slot are equally "no next charge is due".
114
+ if (attemptNumber < 1 || hours === undefined) return null;
115
+ const failedAt = new Date(failedAtIso);
116
+ if (Number.isNaN(failedAt.getTime())) return null;
117
+ const next = new Date(failedAt.getTime() + hours * 60 * 60 * 1000);
118
+ return next.toISOString();
119
+ }
120
+
121
+ /** @beta */
122
+ export function shouldScheduleRetry(
123
+ cycleStatus: CycleHandlerStatus,
124
+ attemptNumber: number,
125
+ cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
126
+ failureClass?: string | null,
127
+ ): boolean {
128
+ if (cycleStatus !== "payment_failed") return false;
129
+ if (attemptNumber < 1) return false;
130
+ // Asked and answered by the same predicate the schedule uses, so this helper
131
+ // cannot say "schedule one" about a refusal for which the ladder answers none.
132
+ if (ladderTerminatedByClass(failureClass, cadence)) return false;
133
+ return attemptNumber <= maxRetryAttempts(cadence);
134
+ }
135
+
136
+ /** @beta */
137
+ export function maxRetryAttempts(
138
+ cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
139
+ ): number {
140
+ return cadence.backoffHours.length;
141
+ }
@@ -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";