@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.
- package/CHANGELOG.md +184 -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 +64 -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 +168 -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 +72 -0
- package/dist/subscription/cycleHardening.js +95 -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 +52 -0
- package/dist/subscription/subscriptionEngineLifecycle.js +196 -0
- package/dist/subscription/subscriptionEnginePayment.d.ts +19 -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 +209 -0
- package/src/company-identity/index.ts +27 -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 +141 -0
- package/src/subscription/index.ts +14 -0
- package/src/subscription/subscriptionEngineCore.ts +181 -0
- package/src/subscription/subscriptionEngineLifecycle.ts +282 -0
- package/src/subscription/subscriptionEnginePayment.ts +174 -0
- package/src/subscription/subscriptionEngineTypes.ts +112 -0
- package/src/subscription/subscriptionEngineUtils.ts +110 -0
- package/src/subscription/types.ts +104 -0
- package/src/testing.ts +169 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pricing breakdown builder — pure functions that emit signed pricing components for
|
|
3
|
+
* a given (cart, resolved unit price) tuple. Sums always reconcile to the order /
|
|
4
|
+
* order_line totals via two persistence-ready invariants:
|
|
5
|
+
*
|
|
6
|
+
* perLine: sum(line_components.amount) = order_line.line_total_minor
|
|
7
|
+
* perOrder: sum(all_components.amount) = order.total_minor (including shipping)
|
|
8
|
+
*
|
|
9
|
+
* Current components: base_unit (always), mode_discount (when subscription resolves
|
|
10
|
+
* below the base unit price), qty_tier (when a quantity tier above 1 matched),
|
|
11
|
+
* bundle (line-scoped and negative, carrying the share of a composed bundle's
|
|
12
|
+
* target-price discount that the allocator assigned to this line), shipping
|
|
13
|
+
* (order-scoped, when a rule or non-zero amount applies), and promo (one negative
|
|
14
|
+
* order-scoped component per applied discount). loyalty remains a reserved
|
|
15
|
+
* component type for downstream engines.
|
|
16
|
+
*/
|
|
17
|
+
/** @beta */
|
|
18
|
+
export const PRICING_COMPONENT_TYPES = [
|
|
19
|
+
"base_unit",
|
|
20
|
+
"mode_discount",
|
|
21
|
+
"qty_tier",
|
|
22
|
+
"bundle",
|
|
23
|
+
"promo",
|
|
24
|
+
"loyalty",
|
|
25
|
+
"shipping",
|
|
26
|
+
];
|
|
27
|
+
const ZERO_SHIPPING = {
|
|
28
|
+
amount_minor: 0,
|
|
29
|
+
applied_rule_id: null,
|
|
30
|
+
carrier_kind: null,
|
|
31
|
+
vat_rate_bps: null,
|
|
32
|
+
};
|
|
33
|
+
/** @beta */
|
|
34
|
+
export function buildLineBreakdown(input) {
|
|
35
|
+
const components = [];
|
|
36
|
+
let sequence = 0;
|
|
37
|
+
// 1) Base unit price: positive, the cost-floor everything else adjusts against.
|
|
38
|
+
components.push({
|
|
39
|
+
sequence: sequence++,
|
|
40
|
+
component_type: "base_unit",
|
|
41
|
+
amount_minor: input.base_unit_price_minor * input.line_qty,
|
|
42
|
+
reason_code: "variant_unit_price",
|
|
43
|
+
reason_payload: {
|
|
44
|
+
variant_id: input.variant_id,
|
|
45
|
+
line_qty: input.line_qty,
|
|
46
|
+
base_unit_price_minor: input.base_unit_price_minor,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
// 2) Mode discount: negative if subscription got a dedicated entry below `any`.
|
|
50
|
+
if (input.mode_at_line === "subscription" &&
|
|
51
|
+
!input.mode_resolved_via_fallback &&
|
|
52
|
+
input.resolved_unit_price_minor < input.base_unit_price_minor) {
|
|
53
|
+
const delta = (input.resolved_unit_price_minor - input.base_unit_price_minor) * input.line_qty;
|
|
54
|
+
components.push({
|
|
55
|
+
sequence: sequence++,
|
|
56
|
+
component_type: "mode_discount",
|
|
57
|
+
amount_minor: delta,
|
|
58
|
+
reason_code: "subscription_band",
|
|
59
|
+
reason_payload: {
|
|
60
|
+
unit_delta_minor: input.resolved_unit_price_minor - input.base_unit_price_minor,
|
|
61
|
+
line_qty: input.line_qty,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// 3) Quantity tier: negative if eligibleCartQty crossed a tier breakpoint above 1.
|
|
66
|
+
// The qty-tier discount value is encoded in the resolver's matched_tier price already, so
|
|
67
|
+
// the component only fires when matched_tier_min_qty > 1 AND we have not already applied
|
|
68
|
+
// the full delta via mode_discount.
|
|
69
|
+
if (input.matched_tier_min_qty > 1 &&
|
|
70
|
+
input.resolved_unit_price_minor === input.base_unit_price_minor) {
|
|
71
|
+
components.push({
|
|
72
|
+
sequence: sequence++,
|
|
73
|
+
component_type: "qty_tier",
|
|
74
|
+
amount_minor: 0,
|
|
75
|
+
reason_code: `qty_tier_${input.matched_tier_min_qty}`,
|
|
76
|
+
reason_payload: { matched_tier_min_qty: input.matched_tier_min_qty },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// 4) Bundle allocation: negative, the share of a bundle's target-price discount the
|
|
80
|
+
// allocator assigned to this line. It is line-scoped rather than order-scoped because
|
|
81
|
+
// a bundle prices a subset of the cart, and a refund of one line must return exactly
|
|
82
|
+
// the money that line was charged.
|
|
83
|
+
const bundleDiscount = input.bundle_allocated_discount_minor ?? 0;
|
|
84
|
+
if (bundleDiscount !== 0) {
|
|
85
|
+
components.push({
|
|
86
|
+
sequence: sequence++,
|
|
87
|
+
component_type: "bundle",
|
|
88
|
+
amount_minor: -bundleDiscount,
|
|
89
|
+
reason_code: "bundle_target_price",
|
|
90
|
+
reason_payload: {
|
|
91
|
+
variant_id: input.variant_id,
|
|
92
|
+
line_qty: input.line_qty,
|
|
93
|
+
allocated_discount_minor: bundleDiscount,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return components;
|
|
98
|
+
}
|
|
99
|
+
/** @beta */
|
|
100
|
+
export function buildOrderBreakdown(input) {
|
|
101
|
+
const shipping = input.shipping ?? ZERO_SHIPPING;
|
|
102
|
+
const lineBreakdowns = input.lines.map((line) => {
|
|
103
|
+
const components = buildLineBreakdown(line);
|
|
104
|
+
const line_total_minor = components.reduce((sum, c) => sum + c.amount_minor, 0);
|
|
105
|
+
return { variant_id: line.variant_id, line_total_minor, components };
|
|
106
|
+
});
|
|
107
|
+
const orderComponents = [];
|
|
108
|
+
let orderSequence = 0;
|
|
109
|
+
if (shipping.amount_minor !== 0 || shipping.applied_rule_id !== null) {
|
|
110
|
+
orderComponents.push({
|
|
111
|
+
sequence: orderSequence++,
|
|
112
|
+
component_type: "shipping",
|
|
113
|
+
amount_minor: shipping.amount_minor,
|
|
114
|
+
reason_code: shipping.applied_rule_id ? `shipping:${shipping.applied_rule_id}` : "shipping:zero",
|
|
115
|
+
reason_payload: {
|
|
116
|
+
applied_rule_id: shipping.applied_rule_id,
|
|
117
|
+
carrier_kind: shipping.carrier_kind,
|
|
118
|
+
vat_rate_bps: shipping.vat_rate_bps,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// Order-scoped promo: one negative component per applied discount so the order total
|
|
123
|
+
// reconciles to the discounted price. amount_off is positive on the way in; the
|
|
124
|
+
// component carries it as a negative adjustment against the line + shipping subtotal.
|
|
125
|
+
for (const discount of input.discounts ?? []) {
|
|
126
|
+
orderComponents.push({
|
|
127
|
+
sequence: orderSequence++,
|
|
128
|
+
component_type: "promo",
|
|
129
|
+
amount_minor: -discount.amountOffMinor,
|
|
130
|
+
reason_code: discount.reasonCode,
|
|
131
|
+
reason_payload: {
|
|
132
|
+
promotion_id: discount.promotionId,
|
|
133
|
+
amount_off_minor: discount.amountOffMinor,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const lineTotal = lineBreakdowns.reduce((sum, l) => sum + l.line_total_minor, 0);
|
|
138
|
+
const orderComponentsTotal = orderComponents.reduce((sum, c) => sum + c.amount_minor, 0);
|
|
139
|
+
return {
|
|
140
|
+
lineBreakdowns,
|
|
141
|
+
orderComponents,
|
|
142
|
+
orderTotalMinor: lineTotal + orderComponentsTotal,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Cart-level invariant: every order's total_minor equals the sum of all components
|
|
147
|
+
* (line + order-level). Callers must assert this before persisting to commerce_orders
|
|
148
|
+
* to prevent a sale from going out with a hidden price-snapshot drift.
|
|
149
|
+
*/
|
|
150
|
+
/** @beta */
|
|
151
|
+
export function assertBreakdownInvariant(result, orderTotalMinor) {
|
|
152
|
+
if (result.orderTotalMinor !== orderTotalMinor) {
|
|
153
|
+
throw new Error(`Pricing breakdown invariant broken: components sum to ${result.orderTotalMinor} but order.total_minor is ${orderTotalMinor}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle target-price allocation — the pure kernel that turns ONE operator-set
|
|
3
|
+
* price for a composed bundle into exact per-component money.
|
|
4
|
+
*
|
|
5
|
+
* An operator prices a bundle as a whole; every downstream surface (order lines,
|
|
6
|
+
* invoices, refunds, tax) needs a per-unit figure that reconciles back to that one
|
|
7
|
+
* number to the last minor unit. This module derives those figures and nothing else:
|
|
8
|
+
* integer arithmetic in BigInt, no clock, no randomness, no I/O.
|
|
9
|
+
*
|
|
10
|
+
* The split is largest-remainder (Hamilton), weighted by each component's own
|
|
11
|
+
* reference subtotal (quantity x unit price), with a deterministic tie-break on the
|
|
12
|
+
* component code so the same input always yields byte-identical output. Two rules
|
|
13
|
+
* bound the result:
|
|
14
|
+
*
|
|
15
|
+
* floor: a component never allocates below its minimum payable per unit, and a
|
|
16
|
+
* component whose own reference unit price sits below that minimum is
|
|
17
|
+
* held to its reference price instead — the reference price is a ceiling,
|
|
18
|
+
* so no floor may lift a component above what it is worth.
|
|
19
|
+
* ceiling: a component never allocates above its reference unit price, which is
|
|
20
|
+
* what makes the emitted per-line adjustment a discount rather than a
|
|
21
|
+
* hidden surcharge.
|
|
22
|
+
*
|
|
23
|
+
* Invariant, asserted by the suite over a generated matrix:
|
|
24
|
+
* sum(lines[].lineSubtotalMinor) === targetPriceMinor, exactly, always.
|
|
25
|
+
*
|
|
26
|
+
* A quantity whose allocated subtotal does not divide evenly emits TWO lines rather
|
|
27
|
+
* than a rounded unit price, because every consumer of a quote line refines
|
|
28
|
+
* `unitPrice x quantity === lineSubtotal` and a rounded unit price cannot satisfy it.
|
|
29
|
+
*
|
|
30
|
+
* Malformed input throws a RangeError (the idiom this package's other pure engines
|
|
31
|
+
* use for a caller contract breach); a well-formed request that cannot be priced
|
|
32
|
+
* returns a discriminated failure with a stable code, because those three cases are
|
|
33
|
+
* operator decisions rather than programming errors.
|
|
34
|
+
*/
|
|
35
|
+
/** @beta */
|
|
36
|
+
export interface BundleComponentInput {
|
|
37
|
+
sku: string;
|
|
38
|
+
unitPriceMinor: number;
|
|
39
|
+
quantity: number;
|
|
40
|
+
}
|
|
41
|
+
/** @beta */
|
|
42
|
+
export interface AllocateBundleTargetPriceInput {
|
|
43
|
+
components: ReadonlyArray<BundleComponentInput>;
|
|
44
|
+
targetPriceMinor: number;
|
|
45
|
+
minimumUnitPayableMinor?: number;
|
|
46
|
+
}
|
|
47
|
+
/** @beta */
|
|
48
|
+
export declare const BUNDLE_ALLOCATION_FAILURE_CODES: readonly ["REFERENCE_TOTAL_ZERO", "TARGET_ABOVE_COMPONENT_SUM", "TARGET_BELOW_FLOOR"];
|
|
49
|
+
/** @beta */
|
|
50
|
+
export type BundleAllocationFailureCode = (typeof BUNDLE_ALLOCATION_FAILURE_CODES)[number];
|
|
51
|
+
/** @beta */
|
|
52
|
+
export declare const DEFAULT_MINIMUM_UNIT_PAYABLE_MINOR = 1;
|
|
53
|
+
/** @beta */
|
|
54
|
+
export interface BundleAllocationFailure {
|
|
55
|
+
ok: false;
|
|
56
|
+
code: BundleAllocationFailureCode;
|
|
57
|
+
}
|
|
58
|
+
/** @beta */
|
|
59
|
+
export interface BundleAllocatedLine {
|
|
60
|
+
sku: string;
|
|
61
|
+
quantity: number;
|
|
62
|
+
effectiveUnitPriceMinor: number;
|
|
63
|
+
lineSubtotalMinor: number;
|
|
64
|
+
}
|
|
65
|
+
/** @beta */
|
|
66
|
+
export interface BundleComponentAllocation {
|
|
67
|
+
sku: string;
|
|
68
|
+
quantity: number;
|
|
69
|
+
referenceUnitPriceMinor: number;
|
|
70
|
+
referenceSubtotalMinor: number;
|
|
71
|
+
targetSubtotalMinor: number;
|
|
72
|
+
discountAllocatedMinor: number;
|
|
73
|
+
discountBps: number;
|
|
74
|
+
hasSplitPricing: boolean;
|
|
75
|
+
}
|
|
76
|
+
/** @beta */
|
|
77
|
+
export interface BundleAllocationResult {
|
|
78
|
+
ok: true;
|
|
79
|
+
referenceTotalMinor: number;
|
|
80
|
+
targetPriceMinor: number;
|
|
81
|
+
discountTotalMinor: number;
|
|
82
|
+
discountBps: number;
|
|
83
|
+
components: BundleComponentAllocation[];
|
|
84
|
+
lines: BundleAllocatedLine[];
|
|
85
|
+
floorApplied: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Derive per-component money for a bundle sold at one target price.
|
|
89
|
+
*
|
|
90
|
+
* Returns a failure when the composition cannot carry the price: nothing to weigh
|
|
91
|
+
* against (`REFERENCE_TOTAL_ZERO`), a target above the sum of the parts
|
|
92
|
+
* (`TARGET_ABOVE_COMPONENT_SUM` — a bundle is never priced above its parts, and the
|
|
93
|
+
* decision lives here as one comparison so relaxing it stays a one-line change), or
|
|
94
|
+
* a target that cannot cover every component's minimum payable (`TARGET_BELOW_FLOOR`).
|
|
95
|
+
*/
|
|
96
|
+
/** @beta */
|
|
97
|
+
export declare function allocateBundleTargetPrice(input: AllocateBundleTargetPriceInput): BundleAllocationResult | BundleAllocationFailure;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle target-price allocation — the pure kernel that turns ONE operator-set
|
|
3
|
+
* price for a composed bundle into exact per-component money.
|
|
4
|
+
*
|
|
5
|
+
* An operator prices a bundle as a whole; every downstream surface (order lines,
|
|
6
|
+
* invoices, refunds, tax) needs a per-unit figure that reconciles back to that one
|
|
7
|
+
* number to the last minor unit. This module derives those figures and nothing else:
|
|
8
|
+
* integer arithmetic in BigInt, no clock, no randomness, no I/O.
|
|
9
|
+
*
|
|
10
|
+
* The split is largest-remainder (Hamilton), weighted by each component's own
|
|
11
|
+
* reference subtotal (quantity x unit price), with a deterministic tie-break on the
|
|
12
|
+
* component code so the same input always yields byte-identical output. Two rules
|
|
13
|
+
* bound the result:
|
|
14
|
+
*
|
|
15
|
+
* floor: a component never allocates below its minimum payable per unit, and a
|
|
16
|
+
* component whose own reference unit price sits below that minimum is
|
|
17
|
+
* held to its reference price instead — the reference price is a ceiling,
|
|
18
|
+
* so no floor may lift a component above what it is worth.
|
|
19
|
+
* ceiling: a component never allocates above its reference unit price, which is
|
|
20
|
+
* what makes the emitted per-line adjustment a discount rather than a
|
|
21
|
+
* hidden surcharge.
|
|
22
|
+
*
|
|
23
|
+
* Invariant, asserted by the suite over a generated matrix:
|
|
24
|
+
* sum(lines[].lineSubtotalMinor) === targetPriceMinor, exactly, always.
|
|
25
|
+
*
|
|
26
|
+
* A quantity whose allocated subtotal does not divide evenly emits TWO lines rather
|
|
27
|
+
* than a rounded unit price, because every consumer of a quote line refines
|
|
28
|
+
* `unitPrice x quantity === lineSubtotal` and a rounded unit price cannot satisfy it.
|
|
29
|
+
*
|
|
30
|
+
* Malformed input throws a RangeError (the idiom this package's other pure engines
|
|
31
|
+
* use for a caller contract breach); a well-formed request that cannot be priced
|
|
32
|
+
* returns a discriminated failure with a stable code, because those three cases are
|
|
33
|
+
* operator decisions rather than programming errors.
|
|
34
|
+
*/
|
|
35
|
+
/** @beta */
|
|
36
|
+
export const BUNDLE_ALLOCATION_FAILURE_CODES = [
|
|
37
|
+
"REFERENCE_TOTAL_ZERO",
|
|
38
|
+
"TARGET_ABOVE_COMPONENT_SUM",
|
|
39
|
+
"TARGET_BELOW_FLOOR",
|
|
40
|
+
];
|
|
41
|
+
/** @beta */
|
|
42
|
+
export const DEFAULT_MINIMUM_UNIT_PAYABLE_MINOR = 1;
|
|
43
|
+
/**
|
|
44
|
+
* Derive per-component money for a bundle sold at one target price.
|
|
45
|
+
*
|
|
46
|
+
* Returns a failure when the composition cannot carry the price: nothing to weigh
|
|
47
|
+
* against (`REFERENCE_TOTAL_ZERO`), a target above the sum of the parts
|
|
48
|
+
* (`TARGET_ABOVE_COMPONENT_SUM` — a bundle is never priced above its parts, and the
|
|
49
|
+
* decision lives here as one comparison so relaxing it stays a one-line change), or
|
|
50
|
+
* a target that cannot cover every component's minimum payable (`TARGET_BELOW_FLOOR`).
|
|
51
|
+
*/
|
|
52
|
+
/** @beta */
|
|
53
|
+
export function allocateBundleTargetPrice(input) {
|
|
54
|
+
const components = validateInput(input);
|
|
55
|
+
const minimumUnitPayableMinor = input.minimumUnitPayableMinor ?? DEFAULT_MINIMUM_UNIT_PAYABLE_MINOR;
|
|
56
|
+
const target = BigInt(input.targetPriceMinor);
|
|
57
|
+
const references = components.map((item) => BigInt(item.quantity) * BigInt(item.unitPriceMinor));
|
|
58
|
+
const referenceTotal = references.reduce(add, 0n);
|
|
59
|
+
assertSafe("referenceTotalMinor", referenceTotal);
|
|
60
|
+
if (referenceTotal === 0n)
|
|
61
|
+
return { ok: false, code: "REFERENCE_TOTAL_ZERO" };
|
|
62
|
+
if (target > referenceTotal)
|
|
63
|
+
return { ok: false, code: "TARGET_ABOVE_COMPONENT_SUM" };
|
|
64
|
+
const floors = components.map((item) => BigInt(item.quantity) * BigInt(Math.min(minimumUnitPayableMinor, item.unitPriceMinor)));
|
|
65
|
+
if (target < floors.reduce(add, 0n))
|
|
66
|
+
return { ok: false, code: "TARGET_BELOW_FLOOR" };
|
|
67
|
+
const codes = components.map((item) => item.sku);
|
|
68
|
+
const { subtotals, floorApplied } = splitAgainstFloors(target, references, floors, codes);
|
|
69
|
+
const allocations = components.map((item, index) => describe(item, references[index], subtotals[index]));
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
referenceTotalMinor: Number(referenceTotal),
|
|
73
|
+
targetPriceMinor: input.targetPriceMinor,
|
|
74
|
+
discountTotalMinor: Number(referenceTotal - target),
|
|
75
|
+
discountBps: roundedBps(referenceTotal - target, referenceTotal),
|
|
76
|
+
components: allocations.map(({ allocation }) => allocation),
|
|
77
|
+
lines: allocations.flatMap(({ lines }) => lines),
|
|
78
|
+
floorApplied,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Split `target` over the open components, pin every component whose share landed
|
|
83
|
+
* under its floor, and re-split the rest until no further component is pinned.
|
|
84
|
+
* Terminates: each round either pins at least one component or is the last one, and
|
|
85
|
+
* at least one component always survives, because a round where every open share
|
|
86
|
+
* fell under its floor would mean the shares sum below a floor total the caller
|
|
87
|
+
* already proved the target covers.
|
|
88
|
+
*/
|
|
89
|
+
function splitAgainstFloors(target, references, floors, codes) {
|
|
90
|
+
const subtotals = references.map(() => 0n);
|
|
91
|
+
const pinned = references.map(() => false);
|
|
92
|
+
let floorApplied = false;
|
|
93
|
+
for (;;) {
|
|
94
|
+
const open = references.map((_, index) => index).filter((index) => !pinned[index]);
|
|
95
|
+
const pinnedTotal = references.reduce((total, _, index) => (pinned[index] ? total + floors[index] : total), 0n);
|
|
96
|
+
const shares = largestRemainder(target - pinnedTotal, open.map((index) => references[index]), open.map((index) => codes[index]));
|
|
97
|
+
const underFloor = open.filter((index, slot) => shares[slot] < floors[index]);
|
|
98
|
+
if (underFloor.length > 0) {
|
|
99
|
+
for (const index of underFloor)
|
|
100
|
+
pinned[index] = true;
|
|
101
|
+
floorApplied = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
open.forEach((index, slot) => { subtotals[index] = shares[slot]; });
|
|
105
|
+
pinned.forEach((isPinned, index) => { if (isPinned)
|
|
106
|
+
subtotals[index] = floors[index]; });
|
|
107
|
+
return { subtotals, floorApplied };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Hamilton split: proportional floor first, then hand the rounding deficit to the
|
|
112
|
+
* largest remainders, ties broken by ascending component code so the outcome never
|
|
113
|
+
* depends on input order or host collation.
|
|
114
|
+
*/
|
|
115
|
+
function largestRemainder(amount, weights, codes) {
|
|
116
|
+
const total = weights.reduce(add, 0n);
|
|
117
|
+
const shares = weights.map((weight) => (amount * weight) / total);
|
|
118
|
+
const remainders = weights.map((weight) => (amount * weight) % total);
|
|
119
|
+
const order = weights
|
|
120
|
+
.map((_, index) => index)
|
|
121
|
+
.sort((left, right) => (remainders[left] === remainders[right]
|
|
122
|
+
? compareCodeUnits(codes[left], codes[right])
|
|
123
|
+
: (remainders[left] > remainders[right] ? -1 : 1)));
|
|
124
|
+
let deficit = amount - shares.reduce(add, 0n);
|
|
125
|
+
for (const index of order) {
|
|
126
|
+
if (deficit === 0n)
|
|
127
|
+
break;
|
|
128
|
+
shares[index] += 1n;
|
|
129
|
+
deficit -= 1n;
|
|
130
|
+
}
|
|
131
|
+
return shares;
|
|
132
|
+
}
|
|
133
|
+
function describe(item, reference, subtotal) {
|
|
134
|
+
const quantity = BigInt(item.quantity);
|
|
135
|
+
const unit = subtotal / quantity;
|
|
136
|
+
const remainder = subtotal - unit * quantity;
|
|
137
|
+
const lines = remainder === 0n
|
|
138
|
+
? [line(item.sku, quantity, unit)]
|
|
139
|
+
: [line(item.sku, remainder, unit + 1n), line(item.sku, quantity - remainder, unit)];
|
|
140
|
+
return {
|
|
141
|
+
allocation: {
|
|
142
|
+
sku: item.sku,
|
|
143
|
+
quantity: item.quantity,
|
|
144
|
+
referenceUnitPriceMinor: item.unitPriceMinor,
|
|
145
|
+
referenceSubtotalMinor: Number(reference),
|
|
146
|
+
targetSubtotalMinor: Number(subtotal),
|
|
147
|
+
discountAllocatedMinor: Number(reference - subtotal),
|
|
148
|
+
discountBps: roundedBps(reference - subtotal, reference),
|
|
149
|
+
hasSplitPricing: remainder !== 0n,
|
|
150
|
+
},
|
|
151
|
+
lines,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function line(sku, quantity, unit) {
|
|
155
|
+
return {
|
|
156
|
+
sku,
|
|
157
|
+
quantity: Number(quantity),
|
|
158
|
+
effectiveUnitPriceMinor: Number(unit),
|
|
159
|
+
lineSubtotalMinor: Number(quantity * unit),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function validateInput(input) {
|
|
163
|
+
if (!Array.isArray(input.components) || input.components.length === 0) {
|
|
164
|
+
throw new RangeError("components must be a non-empty array");
|
|
165
|
+
}
|
|
166
|
+
assertMinor("targetPriceMinor", input.targetPriceMinor, 0);
|
|
167
|
+
assertMinor("minimumUnitPayableMinor", input.minimumUnitPayableMinor ?? DEFAULT_MINIMUM_UNIT_PAYABLE_MINOR, 0);
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
for (const item of input.components) {
|
|
170
|
+
if (typeof item.sku !== "string" || !item.sku.trim()) {
|
|
171
|
+
throw new RangeError("every component requires a code");
|
|
172
|
+
}
|
|
173
|
+
if (seen.has(item.sku))
|
|
174
|
+
throw new RangeError(`duplicate component code ${item.sku}`);
|
|
175
|
+
seen.add(item.sku);
|
|
176
|
+
assertMinor("quantity", item.quantity, 1);
|
|
177
|
+
assertMinor("unitPriceMinor", item.unitPriceMinor, 0);
|
|
178
|
+
}
|
|
179
|
+
return [...input.components];
|
|
180
|
+
}
|
|
181
|
+
function assertMinor(name, value, minimum) {
|
|
182
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
183
|
+
throw new RangeError(`${name} must be a safe integer >= ${minimum}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function assertSafe(name, value) {
|
|
187
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
188
|
+
throw new RangeError(`${name} must stay inside the safe integer range`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function roundedBps(amount, reference) {
|
|
192
|
+
if (reference === 0n)
|
|
193
|
+
return 0;
|
|
194
|
+
return Number((amount * 20000n + reference) / (reference * 2n));
|
|
195
|
+
}
|
|
196
|
+
function add(total, value) {
|
|
197
|
+
return total + value;
|
|
198
|
+
}
|
|
199
|
+
// Component codes are unique by validation, so an equal pair never reaches the sort.
|
|
200
|
+
function compareCodeUnits(left, right) {
|
|
201
|
+
return left < right ? -1 : 1;
|
|
202
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** @beta */
|
|
2
|
+
export declare const PRICING_MODES: readonly ["one_time", "subscription", "any"];
|
|
3
|
+
/** @beta */
|
|
4
|
+
export type PricingMode = (typeof PRICING_MODES)[number];
|
|
5
|
+
/** @beta */
|
|
6
|
+
export declare const PRICING_AMOUNT_KINDS: readonly ["gross", "net"];
|
|
7
|
+
/** @beta */
|
|
8
|
+
export type PricingAmountKind = (typeof PRICING_AMOUNT_KINDS)[number];
|
|
9
|
+
/** @beta */
|
|
10
|
+
export interface PricingRegion {
|
|
11
|
+
regionCode: string;
|
|
12
|
+
currency: string;
|
|
13
|
+
}
|
|
14
|
+
/** @beta */
|
|
15
|
+
export interface ResolvedPrice {
|
|
16
|
+
variantId: string;
|
|
17
|
+
mode: PricingMode;
|
|
18
|
+
matchedMinQty: number;
|
|
19
|
+
unitPriceMinor: number;
|
|
20
|
+
amountKind: PricingAmountKind;
|
|
21
|
+
priceListId: string;
|
|
22
|
+
priceEntryId: string;
|
|
23
|
+
resolvedAt: string;
|
|
24
|
+
}
|
|
25
|
+
/** @beta */
|
|
26
|
+
export interface ResolvePriceQuery extends PricingRegion {
|
|
27
|
+
variantId: string;
|
|
28
|
+
mode: PricingMode;
|
|
29
|
+
lineQty: number;
|
|
30
|
+
eligibleCartQty: number;
|
|
31
|
+
atTime?: string;
|
|
32
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type PromotionAdjustmentCandidate, type PromotionAdjustmentContext, type PromotionAdjustmentResult } from "./adjustmentTypes.js";
|
|
2
|
+
/** Select at most one product and one shipping adjustment. */
|
|
3
|
+
/** @beta */
|
|
4
|
+
export declare function evaluatePromotionAdjustmentsV2(context: PromotionAdjustmentContext, candidates: readonly PromotionAdjustmentCandidate[]): PromotionAdjustmentResult;
|