@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.
- package/CHANGELOG.md +225 -0
- package/CODE_OF_CONDUCT.md +39 -0
- package/CONTRIBUTING.md +34 -0
- package/LICENSE +204 -0
- package/MAINTAINERS.md +33 -0
- package/README.md +85 -3
- package/SECURITY.md +29 -0
- package/dist/bundle/contracts.d.ts +9 -0
- package/dist/bundle/contracts.js +1 -0
- package/dist/bundle/ports.d.ts +41 -0
- package/dist/bundle/ports.js +1 -0
- package/dist/catalog/identifiers.d.ts +31 -0
- package/dist/catalog/identifiers.js +46 -0
- package/dist/catalog/index.d.ts +2 -0
- package/dist/catalog/index.js +2 -0
- package/dist/checkout/idempotency.d.ts +29 -0
- package/dist/checkout/idempotency.js +37 -0
- package/dist/checkout/index.d.ts +2 -0
- package/dist/checkout/index.js +2 -0
- package/dist/company-identity/contracts.d.ts +220 -0
- package/dist/company-identity/contracts.js +167 -0
- package/dist/company-identity/index.d.ts +6 -0
- package/dist/company-identity/index.js +2 -0
- package/dist/company-identity/ports.d.ts +13 -0
- package/dist/company-identity/ports.js +1 -0
- package/dist/fulfillment/facts.d.ts +103 -0
- package/dist/fulfillment/facts.js +195 -0
- package/dist/fulfillment/index.d.ts +4 -0
- package/dist/fulfillment/index.js +2 -0
- package/dist/inventory/atp.d.ts +172 -0
- package/dist/inventory/atp.js +211 -0
- package/dist/inventory/index.d.ts +2 -0
- package/dist/inventory/index.js +2 -0
- package/dist/marketing/research/contracts.d.ts +75 -0
- package/dist/marketing/research/contracts.js +60 -0
- package/dist/marketing/research/index.d.ts +4 -0
- package/dist/marketing/research/index.js +4 -0
- package/dist/marketing/research/ports.d.ts +15 -0
- package/dist/marketing/research/ports.js +1 -0
- package/dist/partners/index.d.ts +4 -0
- package/dist/partners/index.js +4 -0
- package/dist/partners/ports.d.ts +12 -0
- package/dist/partners/ports.js +1 -0
- package/dist/partners/status.d.ts +48 -0
- package/dist/partners/status.js +35 -0
- package/dist/payment/executionBaseContracts.d.ts +81 -0
- package/dist/payment/executionBaseContracts.js +8 -0
- package/dist/payment/index.d.ts +36 -0
- package/dist/payment/index.js +18 -0
- package/dist/payment/methodLifecycle.d.ts +91 -0
- package/dist/payment/methodLifecycle.js +76 -0
- package/dist/payment/paymentControlTypes.d.ts +135 -0
- package/dist/payment/paymentControlTypes.js +110 -0
- package/dist/payment/paymentFailureTaxonomy.d.ts +80 -0
- package/dist/payment/paymentFailureTaxonomy.js +155 -0
- package/dist/payment/paymentFailureTaxonomyContracts.d.ts +106 -0
- package/dist/payment/paymentFailureTaxonomyContracts.js +177 -0
- package/dist/payment/paymentRecovery.d.ts +26 -0
- package/dist/payment/paymentRecovery.js +73 -0
- package/dist/payment/paymentRecoveryContracts.d.ts +53 -0
- package/dist/payment/paymentRecoveryContracts.js +10 -0
- package/dist/payment/paymentStateMachine.d.ts +28 -0
- package/dist/payment/paymentStateMachine.js +184 -0
- package/dist/payment/providerAttemptIdempotency.d.ts +23 -0
- package/dist/payment/providerAttemptIdempotency.js +31 -0
- package/dist/payment/providerCapability.d.ts +148 -0
- package/dist/payment/providerCapability.js +13 -0
- package/dist/platform-runtime/contracts.d.ts +9 -0
- package/dist/platform-runtime/contracts.js +13 -0
- package/dist/platform-runtime/index.d.ts +10 -0
- package/dist/platform-runtime/index.js +4 -0
- package/dist/platform-runtime/platformKernel.d.ts +44 -0
- package/dist/platform-runtime/platformKernel.js +39 -0
- package/dist/platform-runtime/ports.d.ts +100 -0
- package/dist/platform-runtime/ports.js +1 -0
- package/dist/pricing/index.d.ts +8 -0
- package/dist/pricing/index.js +8 -0
- package/dist/pricing/ports.d.ts +22 -0
- package/dist/pricing/ports.js +7 -0
- package/dist/pricing/pricingBreakdown.d.ts +86 -0
- package/dist/pricing/pricingBreakdown.js +155 -0
- package/dist/pricing/targetPriceAllocation.d.ts +97 -0
- package/dist/pricing/targetPriceAllocation.js +202 -0
- package/dist/pricing/types.d.ts +32 -0
- package/dist/pricing/types.js +4 -0
- package/dist/promo/adjustmentEngine.d.ts +4 -0
- package/dist/promo/adjustmentEngine.js +194 -0
- package/dist/promo/adjustmentTypes.d.ts +103 -0
- package/dist/promo/adjustmentTypes.js +13 -0
- package/dist/promo/index.d.ts +8 -0
- package/dist/promo/index.js +8 -0
- package/dist/promo/promoEvaluator.d.ts +25 -0
- package/dist/promo/promoEvaluator.js +135 -0
- package/dist/promo/types.d.ts +86 -0
- package/dist/promo/types.js +12 -0
- package/dist/risk/core/evaluator.d.ts +3 -0
- package/dist/risk/core/evaluator.js +32 -0
- package/dist/risk/core/rules.d.ts +3 -0
- package/dist/risk/core/rules.js +118 -0
- package/dist/risk/core/types.d.ts +65 -0
- package/dist/risk/core/types.js +1 -0
- package/dist/risk/index.d.ts +8 -0
- package/dist/risk/index.js +8 -0
- package/dist/risk/types.d.ts +32 -0
- package/dist/risk/types.js +43 -0
- package/dist/shipping/index.d.ts +4 -0
- package/dist/shipping/index.js +4 -0
- package/dist/shipping/shippingResolver.d.ts +26 -0
- package/dist/shipping/shippingResolver.js +88 -0
- package/dist/shipping/types.d.ts +45 -0
- package/dist/shipping/types.js +4 -0
- package/dist/subscription/cycleHardening.d.ts +95 -0
- package/dist/subscription/cycleHardening.js +118 -0
- package/dist/subscription/index.d.ts +14 -0
- package/dist/subscription/index.js +14 -0
- package/dist/subscription/subscriptionEngineCore.d.ts +13 -0
- package/dist/subscription/subscriptionEngineCore.js +145 -0
- package/dist/subscription/subscriptionEngineLifecycle.d.ts +150 -0
- package/dist/subscription/subscriptionEngineLifecycle.js +433 -0
- package/dist/subscription/subscriptionEnginePayment.d.ts +25 -0
- package/dist/subscription/subscriptionEnginePayment.js +127 -0
- package/dist/subscription/subscriptionEngineTypes.d.ts +94 -0
- package/dist/subscription/subscriptionEngineTypes.js +4 -0
- package/dist/subscription/subscriptionEngineUtils.d.ts +32 -0
- package/dist/subscription/subscriptionEngineUtils.js +74 -0
- package/dist/subscription/types.d.ts +58 -0
- package/dist/subscription/types.js +47 -0
- package/dist/testing.d.ts +48 -0
- package/dist/testing.js +105 -0
- package/package.json +215 -4
- package/release-gates.json +232 -0
- package/src/bundle/contracts.ts +15 -0
- package/src/bundle/ports.ts +32 -0
- package/src/catalog/identifiers.ts +50 -0
- package/src/catalog/index.ts +2 -0
- package/src/checkout/idempotency.ts +49 -0
- package/src/checkout/index.ts +2 -0
- package/src/company-identity/contracts.ts +208 -0
- package/src/company-identity/index.ts +28 -0
- package/src/company-identity/ports.ts +19 -0
- package/src/fulfillment/facts.ts +300 -0
- package/src/fulfillment/index.ts +34 -0
- package/src/inventory/atp.ts +249 -0
- package/src/inventory/index.ts +2 -0
- package/src/marketing/research/contracts.ts +91 -0
- package/src/marketing/research/index.ts +4 -0
- package/src/marketing/research/ports.ts +26 -0
- package/src/partners/index.ts +4 -0
- package/src/partners/ports.ts +18 -0
- package/src/partners/status.ts +49 -0
- package/src/payment/executionBaseContracts.ts +93 -0
- package/src/payment/index.ts +128 -0
- package/src/payment/methodLifecycle.ts +130 -0
- package/src/payment/paymentControlTypes.ts +197 -0
- package/src/payment/paymentFailureTaxonomy.ts +201 -0
- package/src/payment/paymentFailureTaxonomyContracts.ts +228 -0
- package/src/payment/paymentRecovery.ts +95 -0
- package/src/payment/paymentRecoveryContracts.ts +61 -0
- package/src/payment/paymentStateMachine.ts +263 -0
- package/src/payment/providerAttemptIdempotency.ts +57 -0
- package/src/payment/providerCapability.ts +172 -0
- package/src/platform-runtime/contracts.ts +33 -0
- package/src/platform-runtime/index.ts +36 -0
- package/src/platform-runtime/platformKernel.ts +86 -0
- package/src/platform-runtime/ports.ts +88 -0
- package/src/pricing/index.ts +8 -0
- package/src/pricing/ports.ts +27 -0
- package/src/pricing/pricingBreakdown.ts +243 -0
- package/src/pricing/targetPriceAllocation.ts +293 -0
- package/src/pricing/types.ts +36 -0
- package/src/promo/adjustmentEngine.ts +253 -0
- package/src/promo/adjustmentTypes.ts +94 -0
- package/src/promo/index.ts +8 -0
- package/src/promo/promoEvaluator.ts +137 -0
- package/src/promo/types.ts +97 -0
- package/src/risk/core/evaluator.ts +41 -0
- package/src/risk/core/rules.ts +128 -0
- package/src/risk/core/types.ts +73 -0
- package/src/risk/index.ts +8 -0
- package/src/risk/types.ts +60 -0
- package/src/shipping/index.ts +4 -0
- package/src/shipping/shippingResolver.ts +101 -0
- package/src/shipping/types.ts +49 -0
- package/src/subscription/cycleHardening.ts +174 -0
- package/src/subscription/index.ts +14 -0
- package/src/subscription/subscriptionEngineCore.ts +181 -0
- package/src/subscription/subscriptionEngineLifecycle.ts +574 -0
- package/src/subscription/subscriptionEnginePayment.ts +179 -0
- package/src/subscription/subscriptionEngineTypes.ts +114 -0
- package/src/subscription/subscriptionEngineUtils.ts +110 -0
- package/src/subscription/types.ts +104 -0
- 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,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,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";
|