@voyantjs/pricing 0.77.1 → 0.77.4
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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Projection extension that aggregates a "price from" amount across the
|
|
3
|
-
* product's
|
|
3
|
+
* product's future bookable rate-plan prices and contributes
|
|
4
4
|
* `priceFromAmountCents`, `priceFromCurrency`, and `hasPricing` to the
|
|
5
5
|
* product search document.
|
|
6
6
|
*
|
|
@@ -17,28 +17,24 @@
|
|
|
17
17
|
* Scope intentionally narrow:
|
|
18
18
|
* - **No schedule-aware rule resolution.** Only `is_default = true`
|
|
19
19
|
* rules contribute. Seasonal / promo rules with schedules don't
|
|
20
|
-
* surface here
|
|
21
|
-
*
|
|
20
|
+
* surface here; they require per-slice rule evaluation beyond the
|
|
21
|
+
* future-departure presence check below.
|
|
22
22
|
* - **No per-departure overrides.** Same reason.
|
|
23
23
|
* - **Currency consistency.** Only rules whose catalog currency matches
|
|
24
24
|
* the product's `sellCurrency` (or whose catalog currency is null
|
|
25
|
-
* and therefore inherits the product's) are MIN'd together.
|
|
26
|
-
* prevents emitting a misleading "from $50" when one of the rules
|
|
27
|
-
* is actually €50.
|
|
25
|
+
* and therefore inherits the product's) are MIN'd together.
|
|
28
26
|
*
|
|
29
|
-
* Document churn: this projection is `now()`-
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* Document churn: this projection is `now()`-dependent because it only
|
|
28
|
+
* considers future bookable departures. A product can move to "unpriced"
|
|
29
|
+
* once its final departure starts unless a row-level fallback remains.
|
|
32
30
|
*/
|
|
33
31
|
import type { AnyDrizzleDb } from "@voyantjs/db";
|
|
34
32
|
import type { ProductProjectionExtension } from "@voyantjs/products/service-catalog-plane";
|
|
35
33
|
interface PricingProjectionOptions {
|
|
36
34
|
/**
|
|
37
|
-
* Resolve the product's `sellAmountCents` + `sellCurrency
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* default reads the products table via raw SQL, but tests can stub
|
|
41
|
-
* with a known shape without standing up the products schema.
|
|
35
|
+
* Resolve the product's row-level `sellAmountCents` + `sellCurrency`.
|
|
36
|
+
* Templates use the default raw-SQL loader; tests can stub it without
|
|
37
|
+
* standing up the products schema.
|
|
42
38
|
*
|
|
43
39
|
* Returns `null` for both fields when the product doesn't exist.
|
|
44
40
|
*/
|
|
@@ -46,35 +42,51 @@ interface PricingProjectionOptions {
|
|
|
46
42
|
sellAmountCents: number | null;
|
|
47
43
|
sellCurrency: string | null;
|
|
48
44
|
}>;
|
|
45
|
+
/**
|
|
46
|
+
* Resolve future bookable rate-plan prices for the product. Tests can
|
|
47
|
+
* stub this without standing up availability/product option tables.
|
|
48
|
+
*/
|
|
49
|
+
loadRatePlanPricing?: (db: AnyDrizzleDb, productId: string, productCurrency: string) => Promise<RatePlanPricing>;
|
|
49
50
|
}
|
|
50
51
|
interface PricingAggregate {
|
|
51
52
|
priceFromAmountCents: number | null;
|
|
52
53
|
priceFromCurrency: string | null;
|
|
53
54
|
hasPricing: boolean;
|
|
54
55
|
}
|
|
56
|
+
interface RatePlanPricing {
|
|
57
|
+
roomPrices: number[];
|
|
58
|
+
basePrices: number[];
|
|
59
|
+
}
|
|
55
60
|
/**
|
|
56
|
-
* Pure aggregation kernel
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* `productPrice` is the row's `sellAmountCents` (`null` when unset).
|
|
61
|
-
* `currency` is the row's `sellCurrency` (used as the projected
|
|
62
|
-
* currency; rule prices fed in here are pre-filtered to match).
|
|
63
|
-
* `ruleCandidates` is every active default rule's `baseSellAmountCents`
|
|
64
|
-
* AND every active default rule's per-unit `sellAmountCents`, with
|
|
65
|
-
* nulls already filtered out.
|
|
61
|
+
* Pure aggregation kernel. Room prices take precedence over base/unit
|
|
62
|
+
* prices, which take precedence over the product-row fallback. Non-
|
|
63
|
+
* positive values are treated as absent so stale `0` caches don't block
|
|
64
|
+
* nullish fallbacks in catalog consumers.
|
|
66
65
|
*/
|
|
67
|
-
declare function aggregatePricing(productPrice: number | null, currency: string | null,
|
|
66
|
+
declare function aggregatePricing(productPrice: number | null, currency: string | null, roomPrices: ReadonlyArray<number>, basePrices: ReadonlyArray<number>): PricingAggregate;
|
|
68
67
|
/**
|
|
69
68
|
* Construct the pricing projection extension.
|
|
70
69
|
*
|
|
71
|
-
* Pass
|
|
72
|
-
*
|
|
70
|
+
* Pass loaders in tests to stub DB reads; production uses raw SQL against
|
|
71
|
+
* the deployed schema.
|
|
73
72
|
*/
|
|
74
73
|
export declare function createProductPricingProjectionExtension(options?: PricingProjectionOptions): ProductProjectionExtension;
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the same "from price" value emitted by the pricing projection.
|
|
76
|
+
* Promotion projection wiring uses this so strikethrough base prices
|
|
77
|
+
* follow the same rate-plan-first fallback chain.
|
|
78
|
+
*/
|
|
79
|
+
export declare function loadProductPriceFrom(db: AnyDrizzleDb, productId: string): Promise<{
|
|
80
|
+
amountCents: number | null;
|
|
81
|
+
currency: string | null;
|
|
82
|
+
}>;
|
|
83
|
+
declare function firstPositiveMin(values: ReadonlyArray<number>): number | null;
|
|
84
|
+
declare function isMissingCatalogPricingDependencyError(error: unknown): boolean;
|
|
75
85
|
export declare const __test__: {
|
|
76
86
|
aggregatePricing: typeof aggregatePricing;
|
|
77
87
|
EMPTY_AGGREGATE: PricingAggregate;
|
|
88
|
+
firstPositiveMin: typeof firstPositiveMin;
|
|
89
|
+
isMissingCatalogPricingDependencyError: typeof isMissingCatalogPricingDependencyError;
|
|
78
90
|
};
|
|
79
91
|
export {};
|
|
80
92
|
//# sourceMappingURL=service-catalog-plane-pricing.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-catalog-plane-pricing.d.ts","sourceRoot":"","sources":["../src/service-catalog-plane-pricing.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"service-catalog-plane-pricing.d.ts","sourceRoot":"","sources":["../src/service-catalog-plane-pricing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAChD,OAAO,KAAK,EAEV,0BAA0B,EAC3B,MAAM,0CAA0C,CAAA;AAGjD,UAAU,wBAAwB;IAChC;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,CACnB,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,KACd,OAAO,CAAC;QAAE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAA;IAE7E;;;OAGG;IACH,mBAAmB,CAAC,EAAE,CACpB,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,EACjB,eAAe,EAAE,MAAM,KACpB,OAAO,CAAC,eAAe,CAAC,CAAA;CAC9B;AAED,UAAU,gBAAgB;IACxB,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,UAAU,EAAE,OAAO,CAAA;CACpB;AAED,UAAU,eAAe;IACvB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAQD;;;;;GAKG;AACH,iBAAS,gBAAgB,CACvB,YAAY,EAAE,MAAM,GAAG,IAAI,EAC3B,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,UAAU,EAAE,aAAa,CAAC,MAAM,CAAC,EACjC,UAAU,EAAE,aAAa,CAAC,MAAM,CAAC,GAChC,gBAAgB,CAYlB;AAED;;;;;GAKG;AACH,wBAAgB,uCAAuC,CACrD,OAAO,GAAE,wBAA6B,GACrC,0BAA0B,CA2B5B;AAED;;;;GAIG;AACH,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAclE;AAuLD,iBAAS,gBAAgB,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAOtE;AAYD,iBAAS,sCAAsC,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAWvE;AAWD,eAAO,MAAM,QAAQ;;;;;CAKpB,CAAA"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Projection extension that aggregates a "price from" amount across the
|
|
3
|
-
* product's
|
|
3
|
+
* product's future bookable rate-plan prices and contributes
|
|
4
4
|
* `priceFromAmountCents`, `priceFromCurrency`, and `hasPricing` to the
|
|
5
5
|
* product search document.
|
|
6
6
|
*
|
|
@@ -17,55 +17,34 @@
|
|
|
17
17
|
* Scope intentionally narrow:
|
|
18
18
|
* - **No schedule-aware rule resolution.** Only `is_default = true`
|
|
19
19
|
* rules contribute. Seasonal / promo rules with schedules don't
|
|
20
|
-
* surface here
|
|
21
|
-
*
|
|
20
|
+
* surface here; they require per-slice rule evaluation beyond the
|
|
21
|
+
* future-departure presence check below.
|
|
22
22
|
* - **No per-departure overrides.** Same reason.
|
|
23
23
|
* - **Currency consistency.** Only rules whose catalog currency matches
|
|
24
24
|
* the product's `sellCurrency` (or whose catalog currency is null
|
|
25
|
-
* and therefore inherits the product's) are MIN'd together.
|
|
26
|
-
* prevents emitting a misleading "from $50" when one of the rules
|
|
27
|
-
* is actually €50.
|
|
25
|
+
* and therefore inherits the product's) are MIN'd together.
|
|
28
26
|
*
|
|
29
|
-
* Document churn: this projection is `now()`-
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* Document churn: this projection is `now()`-dependent because it only
|
|
28
|
+
* considers future bookable departures. A product can move to "unpriced"
|
|
29
|
+
* once its final departure starts unless a row-level fallback remains.
|
|
32
30
|
*/
|
|
33
|
-
import {
|
|
34
|
-
import { priceCatalogs } from "./schema-catalogs.js";
|
|
35
|
-
import { optionPriceRules, optionUnitPriceRules } from "./schema-option-rules.js";
|
|
31
|
+
import { sql } from "drizzle-orm";
|
|
36
32
|
const EMPTY_AGGREGATE = {
|
|
37
33
|
priceFromAmountCents: null,
|
|
38
34
|
priceFromCurrency: null,
|
|
39
35
|
hasPricing: false,
|
|
40
36
|
};
|
|
41
37
|
/**
|
|
42
|
-
* Pure aggregation kernel
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `productPrice` is the row's `sellAmountCents` (`null` when unset).
|
|
47
|
-
* `currency` is the row's `sellCurrency` (used as the projected
|
|
48
|
-
* currency; rule prices fed in here are pre-filtered to match).
|
|
49
|
-
* `ruleCandidates` is every active default rule's `baseSellAmountCents`
|
|
50
|
-
* AND every active default rule's per-unit `sellAmountCents`, with
|
|
51
|
-
* nulls already filtered out.
|
|
38
|
+
* Pure aggregation kernel. Room prices take precedence over base/unit
|
|
39
|
+
* prices, which take precedence over the product-row fallback. Non-
|
|
40
|
+
* positive values are treated as absent so stale `0` caches don't block
|
|
41
|
+
* nullish fallbacks in catalog consumers.
|
|
52
42
|
*/
|
|
53
|
-
function aggregatePricing(productPrice, currency,
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
candidates.push(productPrice);
|
|
57
|
-
for (const c of ruleCandidates)
|
|
58
|
-
candidates.push(c);
|
|
59
|
-
if (candidates.length === 0) {
|
|
43
|
+
function aggregatePricing(productPrice, currency, roomPrices, basePrices) {
|
|
44
|
+
const min = firstPositiveMin(roomPrices) ?? firstPositiveMin(basePrices) ?? positive(productPrice);
|
|
45
|
+
if (min === null) {
|
|
60
46
|
return { ...EMPTY_AGGREGATE, priceFromCurrency: currency };
|
|
61
47
|
}
|
|
62
|
-
// MIN with a sentinel; faster than spread+Math.min for large arrays
|
|
63
|
-
// and avoids the Math.min stack-arg limit edge case.
|
|
64
|
-
let min = candidates[0];
|
|
65
|
-
for (const c of candidates) {
|
|
66
|
-
if (c < min)
|
|
67
|
-
min = c;
|
|
68
|
-
}
|
|
69
48
|
return {
|
|
70
49
|
priceFromAmountCents: min,
|
|
71
50
|
priceFromCurrency: currency,
|
|
@@ -75,86 +54,180 @@ function aggregatePricing(productPrice, currency, ruleCandidates) {
|
|
|
75
54
|
/**
|
|
76
55
|
* Construct the pricing projection extension.
|
|
77
56
|
*
|
|
78
|
-
* Pass
|
|
79
|
-
*
|
|
57
|
+
* Pass loaders in tests to stub DB reads; production uses raw SQL against
|
|
58
|
+
* the deployed schema.
|
|
80
59
|
*/
|
|
81
60
|
export function createProductPricingProjectionExtension(options = {}) {
|
|
82
61
|
const loadProductPricing = options.loadProductPricing ?? defaultLoadProductPricing;
|
|
62
|
+
const loadRatePlanPricing = options.loadRatePlanPricing ?? defaultLoadRatePlanPricing;
|
|
83
63
|
return {
|
|
84
64
|
name: "products:pricing",
|
|
85
65
|
async project(db, productId, _slice) {
|
|
86
66
|
const product = await loadProductPricing(db, productId);
|
|
87
67
|
const currency = product.sellCurrency;
|
|
88
68
|
// Without a product row currency we can't safely filter rules by
|
|
89
|
-
// matching currency
|
|
90
|
-
// is rare in practice (every product has sellCurrency set in the
|
|
91
|
-
// operator UI) but keeps the projection failure-isolated.
|
|
69
|
+
// matching currency. Emit only the positive row-level fallback.
|
|
92
70
|
if (!currency) {
|
|
93
|
-
const out = aggregatePricing(product.sellAmountCents, null, []);
|
|
71
|
+
const out = aggregatePricing(product.sellAmountCents, null, [], []);
|
|
94
72
|
return toProjectionMap(out);
|
|
95
73
|
}
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
fetchUnitRulePrices(db, productId, currency),
|
|
99
|
-
]);
|
|
100
|
-
const candidates = [...flatPrices, ...unitPrices];
|
|
101
|
-
const out = aggregatePricing(product.sellAmountCents, currency, candidates);
|
|
74
|
+
const ratePlans = await loadRatePlanPricing(db, productId, currency);
|
|
75
|
+
const out = aggregatePricing(product.sellAmountCents, currency, ratePlans.roomPrices, ratePlans.basePrices);
|
|
102
76
|
return toProjectionMap(out);
|
|
103
77
|
},
|
|
104
78
|
};
|
|
105
79
|
}
|
|
106
80
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* Filtering on the `(productId, active=true, isDefault=true)` subset
|
|
112
|
-
* keeps the candidate set small even for products with hundreds of
|
|
113
|
-
* seasonal rules.
|
|
81
|
+
* Resolve the same "from price" value emitted by the pricing projection.
|
|
82
|
+
* Promotion projection wiring uses this so strikethrough base prices
|
|
83
|
+
* follow the same rate-plan-first fallback chain.
|
|
114
84
|
*/
|
|
115
|
-
async function
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
.where(and(eq(optionPriceRules.productId, productId), eq(optionPriceRules.active, true), eq(optionPriceRules.isDefault, true), eq(priceCatalogs.active, true), or(eq(priceCatalogs.currencyCode, productCurrency), isNull(priceCatalogs.currencyCode))));
|
|
121
|
-
const out = [];
|
|
122
|
-
for (const row of rows) {
|
|
123
|
-
if (row.price !== null)
|
|
124
|
-
out.push(row.price);
|
|
85
|
+
export async function loadProductPriceFrom(db, productId) {
|
|
86
|
+
const product = await defaultLoadProductPricing(db, productId);
|
|
87
|
+
const currency = product.sellCurrency;
|
|
88
|
+
if (!currency) {
|
|
89
|
+
return { amountCents: positive(product.sellAmountCents), currency: null };
|
|
125
90
|
}
|
|
126
|
-
|
|
91
|
+
const ratePlans = await defaultLoadRatePlanPricing(db, productId, currency);
|
|
92
|
+
const amountCents = firstPositiveMin(ratePlans.roomPrices) ??
|
|
93
|
+
firstPositiveMin(ratePlans.basePrices) ??
|
|
94
|
+
positive(product.sellAmountCents);
|
|
95
|
+
return { amountCents, currency };
|
|
127
96
|
}
|
|
128
97
|
/**
|
|
129
|
-
* Read
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* rule's `baseSellAmountCents` is null and the actual prices live on
|
|
134
|
-
* the unit tiers.
|
|
98
|
+
* Read positive prices from active default rules that have at least one
|
|
99
|
+
* future bookable departure. Room prices are separated from base/unit
|
|
100
|
+
* fallbacks so per-room pricing wins even when the product row contains
|
|
101
|
+
* a stale zero or stale manual price.
|
|
135
102
|
*/
|
|
136
|
-
async function
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
103
|
+
async function defaultLoadRatePlanPricing(db, productId, productCurrency) {
|
|
104
|
+
try {
|
|
105
|
+
const [roomPrice, basePrice] = await Promise.all([
|
|
106
|
+
fetchBookableRoomPrice(db, productId, productCurrency),
|
|
107
|
+
fetchBookableBasePrice(db, productId, productCurrency),
|
|
108
|
+
]);
|
|
109
|
+
return {
|
|
110
|
+
roomPrices: roomPrice == null ? [] : [roomPrice],
|
|
111
|
+
basePrices: basePrice == null ? [] : [basePrice],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
// Slim test fixtures may omit availability_slots/product_options/
|
|
116
|
+
// option_units. Keep reindex failure-isolated and fall back to the
|
|
117
|
+
// product row only for those expected schema gaps.
|
|
118
|
+
if (isMissingCatalogPricingDependencyError(error)) {
|
|
119
|
+
return { roomPrices: [], basePrices: [] };
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
147
122
|
}
|
|
148
|
-
|
|
123
|
+
}
|
|
124
|
+
async function fetchBookableRoomPrice(db, productId, productCurrency) {
|
|
125
|
+
const rows = await executeRows(db, sql `
|
|
126
|
+
WITH active_rules AS (
|
|
127
|
+
SELECT opr.id
|
|
128
|
+
FROM option_price_rules opr
|
|
129
|
+
INNER JOIN price_catalogs pc ON pc.id = opr.price_catalog_id
|
|
130
|
+
WHERE opr.product_id = ${productId}
|
|
131
|
+
AND opr.active = true
|
|
132
|
+
AND opr.is_default = true
|
|
133
|
+
AND pc.active = true
|
|
134
|
+
AND (pc.currency_code = ${productCurrency} OR pc.currency_code IS NULL)
|
|
135
|
+
AND EXISTS (
|
|
136
|
+
SELECT 1
|
|
137
|
+
FROM product_options po
|
|
138
|
+
WHERE po.id = opr.option_id
|
|
139
|
+
AND po.product_id = opr.product_id
|
|
140
|
+
AND po.status = 'active'
|
|
141
|
+
)
|
|
142
|
+
AND EXISTS (
|
|
143
|
+
SELECT 1
|
|
144
|
+
FROM availability_slots slot
|
|
145
|
+
WHERE slot.product_id = opr.product_id
|
|
146
|
+
AND slot.starts_at >= NOW()
|
|
147
|
+
AND slot.status::text IN ('open', 'planned', 'confirmed')
|
|
148
|
+
AND (slot.option_id IS NULL OR slot.option_id = opr.option_id)
|
|
149
|
+
)
|
|
150
|
+
),
|
|
151
|
+
candidates AS (
|
|
152
|
+
SELECT COALESCE(tier.sell_amount_cents, unit_rule.sell_amount_cents) AS price
|
|
153
|
+
FROM active_rules rule
|
|
154
|
+
INNER JOIN option_unit_price_rules unit_rule
|
|
155
|
+
ON unit_rule.option_price_rule_id = rule.id
|
|
156
|
+
INNER JOIN option_units unit
|
|
157
|
+
ON unit.id = unit_rule.unit_id
|
|
158
|
+
LEFT JOIN option_unit_tiers tier
|
|
159
|
+
ON tier.option_unit_price_rule_id = unit_rule.id
|
|
160
|
+
AND tier.active = true
|
|
161
|
+
WHERE unit_rule.active = true
|
|
162
|
+
AND unit.unit_type = 'room'
|
|
163
|
+
)
|
|
164
|
+
SELECT MIN(price)::int AS price
|
|
165
|
+
FROM candidates
|
|
166
|
+
WHERE price > 0
|
|
167
|
+
`);
|
|
168
|
+
return readNullableInt(rows[0], "price");
|
|
169
|
+
}
|
|
170
|
+
async function fetchBookableBasePrice(db, productId, productCurrency) {
|
|
171
|
+
const rows = await executeRows(db, sql `
|
|
172
|
+
WITH active_rules AS (
|
|
173
|
+
SELECT opr.id, opr.base_sell_amount_cents
|
|
174
|
+
FROM option_price_rules opr
|
|
175
|
+
INNER JOIN price_catalogs pc ON pc.id = opr.price_catalog_id
|
|
176
|
+
WHERE opr.product_id = ${productId}
|
|
177
|
+
AND opr.active = true
|
|
178
|
+
AND opr.is_default = true
|
|
179
|
+
AND pc.active = true
|
|
180
|
+
AND (pc.currency_code = ${productCurrency} OR pc.currency_code IS NULL)
|
|
181
|
+
AND EXISTS (
|
|
182
|
+
SELECT 1
|
|
183
|
+
FROM product_options po
|
|
184
|
+
WHERE po.id = opr.option_id
|
|
185
|
+
AND po.product_id = opr.product_id
|
|
186
|
+
AND po.status = 'active'
|
|
187
|
+
)
|
|
188
|
+
AND EXISTS (
|
|
189
|
+
SELECT 1
|
|
190
|
+
FROM availability_slots slot
|
|
191
|
+
WHERE slot.product_id = opr.product_id
|
|
192
|
+
AND slot.starts_at >= NOW()
|
|
193
|
+
AND slot.status::text IN ('open', 'planned', 'confirmed')
|
|
194
|
+
AND (slot.option_id IS NULL OR slot.option_id = opr.option_id)
|
|
195
|
+
)
|
|
196
|
+
),
|
|
197
|
+
candidates AS (
|
|
198
|
+
SELECT base_sell_amount_cents AS price
|
|
199
|
+
FROM active_rules
|
|
200
|
+
UNION ALL
|
|
201
|
+
SELECT COALESCE(tier.sell_amount_cents, unit_rule.sell_amount_cents) AS price
|
|
202
|
+
FROM active_rules rule
|
|
203
|
+
INNER JOIN option_unit_price_rules unit_rule
|
|
204
|
+
ON unit_rule.option_price_rule_id = rule.id
|
|
205
|
+
INNER JOIN option_units unit
|
|
206
|
+
ON unit.id = unit_rule.unit_id
|
|
207
|
+
LEFT JOIN option_unit_tiers tier
|
|
208
|
+
ON tier.option_unit_price_rule_id = unit_rule.id
|
|
209
|
+
AND tier.active = true
|
|
210
|
+
WHERE unit_rule.active = true
|
|
211
|
+
AND unit.unit_type <> 'room'
|
|
212
|
+
)
|
|
213
|
+
SELECT MIN(price)::int AS price
|
|
214
|
+
FROM candidates
|
|
215
|
+
WHERE price > 0
|
|
216
|
+
`);
|
|
217
|
+
return readNullableInt(rows[0], "price");
|
|
218
|
+
}
|
|
219
|
+
async function executeRows(db, query) {
|
|
220
|
+
// biome-ignore lint/suspicious/noExplicitAny: #1141 supports multiple drizzle driver result shapes
|
|
221
|
+
const result = await db.execute(query);
|
|
222
|
+
return Array.isArray(result) ? result : (result?.rows ?? []);
|
|
149
223
|
}
|
|
150
224
|
/**
|
|
151
225
|
* Default loader reads the products row via raw SQL so we don't pull
|
|
152
|
-
* the products schema into this file
|
|
153
|
-
*
|
|
154
|
-
* rename would break far more than this.
|
|
226
|
+
* the products schema into this file. The columns we read are stable
|
|
227
|
+
* enough that a rename would break far more than this.
|
|
155
228
|
*/
|
|
156
229
|
async function defaultLoadProductPricing(db, productId) {
|
|
157
|
-
// biome-ignore lint/suspicious/noExplicitAny:
|
|
230
|
+
// biome-ignore lint/suspicious/noExplicitAny: #1141 keeps cross-package product lookup driver-agnostic
|
|
158
231
|
const dbAny = db;
|
|
159
232
|
const result = await dbAny.execute(sql `SELECT sell_amount_cents, sell_currency FROM products WHERE id = ${productId} LIMIT 1`);
|
|
160
233
|
// postgres-js returns rows as an array-like; node-postgres returns `{ rows: [...] }`.
|
|
@@ -167,6 +240,39 @@ async function defaultLoadProductPricing(db, productId) {
|
|
|
167
240
|
sellCurrency: first.sell_currency,
|
|
168
241
|
};
|
|
169
242
|
}
|
|
243
|
+
function positive(value) {
|
|
244
|
+
return typeof value === "number" && value > 0 ? value : null;
|
|
245
|
+
}
|
|
246
|
+
function firstPositiveMin(values) {
|
|
247
|
+
let min = null;
|
|
248
|
+
for (const value of values) {
|
|
249
|
+
if (value <= 0)
|
|
250
|
+
continue;
|
|
251
|
+
if (min === null || value < min)
|
|
252
|
+
min = value;
|
|
253
|
+
}
|
|
254
|
+
return min;
|
|
255
|
+
}
|
|
256
|
+
function readNullableInt(row, key) {
|
|
257
|
+
const value = row?.[key];
|
|
258
|
+
if (typeof value === "number")
|
|
259
|
+
return Number.isFinite(value) ? value : null;
|
|
260
|
+
if (typeof value === "string") {
|
|
261
|
+
const parsed = Number(value);
|
|
262
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
function isMissingCatalogPricingDependencyError(error) {
|
|
267
|
+
const err = error;
|
|
268
|
+
const code = typeof err?.code === "string" ? err.code : null;
|
|
269
|
+
if (code === "42P01" || code === "42703")
|
|
270
|
+
return true;
|
|
271
|
+
const message = typeof err?.message === "string" ? err.message.toLowerCase() : "";
|
|
272
|
+
return ((message.includes("relation") && message.includes("does not exist")) ||
|
|
273
|
+
message.includes("no such table") ||
|
|
274
|
+
message.includes("no such column"));
|
|
275
|
+
}
|
|
170
276
|
function toProjectionMap(a) {
|
|
171
277
|
return new Map([
|
|
172
278
|
["priceFromAmountCents", a.priceFromAmountCents],
|
|
@@ -174,8 +280,10 @@ function toProjectionMap(a) {
|
|
|
174
280
|
["hasPricing", a.hasPricing],
|
|
175
281
|
]);
|
|
176
282
|
}
|
|
177
|
-
// Internal exports for unit tests
|
|
283
|
+
// Internal exports for unit tests - kept off the public surface.
|
|
178
284
|
export const __test__ = {
|
|
179
285
|
aggregatePricing,
|
|
180
286
|
EMPTY_AGGREGATE,
|
|
287
|
+
firstPositiveMin,
|
|
288
|
+
isMissingCatalogPricingDependencyError,
|
|
181
289
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyantjs/pricing",
|
|
3
|
-
"version": "0.77.
|
|
3
|
+
"version": "0.77.4",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -45,16 +45,16 @@
|
|
|
45
45
|
"hono": "^4.12.10",
|
|
46
46
|
"rrule": "^2.8.1",
|
|
47
47
|
"zod": "^4.3.6",
|
|
48
|
-
"@voyantjs/availability": "0.77.
|
|
49
|
-
"@voyantjs/core": "0.77.
|
|
50
|
-
"@voyantjs/db": "0.77.
|
|
51
|
-
"@voyantjs/hono": "0.77.
|
|
52
|
-
"@voyantjs/products": "0.77.
|
|
48
|
+
"@voyantjs/availability": "0.77.4",
|
|
49
|
+
"@voyantjs/core": "0.77.4",
|
|
50
|
+
"@voyantjs/db": "0.77.4",
|
|
51
|
+
"@voyantjs/hono": "0.77.4",
|
|
52
|
+
"@voyantjs/products": "0.77.4"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"typescript": "^6.0.2",
|
|
56
|
-
"@voyantjs/extras": "0.77.
|
|
57
|
-
"@voyantjs/facilities": "0.77.
|
|
56
|
+
"@voyantjs/extras": "0.77.4",
|
|
57
|
+
"@voyantjs/facilities": "0.77.4",
|
|
58
58
|
"@voyantjs/voyant-typescript-config": "0.1.0"
|
|
59
59
|
},
|
|
60
60
|
"files": [
|