@final-commerce/command-frame 0.8.0 → 0.9.0-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/CommonTypes.d.ts +25 -1
  2. package/dist/actions/add-product-fee/mock.d.ts +1 -1
  3. package/dist/actions/add-product-fee/mock.js +15 -9
  4. package/dist/actions/add-product-to-cart/mock.d.ts +23 -0
  5. package/dist/actions/add-product-to-cart/mock.js +300 -1
  6. package/dist/actions/add-product-to-cart/types.d.ts +27 -1
  7. package/dist/actions/get-composite-availability/action.d.ts +6 -0
  8. package/dist/actions/get-composite-availability/action.js +8 -0
  9. package/dist/actions/get-composite-availability/mock.d.ts +2 -0
  10. package/dist/actions/get-composite-availability/mock.js +45 -0
  11. package/dist/actions/get-composite-availability/types.d.ts +29 -0
  12. package/dist/actions/get-composite-availability/types.js +1 -0
  13. package/dist/actions/get-composite-price/action.d.ts +6 -0
  14. package/dist/actions/get-composite-price/action.js +8 -0
  15. package/dist/actions/get-composite-price/mock.d.ts +2 -0
  16. package/dist/actions/get-composite-price/mock.js +37 -0
  17. package/dist/actions/get-composite-price/types.d.ts +31 -0
  18. package/dist/actions/get-composite-price/types.js +1 -0
  19. package/dist/actions/get-current-cart/mock.d.ts +1 -1
  20. package/dist/actions/get-current-cart/mock.js +13 -3
  21. package/dist/actions/get-current-cart/types.d.ts +7 -1
  22. package/dist/actions/get-product-modifier-selections/mock.js +12 -0
  23. package/dist/actions/get-products/mock.js +7 -3
  24. package/dist/actions/get-products/types.d.ts +1 -1
  25. package/dist/actions/get-refund-plan/mock.d.ts +0 -4
  26. package/dist/actions/get-refund-plan/mock.js +0 -8
  27. package/dist/actions/get-refund-plan/types.d.ts +0 -89
  28. package/dist/actions/set-product-modifier-selections/mock.js +12 -0
  29. package/dist/demo/database.d.ts +39 -1
  30. package/dist/demo/database.js +214 -1
  31. package/dist/index.d.ts +5 -1
  32. package/dist/index.js +4 -0
  33. package/dist/projects/render/mocks.js +4 -0
  34. package/dist/projects/render/types.d.ts +3 -1
  35. package/dist/pubsub/topics/products/catalog-visibility-changed/types.d.ts +17 -0
  36. package/dist/pubsub/topics/products/catalog-visibility-changed/types.js +1 -0
  37. package/dist/pubsub/topics/products/composite-changed/types.d.ts +17 -0
  38. package/dist/pubsub/topics/products/composite-changed/types.js +1 -0
  39. package/dist/pubsub/topics/products/index.d.ts +2 -2
  40. package/dist/pubsub/topics/products/index.js +33 -18
  41. package/dist/pubsub/topics/products/inventory-changed/types.d.ts +18 -0
  42. package/dist/pubsub/topics/products/inventory-changed/types.js +1 -0
  43. package/dist/pubsub/topics/products/types.d.ts +16 -10
  44. package/dist/pubsub/topics/products/types.js +7 -4
  45. package/package.json +2 -2
  46. package/dist/actions/get-refund-plan/mockRows.d.ts +0 -6
  47. package/dist/actions/get-refund-plan/mockRows.js +0 -114
@@ -0,0 +1,31 @@
1
+ import type { CFCompositePick } from '../../CommonTypes';
2
+ export interface GetCompositePriceParams {
3
+ /** The composite's variant — the one `addProductToCart` takes. */
4
+ variantId: string;
5
+ /** The picks so far, exactly as `addProductToCart({ composite })` would send them. */
6
+ composite: CFCompositePick[];
7
+ /** B41: the date and time a bookable pick is for, as `addProductToCart` takes it; without one a bookable pick is refused. */
8
+ compositeSlot?: {
9
+ startAt: string;
10
+ };
11
+ }
12
+ export interface GetCompositePriceResponse {
13
+ success: boolean;
14
+ /** Why these picks are refused — the same refusal and text `addProductToCart` would give. */
15
+ reason?: string;
16
+ /**
17
+ * What ONE composite with these picks adds to the cart, minor units: the line price the host would build plus the
18
+ * picked items' modifiers. Before discounts, fees and tax. Show it; do not recompute it. Absent when refused.
19
+ */
20
+ total?: number;
21
+ /**
22
+ * The parts that still need picks before the composite can be added, and how many ("Pick 2 more"); empty when every
23
+ * required part is filled. Decided by the host — keep Add to cart disabled while this is not empty.
24
+ */
25
+ missing: {
26
+ partId: string;
27
+ needed: number;
28
+ }[];
29
+ timestamp: string;
30
+ }
31
+ export type GetCompositePrice = (params: GetCompositePriceParams) => Promise<GetCompositePriceResponse>;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,2 +1,2 @@
1
- import { GetCurrentCart } from "./types";
1
+ import { GetCurrentCart } from './types';
2
2
  export declare const mockGetCurrentCart: GetCurrentCart;
