@openlup/core 0.0.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/CODE_OF_CONDUCT.md +39 -0
  3. package/CONTRIBUTING.md +34 -0
  4. package/LICENSE +204 -0
  5. package/MAINTAINERS.md +33 -0
  6. package/README.md +85 -3
  7. package/SECURITY.md +29 -0
  8. package/dist/bundle/contracts.d.ts +9 -0
  9. package/dist/bundle/contracts.js +1 -0
  10. package/dist/bundle/ports.d.ts +41 -0
  11. package/dist/bundle/ports.js +1 -0
  12. package/dist/catalog/identifiers.d.ts +31 -0
  13. package/dist/catalog/identifiers.js +46 -0
  14. package/dist/catalog/index.d.ts +2 -0
  15. package/dist/catalog/index.js +2 -0
  16. package/dist/checkout/idempotency.d.ts +29 -0
  17. package/dist/checkout/idempotency.js +37 -0
  18. package/dist/checkout/index.d.ts +2 -0
  19. package/dist/checkout/index.js +2 -0
  20. package/dist/company-identity/contracts.d.ts +220 -0
  21. package/dist/company-identity/contracts.js +167 -0
  22. package/dist/company-identity/index.d.ts +6 -0
  23. package/dist/company-identity/index.js +2 -0
  24. package/dist/company-identity/ports.d.ts +13 -0
  25. package/dist/company-identity/ports.js +1 -0
  26. package/dist/fulfillment/facts.d.ts +103 -0
  27. package/dist/fulfillment/facts.js +195 -0
  28. package/dist/fulfillment/index.d.ts +4 -0
  29. package/dist/fulfillment/index.js +2 -0
  30. package/dist/inventory/atp.d.ts +172 -0
  31. package/dist/inventory/atp.js +211 -0
  32. package/dist/inventory/index.d.ts +2 -0
  33. package/dist/inventory/index.js +2 -0
  34. package/dist/marketing/research/contracts.d.ts +75 -0
  35. package/dist/marketing/research/contracts.js +60 -0
  36. package/dist/marketing/research/index.d.ts +4 -0
  37. package/dist/marketing/research/index.js +4 -0
  38. package/dist/marketing/research/ports.d.ts +15 -0
  39. package/dist/marketing/research/ports.js +1 -0
  40. package/dist/partners/index.d.ts +4 -0
  41. package/dist/partners/index.js +4 -0
  42. package/dist/partners/ports.d.ts +12 -0
  43. package/dist/partners/ports.js +1 -0
  44. package/dist/partners/status.d.ts +48 -0
  45. package/dist/partners/status.js +35 -0
  46. package/dist/payment/executionBaseContracts.d.ts +81 -0
  47. package/dist/payment/executionBaseContracts.js +8 -0
  48. package/dist/payment/index.d.ts +36 -0
  49. package/dist/payment/index.js +18 -0
  50. package/dist/payment/methodLifecycle.d.ts +91 -0
  51. package/dist/payment/methodLifecycle.js +76 -0
  52. package/dist/payment/paymentControlTypes.d.ts +135 -0
  53. package/dist/payment/paymentControlTypes.js +110 -0
  54. package/dist/payment/paymentFailureTaxonomy.d.ts +80 -0
  55. package/dist/payment/paymentFailureTaxonomy.js +155 -0
  56. package/dist/payment/paymentFailureTaxonomyContracts.d.ts +106 -0
  57. package/dist/payment/paymentFailureTaxonomyContracts.js +177 -0
  58. package/dist/payment/paymentRecovery.d.ts +26 -0
  59. package/dist/payment/paymentRecovery.js +73 -0
  60. package/dist/payment/paymentRecoveryContracts.d.ts +53 -0
  61. package/dist/payment/paymentRecoveryContracts.js +10 -0
  62. package/dist/payment/paymentStateMachine.d.ts +28 -0
  63. package/dist/payment/paymentStateMachine.js +184 -0
  64. package/dist/payment/providerAttemptIdempotency.d.ts +23 -0
  65. package/dist/payment/providerAttemptIdempotency.js +31 -0
  66. package/dist/payment/providerCapability.d.ts +148 -0
  67. package/dist/payment/providerCapability.js +13 -0
  68. package/dist/platform-runtime/contracts.d.ts +9 -0
  69. package/dist/platform-runtime/contracts.js +13 -0
  70. package/dist/platform-runtime/index.d.ts +10 -0
  71. package/dist/platform-runtime/index.js +4 -0
  72. package/dist/platform-runtime/platformKernel.d.ts +44 -0
  73. package/dist/platform-runtime/platformKernel.js +39 -0
  74. package/dist/platform-runtime/ports.d.ts +100 -0
  75. package/dist/platform-runtime/ports.js +1 -0
  76. package/dist/pricing/index.d.ts +8 -0
  77. package/dist/pricing/index.js +8 -0
  78. package/dist/pricing/ports.d.ts +22 -0
  79. package/dist/pricing/ports.js +7 -0
  80. package/dist/pricing/pricingBreakdown.d.ts +86 -0
  81. package/dist/pricing/pricingBreakdown.js +155 -0
  82. package/dist/pricing/targetPriceAllocation.d.ts +97 -0
  83. package/dist/pricing/targetPriceAllocation.js +202 -0
  84. package/dist/pricing/types.d.ts +32 -0
  85. package/dist/pricing/types.js +4 -0
  86. package/dist/promo/adjustmentEngine.d.ts +4 -0
  87. package/dist/promo/adjustmentEngine.js +194 -0
  88. package/dist/promo/adjustmentTypes.d.ts +103 -0
  89. package/dist/promo/adjustmentTypes.js +13 -0
  90. package/dist/promo/index.d.ts +8 -0
  91. package/dist/promo/index.js +8 -0
  92. package/dist/promo/promoEvaluator.d.ts +25 -0
  93. package/dist/promo/promoEvaluator.js +135 -0
  94. package/dist/promo/types.d.ts +86 -0
  95. package/dist/promo/types.js +12 -0
  96. package/dist/risk/core/evaluator.d.ts +3 -0
  97. package/dist/risk/core/evaluator.js +32 -0
  98. package/dist/risk/core/rules.d.ts +3 -0
  99. package/dist/risk/core/rules.js +118 -0
  100. package/dist/risk/core/types.d.ts +65 -0
  101. package/dist/risk/core/types.js +1 -0
  102. package/dist/risk/index.d.ts +8 -0
  103. package/dist/risk/index.js +8 -0
  104. package/dist/risk/types.d.ts +32 -0
  105. package/dist/risk/types.js +43 -0
  106. package/dist/shipping/index.d.ts +4 -0
  107. package/dist/shipping/index.js +4 -0
  108. package/dist/shipping/shippingResolver.d.ts +26 -0
  109. package/dist/shipping/shippingResolver.js +88 -0
  110. package/dist/shipping/types.d.ts +45 -0
  111. package/dist/shipping/types.js +4 -0
  112. package/dist/subscription/cycleHardening.d.ts +95 -0
  113. package/dist/subscription/cycleHardening.js +118 -0
  114. package/dist/subscription/index.d.ts +14 -0
  115. package/dist/subscription/index.js +14 -0
  116. package/dist/subscription/subscriptionEngineCore.d.ts +13 -0
  117. package/dist/subscription/subscriptionEngineCore.js +145 -0
  118. package/dist/subscription/subscriptionEngineLifecycle.d.ts +150 -0
  119. package/dist/subscription/subscriptionEngineLifecycle.js +433 -0
  120. package/dist/subscription/subscriptionEnginePayment.d.ts +25 -0
  121. package/dist/subscription/subscriptionEnginePayment.js +127 -0
  122. package/dist/subscription/subscriptionEngineTypes.d.ts +94 -0
  123. package/dist/subscription/subscriptionEngineTypes.js +4 -0
  124. package/dist/subscription/subscriptionEngineUtils.d.ts +32 -0
  125. package/dist/subscription/subscriptionEngineUtils.js +74 -0
  126. package/dist/subscription/types.d.ts +58 -0
  127. package/dist/subscription/types.js +47 -0
  128. package/dist/testing.d.ts +48 -0
  129. package/dist/testing.js +105 -0
  130. package/package.json +215 -4
  131. package/release-gates.json +232 -0
  132. package/src/bundle/contracts.ts +15 -0
  133. package/src/bundle/ports.ts +32 -0
  134. package/src/catalog/identifiers.ts +50 -0
  135. package/src/catalog/index.ts +2 -0
  136. package/src/checkout/idempotency.ts +49 -0
  137. package/src/checkout/index.ts +2 -0
  138. package/src/company-identity/contracts.ts +208 -0
  139. package/src/company-identity/index.ts +28 -0
  140. package/src/company-identity/ports.ts +19 -0
  141. package/src/fulfillment/facts.ts +300 -0
  142. package/src/fulfillment/index.ts +34 -0
  143. package/src/inventory/atp.ts +249 -0
  144. package/src/inventory/index.ts +2 -0
  145. package/src/marketing/research/contracts.ts +91 -0
  146. package/src/marketing/research/index.ts +4 -0
  147. package/src/marketing/research/ports.ts +26 -0
  148. package/src/partners/index.ts +4 -0
  149. package/src/partners/ports.ts +18 -0
  150. package/src/partners/status.ts +49 -0
  151. package/src/payment/executionBaseContracts.ts +93 -0
  152. package/src/payment/index.ts +128 -0
  153. package/src/payment/methodLifecycle.ts +130 -0
  154. package/src/payment/paymentControlTypes.ts +197 -0
  155. package/src/payment/paymentFailureTaxonomy.ts +201 -0
  156. package/src/payment/paymentFailureTaxonomyContracts.ts +228 -0
  157. package/src/payment/paymentRecovery.ts +95 -0
  158. package/src/payment/paymentRecoveryContracts.ts +61 -0
  159. package/src/payment/paymentStateMachine.ts +263 -0
  160. package/src/payment/providerAttemptIdempotency.ts +57 -0
  161. package/src/payment/providerCapability.ts +172 -0
  162. package/src/platform-runtime/contracts.ts +33 -0
  163. package/src/platform-runtime/index.ts +36 -0
  164. package/src/platform-runtime/platformKernel.ts +86 -0
  165. package/src/platform-runtime/ports.ts +88 -0
  166. package/src/pricing/index.ts +8 -0
  167. package/src/pricing/ports.ts +27 -0
  168. package/src/pricing/pricingBreakdown.ts +243 -0
  169. package/src/pricing/targetPriceAllocation.ts +293 -0
  170. package/src/pricing/types.ts +36 -0
  171. package/src/promo/adjustmentEngine.ts +253 -0
  172. package/src/promo/adjustmentTypes.ts +94 -0
  173. package/src/promo/index.ts +8 -0
  174. package/src/promo/promoEvaluator.ts +137 -0
  175. package/src/promo/types.ts +97 -0
  176. package/src/risk/core/evaluator.ts +41 -0
  177. package/src/risk/core/rules.ts +128 -0
  178. package/src/risk/core/types.ts +73 -0
  179. package/src/risk/index.ts +8 -0
  180. package/src/risk/types.ts +60 -0
  181. package/src/shipping/index.ts +4 -0
  182. package/src/shipping/shippingResolver.ts +101 -0
  183. package/src/shipping/types.ts +49 -0
  184. package/src/subscription/cycleHardening.ts +174 -0
  185. package/src/subscription/index.ts +14 -0
  186. package/src/subscription/subscriptionEngineCore.ts +181 -0
  187. package/src/subscription/subscriptionEngineLifecycle.ts +574 -0
  188. package/src/subscription/subscriptionEnginePayment.ts +179 -0
  189. package/src/subscription/subscriptionEngineTypes.ts +114 -0
  190. package/src/subscription/subscriptionEngineUtils.ts +110 -0
  191. package/src/subscription/types.ts +104 -0
  192. package/src/testing.ts +169 -0
