@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 configured default-rule prices and contributes
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 they'd require per-slice resolution against a
21
- * moving "now" date and are deferred to a follow-up.
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. This
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()`-independent it reads
30
- * static configured prices, not date-dependent rule windows. Same
31
- * product reindexed an hour later produces the same fields.
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` so the
38
- * projection can MIN the product-row default into the candidate set
39
- * and filter rules by matching currency. Templates inject this — the
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 given the product-row pricing and the rule
57
- * candidate set, produce the projection fields. Exposed via `__test__`
58
- * for unit coverage that doesn't need a real DB.
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, ruleCandidates: ReadonlyArray<number>): PricingAggregate;
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 `loadProductPricing` in tests to stub the products-row fetch;
72
- * production uses the default raw-SQL implementation.
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAChD,OAAO,KAAK,EAEV,0BAA0B,EAC3B,MAAM,0CAA0C,CAAA;AAMjD,UAAU,wBAAwB;IAChC;;;;;;;;OAQG;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;CAC9E;AAED,UAAU,gBAAgB;IACxB,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,UAAU,EAAE,OAAO,CAAA;CACpB;AAQD;;;;;;;;;;;GAWG;AACH,iBAAS,gBAAgB,CACvB,YAAY,EAAE,MAAM,GAAG,IAAI,EAC3B,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,GACpC,gBAAgB,CAqBlB;AAED;;;;;GAKG;AACH,wBAAgB,uCAAuC,CACrD,OAAO,GAAE,wBAA6B,GACrC,0BAA0B,CA4B5B;AA6GD,eAAO,MAAM,QAAQ;;;CAGpB,CAAA"}
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 configured default-rule prices and contributes
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 they'd require per-slice resolution against a
21
- * moving "now" date and are deferred to a follow-up.
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. This
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()`-independent it reads
30
- * static configured prices, not date-dependent rule windows. Same
31
- * product reindexed an hour later produces the same fields.
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 { and, eq, isNull, or, sql } from "drizzle-orm";
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 given the product-row pricing and the rule
43
- * candidate set, produce the projection fields. Exposed via `__test__`
44
- * for unit coverage that doesn't need a real DB.
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, ruleCandidates) {
54
- const candidates = [];
55
- if (productPrice !== null)
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 `loadProductPricing` in tests to stub the products-row fetch;
79
- * production uses the default raw-SQL implementation.
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 emit only the (null) row pricing. This case
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 [flatPrices, unitPrices] = await Promise.all([
97
- fetchFlatRulePrices(db, productId, currency),
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
- * Read `optionPriceRules.baseSellAmountCents` for the product's active
108
- * default rules whose catalog currency matches the product currency
109
- * (or whose catalog has a NULL currency, meaning "inherit product").
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 fetchFlatRulePrices(db, productId, productCurrency) {
116
- const rows = await db
117
- .select({ price: optionPriceRules.baseSellAmountCents })
118
- .from(optionPriceRules)
119
- .innerJoin(priceCatalogs, eq(priceCatalogs.id, optionPriceRules.priceCatalogId))
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
- return out;
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 `optionUnitPriceRules.sellAmountCents` for active per-unit tiers
130
- * whose parent rule is one of the product's active default rules in a
131
- * matching-currency catalog. Used for products priced per occupancy
132
- * (e.g. cabins at "single $X / double $Y / triple $Z") where the parent
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 fetchUnitRulePrices(db, productId, productCurrency) {
137
- const rows = await db
138
- .select({ price: optionUnitPriceRules.sellAmountCents })
139
- .from(optionUnitPriceRules)
140
- .innerJoin(optionPriceRules, eq(optionPriceRules.id, optionUnitPriceRules.optionPriceRuleId))
141
- .innerJoin(priceCatalogs, eq(priceCatalogs.id, optionPriceRules.priceCatalogId))
142
- .where(and(eq(optionPriceRules.productId, productId), eq(optionPriceRules.active, true), eq(optionPriceRules.isDefault, true), eq(optionUnitPriceRules.active, true), eq(priceCatalogs.active, true), or(eq(priceCatalogs.currencyCode, productCurrency), isNull(priceCatalogs.currencyCode))));
143
- const out = [];
144
- for (const row of rows) {
145
- if (row.price !== null)
146
- out.push(row.price);
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
- return out;
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 (would create a circular import
153
- * via the typed schema). The columns we read are stable enough that a
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: drizzle's typed sql is overkill for two-column read
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 kept off the public surface.
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.1",
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.1",
49
- "@voyantjs/core": "0.77.1",
50
- "@voyantjs/db": "0.77.1",
51
- "@voyantjs/hono": "0.77.1",
52
- "@voyantjs/products": "0.77.1"
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.1",
57
- "@voyantjs/facilities": "0.77.1",
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": [