@@ -1,9 +1,19 @@
1
- import { MOCK_CART, safeSerialize } from "../../demo/database";
1
+ import { MOCK_CART, buildModifierRows, safeSerialize } from '../../demo/database';
2
+ import { extendPrice } from '@final-commerce/common';
2
3
  export const mockGetCurrentCart = async () => {
3
- console.log("[Mock] getCurrentCart called");
4
+ console.log('[Mock] getCurrentCart called');
5
+ // The mock keeps no discounts or fees per line: a line is its price × quantity plus its modifier rows.
6
+ const lineTotals = Object.fromEntries([
7
+ ...MOCK_CART.products.map((line) => [
8
+ line.internalId ?? line.id,
9
+ extendPrice(line.price, line.quantity) + buildModifierRows(line.modifiers, line.quantity).modifiersTotal,
10
+ ]),
11
+ ...(MOCK_CART.customSales ?? []).map((sale) => [sale.id, extendPrice(sale.price, sale.quantity)]),
12
+ ]);
4
13
  return {
5
14
  success: true,
6
15
  cart: safeSerialize(MOCK_CART),
7
- timestamp: new Date().toISOString()
16
+ lineTotals,
17
+ timestamp: new Date().toISOString(),
8
18
  };
9
19
  };
@@ -1,7 +1,13 @@
1
- import { CFActiveCart } from "../../CommonTypes";
1
+ import { CFActiveCart } from '../../CommonTypes';
2
2
  export interface GetCurrentCartResponse {
3
3
  success: boolean;
4
4
  cart: CFActiveCart;
5
+ /**
6
+ * What each line adds to the cart, minor units, keyed by the line's `internalId` (products, bookings) or `id`
7
+ * (custom sales): price × quantity − its own discounts + its own fees + its modifiers. Before the cart discount
8
+ * and tax; Σ = the cart's subtotal before non-revenue items. Show it; do not recompute it.
9
+ */
10
+ lineTotals?: Record<string, number>;
5
11
  timestamp: string;
6
12
  }
7
13
  export type GetCurrentCart = () => Promise<GetCurrentCartResponse>;
@@ -16,6 +16,18 @@ export const mockGetProductModifierSelections = async (params) => {
16
16
  timestamp: new Date().toISOString(),
17
17
  };
18
18
  }
19
+ // Like the host (FT-83): a composite line's modifiers belong to its components.
20
+ if (line.components?.length) {
21
+ return {
22
+ success: false,
23
+ reason: 'The modifiers of a composite line cannot be edited yet: remove the line and add it again with its picks',
24
+ internalId: line.internalId,
25
+ selections: [],
26
+ rows: [],
27
+ modifiersTotal: 0,
28
+ timestamp: new Date().toISOString(),
29
+ };
30
+ }
19
31
  // The priced rows already sit on the line; extending them by the line quantity is
20
32
  // the host's job, not the flow's.
21
33
  const { rows, modifiersTotal } = buildModifierRows(line.modifiers, line.quantity);
@@ -1,9 +1,13 @@
1
- import { MOCK_PRODUCTS, safeSerialize } from '../../demo/database';
1
+ import { MOCK_PRODUCTS, mockCompositeAtOutlet, mockHiddenProductIds, safeSerialize } from '../../demo/database';
2
2
  import { resolveUnit } from '@final-commerce/common';