@@ -0,0 +1,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,174 @@
1
+ /**
2
+ * Subscription cycle hardening helpers — the canonical retry ladder a renewal
3
+ * payment failure follows.
4
+ *
5
+ * This module is the SINGLE authority on the ladder. It answers four questions:
6
+ *
7
+ * - `nextRetryAttemptAt(failedAt, attemptNumber, cadence?, failureClass?)` —
8
+ * when the next charge is due, or `null` when no next charge is due.
9
+ * - `shouldScheduleRetry(cycleStatus, attemptNumber, cadence?, failureClass?)`
10
+ * — whether a failed payment may move into `retry_scheduled`.
11
+ * - `ladderTerminatedByClass(failureClass, cadence?)` — whether the REASON for
12
+ * the refusal, rather than the rung reached, ends the ladder.
13
+ * - `isDunningLadderExhausted(cycle, cadence?)` — whether a refused cycle has
14
+ * run past the last rung, the only non-payment reason to pause.
15
+ *
16
+ * TERMINATING, not capping. An attempt past the end of the ladder answers
17
+ * `null`, which is the pause signal; it does not answer "the last slot again".
18
+ * Callers therefore never need a `attempt <= maxRetryAttempts()` guard of their
19
+ * own — asking for the next slot IS the decision, and a second copy of the
20
+ * budget check is exactly how two rails drift apart.
21
+ *
22
+ * The cadence is injectable so a deployment can publish its own ladder, and the
23
+ * default is frozen so nobody can mutate the shipped one in place.
24
+ *
25
+ * All helpers are PURE — they never schedule local jobs, never touch payment
26
+ * adapters, never persist a cycle.
27
+ */
28
+
29
+ import {
30
+ PAYMENT_FAILURE_CLASSES,
31
+ failureClassDecision,
32
+ type PaymentFailureClass,
33
+ } from "../payment/paymentFailureTaxonomyContracts.js";
34
+
35
+ /** @beta */
36
+ export interface CycleRetryCadence {
37
+ /**
38
+ * Hours after the failure at which each successive retry is due, in order.
39
+ * Its length IS the retry budget: `maxRetryAttempts()` reports it and
40
+ * `nextRetryAttemptAt` terminates past it.
41
+ */
42
+ readonly backoffHours: readonly number[];
43
+ /**
44
+ * Refusal classes for which this deployment publishes no ladder at all, named
45
+ * as {@link PaymentFailureClass} values. Typed as strings because the same set
46
+ * is read back out of a nullable `text` column written by an older deployment,
47
+ * and an unrecognised value must be ignored rather than crash a schedule.
48
+ *
49
+ * Omitting it means "no class ends the ladder early", which is the behaviour
50
+ * every deployment had before this field existed.
51
+ */
52
+ readonly terminatingFailureClasses?: readonly string[];
53
+ }
54
+
55
+ /** @beta */
56
+ export const DEFAULT_CYCLE_RETRY_CADENCE: CycleRetryCadence = Object.freeze({
57
+ backoffHours: Object.freeze([24, 72, 168]) as readonly number[], // 1d, 3d, 7d
58
+ // ONE member. `hard_do_not_retry` is the taxonomy's statement that the issuer
59
+ // refused the INSTRUMENT rather than this charge, so no rung below can change
60
+ // the answer and three more attempts only promise the customer eleven days of
61
+ // charges that cannot land. Every other class keeps the full cadence. Its SQL
62
+ // twin is `v_terminating_classes` in the canonical apply body;
63
+ // scripts/retry-ladder-terminating-classes.test.ts reads that literal out of
64
+ // the latest declaring migration and fails if the two sets ever differ.
65
+ terminatingFailureClasses: Object.freeze(["hard_do_not_retry"]) as readonly string[],
66
+ });
67
+
68
+ /** @beta */
69
+ export type CycleHandlerStatus =
70
+ | "planned"
71
+ | "payment_pending"
72
+ | "paid"
73
+ | "payment_failed"
74
+ | "retry_scheduled"
75
+ | "skipped"
76
+ | "cancelled";
77
+
78
+ /**
79
+ * Whether the class of a refusal, rather than the rung it reached, ends the
80
+ * ladder.
81
+ *
82
+ * Fails OPEN in three separate ways, because a wrong `true` here withholds a
83
+ * charge the customer expected while a wrong `false` merely retries as before:
84
+ * an absent class does not terminate, a class the cadence does not list does not
85
+ * terminate, and a listed class the taxonomy does not recognise does not
86
+ * terminate. The last check is why a typo in configuration cannot stop a ladder.
87
+ *
88
+ * The final word belongs to {@link failureClassDecision}, not to the list: a
89
+ * cadence that lists a class the decision table still permits retrying is
90
+ * incoherent, and the table wins. That keeps the schedule from ever contradicting
91
+ * the kernel that already ruled on whether the instrument may be charged again.
92
+ *
93
+ * @beta
94
+ */
95
+ export function ladderTerminatedByClass(
96
+ failureClass: string | null | undefined,
97
+ cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
98
+ ): boolean {
99
+ if (failureClass === null || failureClass === undefined) return false;
100
+ if (!(cadence.terminatingFailureClasses ?? []).includes(failureClass)) return false;
101
+ if (!(PAYMENT_FAILURE_CLASSES as readonly string[]).includes(failureClass)) return false;
102
+ return !failureClassDecision(failureClass as PaymentFailureClass).retryAllowed;
103
+ }
104
+
105
+ /** @beta */
106
+ export function nextRetryAttemptAt(
107
+ failedAtIso: string,
108
+ attemptNumber: number,
109
+ cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
110
+ failureClass?: string | null,
111
+ ): string | null {
112
+ if (ladderTerminatedByClass(failureClass, cadence)) return null;
113
+ const hours = cadence.backoffHours[attemptNumber - 1];
114
+ // Covers both ends at once: an attempt below 1 and an attempt past the last
115
+ // ladder slot are equally "no next charge is due".
116
+ if (attemptNumber < 1 || hours === undefined) return null;
117
+ const failedAt = new Date(failedAtIso);
118
+ if (Number.isNaN(failedAt.getTime())) return null;
119
+ const next = new Date(failedAt.getTime() + hours * 60 * 60 * 1000);
120
+ return next.toISOString();
121
+ }
122
+
123
+ /** @beta */
124
+ export function shouldScheduleRetry(
125
+ cycleStatus: CycleHandlerStatus,
126
+ attemptNumber: number,
127
+ cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
128
+ failureClass?: string | null,
129
+ ): boolean {
130
+ if (cycleStatus !== "payment_failed") return false;
131
+ if (attemptNumber < 1) return false;
132
+ // Asked and answered by the same predicate the schedule uses, so this helper
133
+ // cannot say "schedule one" about a refusal for which the ladder answers none.
134
+ if (ladderTerminatedByClass(failureClass, cadence)) return false;
135
+ return attemptNumber <= maxRetryAttempts(cadence);
136
+ }
137
+
138
+ /** @beta */
139
+ export function maxRetryAttempts(
140
+ cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
141
+ ): number {
142
+ return cadence.backoffHours.length;
143
+ }
144
+
145
+ /**
146
+ * Whether a refused cycle has EXHAUSTED the ladder: it failed, nothing is
147
+ * scheduled after it, and the attempt that failed sits past the last rung.
148
+ *
149
+ * This is the one condition under which the platform may pause a live
150
+ * subscription for non-payment. Both halves are required. An absent schedule on
151
+ * its own is not exhaustion: a class-terminated refusal on rung one also leaves
152
+ * nothing scheduled, and treating it as exhaustion would pause a subscription on
153
+ * the day of its first refusal. The rung is derived from the cadence, so pass
154
+ * the same cadence here and to `recordPaymentFailure`. The managed SQL dunning
155
+ * boundary spells the shipped ladder's rung as a fixed constant
156
+ * (`v_ladder_exhausted_from`, 4); a deployment that publishes a different
157
+ * ladder must move that constant with it.
158
+ *
159
+ * @beta
160
+ */
161
+ export function isDunningLadderExhausted(
162
+ cycle: {
163
+ readonly status: CycleHandlerStatus;
164
+ readonly retryAttempt: number;
165
+ readonly nextRetryAt: string | null;
166
+ },
167
+ cadence: CycleRetryCadence = DEFAULT_CYCLE_RETRY_CADENCE,
168
+ ): boolean {
169
+ return (
170
+ cycle.status === "payment_failed" &&
171
+ cycle.nextRetryAt === null &&
172
+ cycle.retryAttempt > maxRetryAttempts(cadence)
173
+ );
174
+ }
@@ -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";