3
3
  export const mockGetProducts = async (params) => {
4
4
  console.log('[Mock] getProducts called', params);
5
- // Simple filter simulation
6
- let products = MOCK_PRODUCTS;
5
+ // Simple filter simulation. Like the host: a product hidden at this outlet is left out, and a composite offers only
6
+ // what is sold here.
7
+ const hidden = mockHiddenProductIds();
8
+ let products = MOCK_PRODUCTS.filter((p) => !hidden.has(p._id)).map((p) =>
9
+ // B43: the host never hands a composite modifiers of its own.
10
+ p.composite ? { ...p, modifiers: [], composite: mockCompositeAtOutlet(p.composite, hidden) } : p);
7
11
  const query = params?.query || {};
8
12
  // Same rule as the real host: a bookable service is not catalogue stock and is left out unless
9
13
  // the query names `productType`. Mirrored here on purpose — a mock that hands back services the
@@ -27,7 +27,7 @@ export interface GetProductsParams {
27
27
  };
28
28
  status?: string;
29
29
  /**
30
- * `simple` | `variable` | `booking`. Naming it at all switches OFF the default exclusion
30
+ * `simple` | `variable` | `booking` | `composite`. Naming it at all switches OFF the default exclusion
31
31
  * of bookable services — including `{ $ne: 'booking' }`, which then means what it says.
32
32
  */
33
33
  productType?: string | {
@@ -15,9 +15,5 @@ import { GetRefundPlan } from './types';
15
15
  * and no company cash-rounding setting, so it always describes a FULL refund
16
16
  * with `rounding: 0` and no cash `payout`. Against real kaching the allocation
17
17
  * tracks the live selection and carries the drawer snap.
18
- *
19
- * `rows` / `allocation.breakdown` follow the engine's reading of the order
20
- * (`line.total` includes the line's tax) but prorate by plain quantity share;
21
- * the engine's remainder carry across earlier partial refunds is not modelled.
22
18
  */
23
19
  export declare const mockGetRefundPlan: GetRefundPlan;
@@ -1,5 +1,4 @@
1
1
  import { MOCK_ORDERS } from '../../demo/database';
2
- import { mockRefundBreakdown, mockRefundRows } from './mockRows';
3
2
  /**
4
3
  * Demo derivation of the runtime `getRefundPlan`. Builds the per-source rows
5
4
  * from the mock order's `paymentMethods`, mirroring the runtime's capacity
@@ -16,10 +15,6 @@ import { mockRefundBreakdown, mockRefundRows } from './mockRows';
16
15
  * and no company cash-rounding setting, so it always describes a FULL refund
17
16
  * with `rounding: 0` and no cash `payout`. Against real kaching the allocation
18
17
  * tracks the live selection and carries the drawer snap.
19
- *
20
- * `rows` / `allocation.breakdown` follow the engine's reading of the order
21
- * (`line.total` includes the line's tax) but prorate by plain quantity share;
22
- * the engine's remainder carry across earlier partial refunds is not modelled.
23
18
  */
24
19
  export const mockGetRefundPlan = async (params) => {
25
20
  console.log('[Mock] getRefundPlan called', params);
@@ -67,19 +62,16 @@ export const mockGetRefundPlan = async (params) => {
67
62
  requiresGiftCardDestination: s.paymentType === 'redeem',
68
63
  }));
69
64
  const budget = legs.reduce((sum, l) => sum + l.amount, 0);
70
- const rows = mockRefundRows(order);
71
65
  return {
72
66
  success: true,
73
67
  orderId: order._id,
74
68
  sources,
75
- rows,
76
69
  allocation: {
77
70
  budget,
78
71
  // No cash rounding in the demo, so the goods value and the budget agree.
79
72
  itemTotal: budget,
80
73
  rounding: 0,
81
74
  legs,
82
- breakdown: mockRefundBreakdown(rows, params?.items),
83
75
  },
84
76
  // Demo: no prior refunds, so remaining = captured minus the non-revenue load.
85
77
  remainingRefundable: Math.max(0, totalCaptured - nonRefundableLiability),
@@ -79,81 +79,6 @@ export interface RefundPlanLeg {
79
79
  rounding: number;
80
80
  };
81
81
  }
82
- export type RefundPlanRowType = 'product' | 'customSale' | 'fee' | 'tip';
83
- /** One tax rate's share of a row's refund (minor units). */
84
- export interface RefundPlanTaxLine {
85
- name: string;
86
- /** Decimal rate as stored on the order (e.g. `0.15`), when the order recorded one. */
87
- percentage?: number;
88
- amount: number;
89
- }
90
- /**
91
- * The money a row refunds, split the way a receipt shows it. Every field is
92
- * minor units and already rounded; `subtotal − itemDiscount − cartDiscount +
93
- * tax === total` always holds. DISPLAY IT — never re-add or prorate it.
94
- */
95
- export interface RefundPlanAmounts {
96
- /** Before discounts, tax excluded. For a tip row, the tip itself. */
97
- subtotal: number;
98
- /** Per-item discounts on the refunded quantity (positive). */
99
- itemDiscount: number;
100
- /** Cart discount share on the refunded quantity (positive). */
101
- cartDiscount: number;
102
- /** Tax on the refunded quantity. Zero for a tip. */
103
- tax: number;
104
- /** `tax` per rate — the price breakdown's tax lines. */
105
- taxes: RefundPlanTaxLine[];
106
- /** What the row refunds. */
107
- total: number;
108
- }
109
- /**
110
- * One refundable row of the order — a line item, custom sale, cart fee or tip —
111
- * served ready to render. Rows with nothing left to refund are not listed.
112
- */
113
- export interface RefundPlanRow {
114
- type: RefundPlanRowType;
115
- /** The key `items[].itemKey` takes, for this call and for `processPartialRefund`. */
116
- itemKey: string;
117
- /** Line or fee name; `Tip` for a tip. */
118
- label: string;
119
- /** Product lines only, when the order recorded them. */
120
- sku?: string;
121
- attributes?: string;
122
- /** Tip rows only: the tender that took the tip (`card`, `cash`, …). */
123
- paymentType?: string;
124
- /** Quantity originally sold. Fees and tips are `1` — all or nothing. */
125
- quantity: number;
126
- /** Quantity still refundable — the stepper's max. */
127
- refundableQuantity: number;
128
- /** The money for refunding ALL of `refundableQuantity`. */
129
- amounts: RefundPlanAmounts;
130
- }
131
- /** One selected row and what refunding the selected quantity of it moves. */
132
- export interface RefundPlanSelectedRow {
133
- type: RefundPlanRowType;
134
- itemKey: string;
135
- quantity: number;
136
- amounts: RefundPlanAmounts;
137
- }
138
- /**
139
- * The selection's goods value broken down for display. Minor units;
140
- * `items − discounts + fees + tax + tip === total === allocation.itemTotal`.
141
- */
142
- export interface RefundPlanTotals {
143
- /** Σ selected line subtotals (before discounts, tax excluded). */
144
- items: number;
145
- /** Σ item + cart discounts on the selected lines (positive). */
146
- discounts: number;
147
- /** Σ selected cart fees, tax excluded. */
148
- fees: number;
149
- tax: number;
150
- tip: number;
151
- total: number;
152
- }
153
- export interface RefundPlanBreakdown {
154
- rows: RefundPlanSelectedRow[];
155
- totals: RefundPlanTotals;
156
- }
157
82
  /**
158
83
  * The engine's own allocation of the CURRENT refund selection across the
159
84
  * order's captures — what a flow renders and submits instead of computing a
@@ -181,25 +106,11 @@ export interface RefundPlanAllocation {
181
106
  rounding: number;
182
107
  /** One leg per source that receives money. Submit as `legs`, unchanged. */
183
108
  legs: RefundPlanLeg[];
184
- /**
185
- * What the selection refunds, per row and in total — the numbers a refund
186
- * dialog's rows and footer render. Optional so an older runtime still
187
- * type-checks; kaching 1.12.1+ always sends it.
188
- */
189
- breakdown?: RefundPlanBreakdown;
190
109
  }
191
110
  export interface GetRefundPlanResponse {
192
111
  success: boolean;
193
112
  orderId: string;
194
113
  sources: RefundPlanSource[];
195
- /**
196
- * Every row still refundable on the order, with the money for refunding all
197
- * of it — what a refund dialog lists before anything is selected. Replaces
198
- * reading line totals off the order and deciding whether they include tax.
199
- * Optional so an older runtime still type-checks; kaching 1.12.1+ always
200
- * sends it.
201
- */
202
- rows?: RefundPlanRow[];
203
114
  /**
204
115
  * Ready-to-submit allocation of the current selection. Present only when a
205
116
  * refund selection exists on the active order. See {@link RefundPlanAllocation}.
@@ -30,6 +30,18 @@ export const mockSetProductModifierSelections = async (params) => {
30
30
  timestamp: new Date().toISOString(),
31
31
  };
32
32
  }
33
+ // Like the host (FT-83): a composite line's modifiers belong to its components.
34
+ if (line.components?.length) {
35
+ return {
36
+ success: false,
37
+ reason: 'The modifiers of a composite line cannot be edited yet: remove the line and add it again with its picks',
38
+ internalId: line.internalId,
39
+ selections: [],
40
+ rows: [],
41
+ modifiersTotal: 0,
42
+ timestamp: new Date().toISOString(),
43
+ };
44
+ }
33
45
  // Full replacement — passing [] clears the rows. The line drops the product's resolved
34
46
  // stack when it is built, so the menu is re-read from the catalogue to price against.
35
47
  // Cart totals are not touched, for the same reason addProductToCart does not move them
@@ -2,7 +2,7 @@
2
2
  * Mock Database for Standalone/Demo Mode
3
3
  * Stores mock data that mimics the Render environment
4
4
  */
5
- import { CFActiveCompany, CFActiveUser, CFActiveStation, CFActiveOutlet, CFActiveOrder, CFCustomer, CFProduct, CFActiveCart, CFCategory, CFActiveProduct, CFSession, CFActiveRefundDetails, CFSmartGridLayout, CFBooking, CFBookingAvailability, CFBookingResource, CFCartLineModifier, CFProdModifierBreakdown, CFModifierSelection, CFResolvedModifier } from '../CommonTypes';
5
+ import { CFActiveCompany, CFActiveUser, CFActiveStation, CFActiveOutlet, CFActiveOrder, CFCustomer, CFProduct, CFComposite, CFCartLineComponent, CFActiveCart, CFCategory, CFActiveProduct, CFSession, CFActiveRefundDetails, CFSmartGridLayout, CFBooking, CFBookingAvailability, CFBookingResource, CFBookingSlot, CFCartLineModifier, CFProdModifierBreakdown, CFModifierSelection, CFResolvedModifier } from '../CommonTypes';
6
6
  export * from './mocks';
7
7
  /** Replace mock catalog / context data in place (same array references mock handlers use). */
8
8
  export interface MockDatabaseConfig {
@@ -19,6 +19,12 @@ export interface MockDatabaseConfig {
19
19
  bookingResources?: CFBookingResource[];
20
20
  /** Windows already taken when the dataset loads, so a calendar does not open empty. */
21
21
  bookings?: CFBooking[];
22
+ /** Products hidden at an outlet (the host's `catalog-visibility`, product-level rows). Empty = sold everywhere. */
23
+ catalogVisibility?: MockCatalogVisibility[];
24
+ }
25
+ export interface MockCatalogVisibility {
26
+ productId: string;
27
+ outletId: string;
22
28
  }
23
29
  export declare const MOCK_COMPANY: CFActiveCompany;
24
30
  export declare const MOCK_OUTLET_MAIN: CFActiveOutlet;
@@ -64,8 +70,40 @@ export declare const MOCK_OUTLETS: import("@final-commerce/common/pos-types").Ac
64
70
  export declare const MOCK_CUSTOMERS: import("@final-commerce/common/pos-types").ActiveCustomer[];
65
71
  export declare const MOCK_CATEGORIES: import("@final-commerce/common/pos-types").Category[];
66
72
  export declare const MOCK_PRODUCT_HAIRCUT: CFProduct;
73
+ export declare const MOCK_PRODUCT_PASTE_TRIO: CFProduct;
67
74
  export declare const MOCK_PRODUCTS: import("@final-commerce/common/pos-types").FullProduct[];
68
75
  export declare const MOCK_ORDERS: import("@final-commerce/common/pos-types").ActiveOrder[];
76
+ /** Nothing hidden by default; `setMockDatabase({ catalogVisibility })` hides products at an outlet. */
77
+ export declare const MOCK_CATALOG_VISIBILITY: MockCatalogVisibility[];
78
+ /**
79
+ * The host's cart stock check for composites (kaching `compositeStockRefusal`), over the demo shelves — a copy: what the
80
+ * cart takes of each component's variant (plain lines and components × their line quantity) against its shelf here.
81
+ * The demo has no pools or units, so it counts per variant; untracked and backordered variants are never short.
82
+ */
83
+ export declare function mockCompositeStockRefusal(products: {
84
+ variantId: string;
85
+ quantity: number;
86
+ components?: CFCartLineComponent[];
87
+ }[]): string | null;
88
+ /** Product ids hidden at the demo's active outlet. */
89
+ export declare const mockHiddenProductIds: () => Set<string>;
90
+ /**
91
+ * The host's outlet rules for a composite (kaching `resolveComposite`), over the demo's static view — a copy, change both
92
+ * in one step: an item whose product is hidden here is left out, a category item keeps only products sold here (none →
93
+ * `'empty'`), an Optional part with nothing to pick is not offered, a required one with nothing makes it unavailable,
94
+ * and the D36 default and "from" price are read off what is left.
95
+ */
96
+ /** B41: the seats a bookable demo product has free in the slot starting at `startAt` — as the host reads them. */
97
+ export declare function mockBookableWindow(productId: string, startAt: string): {
98
+ free: number;
99
+ slot: CFBookingSlot | null;
100
+ };
101
+ export declare const mockIsBookable: (productId: string) => boolean;
102
+ export declare function mockCompositeAtOutlet(composite: Omit<CFComposite, 'needsDate'>, hidden?: Set<string>,
103
+ /** B41: the window bookable items are judged in; absent = `by_date`. */
104
+ slot?: {
105
+ startAt: string;
106
+ }): CFComposite;
69
107
  export declare const MOCK_PARKED_ORDERS: CFActiveOrder[];
70
108
  export declare let MOCK_USER: CFActiveUser;
71
109
  export declare let MOCK_STATION: CFActiveStation;
@@ -3,7 +3,7 @@
3
3
  * Stores mock data that mimics the Render environment
4
4
  */
5
5
  import { CFProductType, CFUserTypes, CurrencyCode, } from '../CommonTypes';
6
- import { extendPrice, resolveUnit, toBase } from '@final-commerce/common';
6
+ import { cheapestCompositeItem, cheapestCompositePrice, extendPrice, resolveUnit, toBase, } from '@final-commerce/common';
7
7
  import { BookingRatePeriod, BookingResourceKind, BookingType, ReservationStatus } from '@final-commerce/common';
8
8
  export * from './mocks';
9
9
  // Asset Imports - Using Remote URLs to avoid build complexity with asset copying
@@ -228,6 +228,7 @@ const createSimpleProduct = (id, name, price, image, categories, description) =>
228
228
  maxPrice: price,
229
229
  status: 'active',
230
230
  productType: CFProductType.SIMPLE,
231
+ composite: null,
231
232
  taxTable: 'tax_standard',
232
233
  description,
233
234
  images: [image],
@@ -263,6 +264,7 @@ const createVariableProduct = (id, name, basePrice, largePrice, image, categorie
263
264
  maxPrice: largePrice,
264
265
  status: 'active',
265
266
  productType: CFProductType.VARIABLE,
267
+ composite: null,
266
268
  taxTable: 'tax_standard',
267
269
  description,
268
270
  images: [image],
@@ -658,7 +660,106 @@ export const MOCK_PRODUCT_HAIRCUT = {
658
660
  productType: CFProductType.BOOKING,
659
661
  bookingRulesId: 'rule_salon_30',
660
662
  };
663
+ // A composite (FT-83) as the host serves it: the merchant's price 27.00, items add only their upcharges (B31); each item
664
+ // keeps its own tax (`taxTable` empty). One item points at a deleted variant (unavailable), one is a whole category.
665
+ export const MOCK_PRODUCT_PASTE_TRIO = {
666
+ ...createSimpleProduct('prod_paste_trio', 'Paste Trio', 2700, basilAlmondImg, [MOCK_CATEGORY_PASTES], 'Pick a paste and a spicy one.'),
667
+ productType: CFProductType.COMPOSITE,
668
+ taxTable: '',
669
+ // B43: a composite has no modifiers of its own; its items carry theirs.
670
+ modifiers: [],
671
+ composite: {
672
+ available: true,
673
+ basePrice: 2700,
674
+ fromPrice: 2800,
675
+ parts: [
676
+ {
677
+ _id: 'part_paste',
678
+ name: 'Paste',
679
+ required: true,
680
+ min: 1,
681
+ max: 1,
682
+ defaultPick: { itemId: 'item_basil', variantId: 'prod_basil_almond_var_main' },
683
+ items: [
684
+ {
685
+ _id: 'item_basil',
686
+ variantId: 'prod_basil_almond_var_main',
687
+ categoryId: null,
688
+ name: 'Basil Almond Paste',
689
+ extraCharge: 0,
690
+ quantity: 1,
691
+ cost: 0,
692
+ unavailable: null,
693
+ choices: [
694
+ {
695
+ productId: 'prod_basil_almond',
696
+ variantId: 'prod_basil_almond_var_main',
697
+ name: 'Basil Almond Paste',
698
+ attributes: [],
699
+ // The picked product's own modifiers (B28): taxed at its table, sent in the pick.
700
+ modifiers: [
701
+ {
702
+ _id: 'mod_texture',
703
+ name: 'Texture',
704
+ selectionType: 'single',
705
+ required: false,
706
+ sortOrder: 0,
707
+ choices: [
708
+ { _id: 'choice_smooth', name: 'Smooth', price: 0, sortOrder: 0 },
709
+ { _id: 'choice_chunky', name: 'Chunky', price: 50, sortOrder: 1 },
710
+ ],
711
+ },
712
+ ],
713
+ },
714
+ ],
715
+ },
716
+ {
717
+ _id: 'item_gone',
718
+ variantId: 'prod_gone_var_main',
719
+ categoryId: null,
720
+ name: '',
721
+ extraCharge: 0,
722
+ quantity: 1,
723
+ cost: 0,
724
+ unavailable: 'deleted',
725
+ choices: [],
726
+ },
727
+ ],
728
+ },
729
+ {
730
+ _id: 'part_spicy',
731
+ name: null,
732
+ required: true,
733
+ min: 1,
734
+ max: 2,
735
+ // "Pick up to 2": no preselected pick (B29 = D36) — the till shows how many are still needed.
736
+ defaultPick: null,
737
+ items: [
738
+ {
739
+ _id: 'item_spicy',
740
+ variantId: null,
741
+ categoryId: MOCK_CATEGORY_SPICY.id,
742
+ name: 'Spicy',
743
+ extraCharge: 100,
744
+ quantity: 1,
745
+ cost: 100,
746
+ unavailable: null,
747
+ choices: [
748
+ {
749
+ productId: 'prod_habanero',
750
+ variantId: 'prod_habanero_var_main',
751
+ name: 'Habanero Paste',
752
+ attributes: [],
753
+ },
754
+ ],
755
+ },
756
+ ],
757
+ },
758
+ ],
759
+ },
760
+ };
661
761
  export const MOCK_PRODUCTS = [
762
+ MOCK_PRODUCT_PASTE_TRIO,
662
763
  MOCK_PRODUCT_BASIL_ALMOND,
663
764
  MOCK_PRODUCT_BEER,
664
765
  MOCK_PRODUCT_BEET,
@@ -676,6 +777,114 @@ export const MOCK_PRODUCTS = [
676
777
  MOCK_PRODUCT_HAIRCUT,
677
778
  ];
678
779
  export const MOCK_ORDERS = [MOCK_ORDER_1, MOCK_ORDER_2, MOCK_ORDER_3, MOCK_ORDER_4];
780
+ /** Nothing hidden by default; `setMockDatabase({ catalogVisibility })` hides products at an outlet. */
781
+ export const MOCK_CATALOG_VISIBILITY = [];
782
+ /**
783
+ * The host's cart stock check for composites (kaching `compositeStockRefusal`), over the demo shelves — a copy: what the
784
+ * cart takes of each component's variant (plain lines and components × their line quantity) against its shelf here.
785
+ * The demo has no pools or units, so it counts per variant; untracked and backordered variants are never short.
786
+ */
787
+ export function mockCompositeStockRefusal(products) {
788
+ const components = products.flatMap((product) => product.components ?? []);
789
+ const demand = new Map();
790
+ for (const product of products) {
791
+ if (product.components)
792
+ for (const component of product.components)
793
+ demand.set(component.variantId, (demand.get(component.variantId) ?? 0) + component.quantity * product.quantity);
794
+ else
795
+ demand.set(product.variantId, (demand.get(product.variantId) ?? 0) + product.quantity);
796
+ }
797
+ for (const component of components) {
798
+ const variant = MOCK_PRODUCTS.flatMap((product) => product.variants).find((candidate) => candidate._id === component.variantId);
799
+ if (!variant?.manageStock || variant.allowBackorder)
800
+ continue;
801
+ const left = variant.inventory?.find((row) => row.outletId === MOCK_OUTLET.id)?.stock ?? 0;
802
+ if ((demand.get(component.variantId) ?? 0) > left)
803
+ return `${component.name}: only ${left} left`;
804
+ }
805
+ return null;
806
+ }
807
+ /** Product ids hidden at the demo's active outlet. */
808
+ export const mockHiddenProductIds = () => new Set(MOCK_CATALOG_VISIBILITY.filter((row) => row.outletId === MOCK_OUTLET.id).map((row) => row.productId));
809
+ /**
810
+ * The host's outlet rules for a composite (kaching `resolveComposite`), over the demo's static view — a copy, change both
811
+ * in one step: an item whose product is hidden here is left out, a category item keeps only products sold here (none →
812
+ * `'empty'`), an Optional part with nothing to pick is not offered, a required one with nothing makes it unavailable,
813
+ * and the D36 default and "from" price are read off what is left.
814
+ */
815
+ /** B41: the seats a bookable demo product has free in the slot starting at `startAt` — as the host reads them. */
816
+ export function mockBookableWindow(productId, startAt) {
817
+ const at = new Date(startAt);
818
+ const slot = mockBookingAvailability(productId, at, new Date(+at + 86400000)).slots.find((candidate) => +new Date(candidate.startAt) === +at && candidate.canStart) ?? null;
819
+ return { free: slot?.free ?? 0, slot };
820
+ }
821
+ export const mockIsBookable = (productId) => MOCK_PRODUCTS.find((product) => product._id === productId)?.productType === CFProductType.BOOKING;
822
+ export function mockCompositeAtOutlet(composite, hidden = mockHiddenProductIds(),
823
+ /** B41: the window bookable items are judged in; absent = `by_date`. */
824
+ slot) {
825
+ // B41: a bookable item is judged by seats in the window, never by stock; with no window it waits for a date.
826
+ const bookable = (item) => !!item.variantId && item.choices.some((choice) => mockIsBookable(choice.productId));
827
+ const seatsFor = (item) => !slot
828
+ ? 'by_date'
829
+ : mockBookableWindow(item.choices[0].productId, slot.startAt).free >= item.quantity
830
+ ? null
831
+ : 'fully_booked';
832
+ const offered = (item) => !item.unavailable || item.unavailable === 'by_date';
833
+ // B35: a tracked variant without `quantity` on the demo shelf here (and no backorder) is sold out.
834
+ const soldOut = (variantId, quantity) => {
835
+ const variant = MOCK_PRODUCTS.flatMap((product) => product.variants).find((candidate) => candidate._id === variantId);
836
+ if (!variant?.manageStock || variant.allowBackorder)
837
+ return false;
838
+ return (variant.inventory?.find((row) => row.outletId === MOCK_OUTLET.id)?.stock ?? 0) < quantity;
839
+ };
840
+ // B36e: "N left" on a unit-sold tracked choice — the demo shelf already reads in the variant's own unit.
841
+ const withStockLeft = (choice) => {
842
+ const variant = MOCK_PRODUCTS.flatMap((product) => product.variants).find((candidate) => candidate._id === choice.variantId);
843
+ const onHand = variant?.inventory?.find((row) => row.outletId === MOCK_OUTLET.id)?.stock;
844
+ return variant?.unitId && variant.manageStock && typeof onHand === 'number'
845
+ ? { ...choice, stockLeft: onHand }
846
+ : choice;
847
+ };
848
+ const upcharge = (item) => ({
849
+ extraCharge: item.extraCharge,
850
+ quantity: item.quantity,
851
+ available: offered(item),
852
+ });
853
+ const parts = composite.parts
854
+ .map((part) => {
855
+ const items = part.items.flatMap((item) => {
856
+ if (item.variantId) {
857
+ if (item.choices.some((choice) => hidden.has(choice.productId)))
858
+ return [];
859
+ if (!item.unavailable && bookable(item))
860
+ return [{ ...item, unavailable: seatsFor(item) }];
861
+ return item.unavailable || !soldOut(item.variantId, item.quantity)
862
+ ? [{ ...item, choices: item.choices.map(withStockLeft) }]
863
+ : [{ ...item, unavailable: 'out_of_stock', choices: [] }];
864
+ }
865
+ const sold = item.choices.filter((choice) => !hidden.has(choice.productId));
866
+ const choices = sold.filter((choice) => !soldOut(choice.variantId, item.quantity));
867
+ const unavailable = item.unavailable ?? (!sold.length ? 'empty' : !choices.length ? 'out_of_stock' : null);
868
+ return [{ ...item, choices: unavailable ? [] : choices.map(withStockLeft), unavailable }];
869
+ });
870
+ const index = part.min === 1 && part.max === 1 ? cheapestCompositeItem(items.map(upcharge)) : undefined;
871
+ const cheapest = index === undefined ? undefined : items[index];
872
+ return {
873
+ ...part,
874
+ items,
875
+ defaultPick: cheapest?.variantId ? { itemId: cheapest._id, variantId: cheapest.variantId } : null,
876
+ };
877
+ })
878
+ .filter((part) => part.min > 0 || part.items.some(offered));
879
+ const cheapest = cheapestCompositePrice(parts.map((part) => ({ min: part.min, items: part.items.map(upcharge) })));
880
+ return {
881
+ ...composite,
882
+ available: composite.available && parts.every((part) => part.min === 0 || part.items.some(offered)),
883
+ needsDate: parts.some((part) => part.items.some(bookable)),
884
+ fromPrice: composite.basePrice === null || cheapest === undefined ? null : composite.basePrice + cheapest,
885
+ parts,
886
+ };
887
+ }
679
888
  export const MOCK_PARKED_ORDERS = [MOCK_PARKED_ORDER_1, MOCK_PARKED_ORDER_2];
680
889
  // Compatibility Exports (reassigned by setMockDatabase)
681
890
  export let MOCK_USER = MOCK_USERS[0];
@@ -760,6 +969,9 @@ export function setMockDatabase(config) {
760
969
  if (config.bookings !== undefined) {
761
970
  MOCK_BOOKINGS.splice(0, MOCK_BOOKINGS.length, ...config.bookings);
762
971
  }
972
+ if (config.catalogVisibility !== undefined) {
973
+ MOCK_CATALOG_VISIBILITY.splice(0, MOCK_CATALOG_VISIBILITY.length, ...config.catalogVisibility);
974
+ }
763
975
  if (MOCK_OUTLETS.length > 0) {
764
976
  Object.assign(MOCK_OUTLET_MAIN, MOCK_OUTLETS[0]);
765
977
  if (MOCK_OUTLET_MAIN.id === undefined && MOCK_OUTLET_MAIN._id !== undefined) {
@@ -1132,6 +1344,7 @@ export const buildModifierRows = (modifiers, lineQuantity) => {
1132
1344
  amount: extendPrice(modifier.unitPrice * modifier.quantity, quantity),
1133
1345
  tax: 0,
1134
1346
  ...(modifier.taxTableId ? { taxTableId: modifier.taxTableId } : {}),
1347
+ ...(modifier.componentIndex !== undefined ? { componentIndex: modifier.componentIndex } : {}),
1135
1348
  }));
1136
1349
  return { rows, modifiersTotal: rows.reduce((sum, row) => sum + row.amount, 0) };
1137
1350
  };
package/dist/index.d.ts CHANGED
@@ -29,6 +29,8 @@ export declare const command: {
29
29
  readonly addProductNote: import(".").AddProductNote;
30
30
  readonly addProductFee: import(".").AddProductFee;
31
31
  readonly getProductModifierSelections: import(".").GetProductModifierSelections;
32
+ readonly getCompositePrice: import(".").GetCompositePrice;
33
+ readonly getCompositeAvailability: import(".").GetCompositeAvailability;
32
34
  readonly setProductModifierSelections: import(".").SetProductModifierSelections;
33
35
  readonly setActiveProductFee: import(".").SetActiveProductFee;
34
36
  readonly setActiveProductDiscount: import(".").SetActiveProductDiscount;
@@ -160,7 +162,7 @@ export type { CalculateRefundTotal, CalculateRefundTotalParams, CalculateRefundT
160
162
  export type { GetRemainingRefundableQuantities, GetRemainingRefundableQuantitiesParams, GetRemainingRefundableQuantitiesResponse, } from './actions/get-remaining-refundable-quantities/types';
161
163
  export type { ProcessPartialRefund, ProcessPartialRefundParams, ProcessPartialRefundResponse, } from './actions/process-partial-refund/types';
162
164
  export type { RedeemRefund, RedeemRefundParams, RedeemRefundResponse } from './actions/redeem-refund/types';
163
- export type { GetRefundPlan, GetRefundPlanParams, GetRefundPlanResponse, RefundPlanSource, RefundPlanAllocation, RefundPlanLeg, RefundPlanRowType, RefundPlanTaxLine, RefundPlanAmounts, RefundPlanRow, RefundPlanSelectedRow, RefundPlanTotals, RefundPlanBreakdown, } from './actions/get-refund-plan/types';
165
+ export type { GetRefundPlan, GetRefundPlanParams, GetRefundPlanResponse, RefundPlanSource, RefundPlanAllocation, RefundPlanLeg, } from './actions/get-refund-plan/types';
164
166
  export type { CheckPermission, CheckPermissionParams, CheckPermissionResponse } from './actions/check-permission/types';
165
167
  export type { InitiateRefund, InitiateRefundParams, InitiateRefundResponse } from './actions/initiate-refund/types';
166
168
  export type { GetCurrentCart, GetCurrentCartResponse } from './actions/get-current-cart/types';
@@ -174,6 +176,8 @@ export type { GetFinalContext, GetFinalContextResponse } from './actions/get-fin
174
176
  export type { AddProductNote, AddProductNoteParams, AddProductNoteResponse } from './actions/add-product-note/types';
175
177
  export type { AddProductFee, AddProductFeeParams, AddProductFeeResponse } from './actions/add-product-fee/types';
176
178
  export type { GetProductModifierSelections, GetProductModifierSelectionsParams, GetProductModifierSelectionsResponse, ModifierSelection, ModifierChoiceSelection, } from './actions/get-product-modifier-selections/types';
179
+ export type { GetCompositePrice, GetCompositePriceParams, GetCompositePriceResponse, } from './actions/get-composite-price/types';
180
+ export type { GetCompositeAvailability, GetCompositeAvailabilityParams, GetCompositeAvailabilityResponse, CFCompositeSlot, } from './actions/get-composite-availability/types';
177
181
  export type { SetProductModifierSelections, SetProductModifierSelectionsParams, SetProductModifierSelectionsResponse, } from './actions/set-product-modifier-selections/types';
178
182
  export type { SetActiveProductFee, SetActiveProductFeeParams, SetActiveProductFeeResponse, } from './actions/set-active-product-fee/types';
179
183
  export type { SetActiveProductDiscount, SetActiveProductDiscountParams, SetActiveProductDiscountResponse, } from './actions/set-active-product-discount/types';