@final-commerce/command-frame 0.10.0-staging.5 → 0.10.0-staging.6

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.
@@ -7,8 +7,11 @@ export declare function compositePicksRefusal(composite: CFComposite, picks: CFC
7
7
  * left empty is not listed. A copy — change both in one step. */
8
8
  export declare function mockPicksStillNeeded(composite: CFComposite, picks: CFCompositePick[]): {
9
9
  partId: string;
10
+ name: string | null;
10
11
  needed: number;
11
12
  }[];
13
+ /** The parts whose picks already reach their `max` — a picker disables "+" on them. A copy of the host's. */
14
+ export declare const mockFullPartIds: (composite: CFComposite, picks: CFCompositePick[]) => string[];
12
15
  type MockCompositeLine = {
13
16
  price: number;
14
17
  components: CFCartLineComponent[];
@@ -275,17 +275,19 @@ function mockSelectionsRefusal(modifiers, selections) {
275
275
  }
276
276
  return null;
277
277
  }
278
+ const mockPartPicks = (part, picks) => picks
279
+ .filter((pick) => part.items.some((item) => item._id === pick.itemId))
280
+ .reduce((sum, pick) => sum + (pick.quantity ?? 1), 0);
278
281
  /** The host's `picksStillNeeded` (kaching `resolveComposite.ts`): per part, what is left of its min; an Optional part
279
282
  * left empty is not listed. A copy — change both in one step. */
280
283
  export function mockPicksStillNeeded(composite, picks) {
281
284
  return composite.parts.flatMap((part) => {
282
- const count = picks
283
- .filter((pick) => part.items.some((item) => item._id === pick.itemId))
284
- .reduce((sum, pick) => sum + (pick.quantity ?? 1), 0);
285
- const needed = Math.max(0, part.min - count);
286
- return needed > 0 ? [{ partId: part._id, needed }] : [];
285
+ const needed = Math.max(0, part.min - mockPartPicks(part, picks));
286
+ return needed > 0 ? [{ partId: part._id, name: part.name, needed }] : [];
287
287
  });
288
288
  }
289
+ /** The parts whose picks already reach their `max` — a picker disables "+" on them. A copy of the host's. */
290
+ export const mockFullPartIds = (composite, picks) => composite.parts.filter((part) => mockPartPicks(part, picks) >= part.max).map((part) => part._id);
289
291
  /** kaching `buildCompositeLine` (B31): a component's share of ONE composite = the composite price split by the picked
290
292
  * variant's price × units (all 0 → units) + its own upcharge; Σ = price. Item modifiers flat with `componentIndex`
291
293
  * and quantity × the component's units. A copy — change both in one step. */
@@ -5,9 +5,11 @@ export const mockGetCompositeAvailability = async (params) => {
5
5
  console.log('[Mock] getCompositeAvailability called', params);
6
6
  const timestamp = new Date().toISOString();
7
7
  const refuse = (reason) => ({ success: false, reason, slots: [], timeZone: null, timestamp });
8
- const product = MOCK_PRODUCTS.find((candidate) => candidate.variants.some((v) => v._id === params?.variantId));
8
+ if (!params?.variantId)
9
+ return refuse('variantId is required');
10
+ const product = MOCK_PRODUCTS.find((candidate) => candidate.variants.some((v) => v._id === params.variantId));
9
11
  if (!product)
10
- throw new Error(`Variant with ID ${params?.variantId} not found`);
12
+ return refuse(`Variant with ID ${params.variantId} not found`);
11
13
  if (!product.composite)
12
14
  return refuse(`${product.name} is not a composite: it takes no composite picks`);
13
15
  const composite = mockCompositeAtOutlet(product.composite);
@@ -1,6 +1,6 @@
1
1
  import { MOCK_CART, MOCK_PRODUCTS, buildModifierRows, mockCompositeAtOutlet, mockCompositeStockRefusal, mockHiddenProductIds, } from '../../demo/database';
2
- import { compositePicksRefusal, mockCompositeLine, mockNeedsDateRefusal, mockPicksStillNeeded, } from '../add-product-to-cart/mock';
3
- // The same line the mock addProductToCart builds, priced for ONE composite and added to nothing.
2
+ import { compositePicksRefusal, mockCompositeLine, mockFullPartIds, mockNeedsDateRefusal, mockPicksStillNeeded, } from '../add-product-to-cart/mock';
3
+ // The same line the mock addProductToCart builds, priced for `quantity` composites and added to nothing.
4
4
  export const mockGetCompositePrice = async (params) => {
5
5
  console.log('[Mock] getCompositePrice called', params);
6
6
  const timestamp = new Date().toISOString();
@@ -12,6 +12,7 @@ export const mockGetCompositePrice = async (params) => {
12
12
  success: false,
13
13
  reason: `${product.name} is not a composite: it takes no composite picks`,
14
14
  missing: [],
15
+ fullPartIds: [],
15
16
  timestamp,
16
17
  };
17
18
  }
@@ -19,19 +20,22 @@ export const mockGetCompositePrice = async (params) => {
19
20
  // What this outlet sells (the host's catalog-visibility rules).
20
21
  const composite = mockCompositeAtOutlet(product.composite, undefined, params.compositeSlot);
21
22
  const missing = mockPicksStillNeeded(composite, picks);
23
+ const fullPartIds = mockFullPartIds(composite, picks);
24
+ const quantity = params.quantity ?? 1;
22
25
  if (mockHiddenProductIds().has(product._id)) {
23
- return { success: false, reason: `${product.name} is not sold at this outlet`, missing, timestamp };
26
+ return { success: false, reason: `${product.name} is not sold at this outlet`, missing, fullPartIds, timestamp };
24
27
  }
25
28
  const line = mockNeedsDateRefusal(product.name, composite, params.compositeSlot) ??
26
29
  compositePicksRefusal(composite, picks) ??
27
30
  mockCompositeLine(composite, picks, !product.taxTable);
28
31
  if (typeof line === 'string')
29
- return { success: false, reason: line, missing, timestamp };
32
+ return { success: false, reason: line, missing, fullPartIds, timestamp };
30
33
  const shortOfStock = mockCompositeStockRefusal([
31
34
  ...MOCK_CART.products,
32
- { variantId: params.variantId, quantity: 1, components: line.components },
35
+ { variantId: params.variantId, quantity, components: line.components },
33
36
  ]);
34
37
  if (shortOfStock)
35
- return { success: false, reason: shortOfStock, missing, timestamp };
36
- return { success: true, total: line.price + buildModifierRows(line.modifiers, 1).modifiersTotal, missing, timestamp };
38
+ return { success: false, reason: shortOfStock, missing, fullPartIds, timestamp };
39
+ const unitTotal = line.price + buildModifierRows(line.modifiers, 1).modifiersTotal;
40
+ return { success: true, total: unitTotal * quantity, unitTotal, missing, fullPartIds, timestamp };
37
41
  };
@@ -8,24 +8,33 @@ export interface GetCompositePriceParams {
8
8
  compositeSlot?: {
9
9
  startAt: string;
10
10
  };
11
+ /** How many of this composite, as `addProductToCart({ quantity })` takes it. Defaults to 1. */
12
+ quantity?: number;
11
13
  }
12
14
  export interface GetCompositePriceResponse {
13
15
  success: boolean;
14
16
  /** Why these picks are refused — the same refusal and text `addProductToCart` would give. */
15
17
  reason?: string;
16
18
  /**
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
+ * What `quantity` composites with these picks add to the cart, minor units: `unitTotal` × `quantity`. Before
20
+ * discounts, fees and tax. Show it; do not recompute it. Absent when refused.
19
21
  */
20
22
  total?: number;
23
+ /** What ONE composite with these picks adds: the line price the host would build plus the picked items' modifiers,
24
+ * minor units. Absent when refused. */
25
+ unitTotal?: number;
21
26
  /**
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.
27
+ * The parts that still need picks before the composite can be added, with the part's `name` (null = untitled) and
28
+ * how many ("Pick 2 more side"); empty when every required part is filled. Decided by the host — keep Add to cart
29
+ * disabled while this is not empty.
24
30
  */
25
31
  missing: {
26
32
  partId: string;
33
+ name: string | null;
27
34
  needed: number;
28
35
  }[];
36
+ /** The parts whose picks already reach their `max` — disable "+" on them. Decided by the host. */
37
+ fullPartIds: string[];
29
38
  timestamp: string;
30
39
  }
31
40
  export type GetCompositePrice = (params: GetCompositePriceParams) => Promise<GetCompositePriceResponse>;
@@ -1,12 +1,22 @@
1
1
  import { MOCK_OUTLET, MOCK_PRODUCTS } from '../../demo/database';
2
2
  export const mockSetVariantUnavailable = async (params) => {
3
3
  console.log('[Mock] setVariantUnavailable called', params);
4
- if (!params?.variantId)
5
- throw new Error('variantId is required');
4
+ const answer = (success, reason) => ({
5
+ success,
6
+ reason,
7
+ variantId: params?.variantId ?? '',
8
+ outletId: MOCK_OUTLET.id,
9
+ unavailable: !!params?.unavailable,
10
+ timestamp: new Date().toISOString(),
11
+ });
12
+ // Same refusals and texts as the host, resolved instead of thrown.
13
+ if (!params?.variantId || typeof params.unavailable !== 'boolean') {
14
+ return answer(false, 'variantId and unavailable are required');
15
+ }
6
16
  const { variantId, unavailable } = params;
7
17
  const variant = MOCK_PRODUCTS.flatMap((product) => product.variants).find((v) => v._id === variantId);
8
18
  if (!variant)
9
- throw new Error(`Variant with ID ${variantId} not found`);
19
+ return answer(false, `Variant with ID ${variantId} not found`);
10
20
  variant.unavailable = unavailable;
11
- return { success: true, variantId, outletId: MOCK_OUTLET.id, unavailable, timestamp: new Date().toISOString() };
21
+ return answer(true);
12
22
  };
@@ -8,7 +8,9 @@ export interface SetVariantUnavailableParams {
8
8
  unavailable: boolean;
9
9
  }
10
10
  export interface SetVariantUnavailableResponse {
11
+ /** False when the host refused: missing params, no active outlet or company, unknown variant, offline. */
11
12
  success: boolean;
13
+ reason?: string;
12
14
  variantId: string;
13
15
  outletId: string;
14
16
  unavailable: boolean;
@@ -1,5 +1,5 @@
1
- import { MOCK_CART, MOCK_PRODUCTS, mockPublishEvent } from '../../demo/database';
2
- import { extendPrice, isValidQuantity } from '@final-commerce/common';
1
+ import { MOCK_BOOKINGS, MOCK_CART, MOCK_PRODUCTS, mockCompositeStockRefusal, mockPublishEvent, } from '../../demo/database';
2
+ import { ReservationStatus, extendPrice, isValidQuantity } from '@final-commerce/common';
3
3
  export const mockUpdateCartItemQuantity = (params) => {
4
4
  console.log('[Mock] updateCartItemQuantity called', params);
5
5
  if (!params?.internalId) {
@@ -16,6 +16,10 @@ export const mockUpdateCartItemQuantity = (params) => {
16
16
  }
17
17
  const product = MOCK_CART.products[productIndex];
18
18
  const previousQuantity = product.quantity;
19
+ // Same refusal and text as the host: a line holding bookings keeps its quantity — its holds are that many seats.
20
+ if (quantity !== 0 && MOCK_CART.reservations?.some((reservation) => reservation.lineItemInternalId === internalId)) {
21
+ throw new Error(`${product.name} holds a booking — its quantity is the seats held: remove the line and add it again with the new quantity (addProductToCart with compositeSlot)`);
22
+ }
19
23
  // The line resolved its unit when it was added; a piece-sold line has none and stays whole.
20
24
  if (quantity !== 0 &&
21
25
  !isValidQuantity(quantity, (product.unit ?? { unitId: 'piece', ratioToBase: 1, precision: 0 }))) {
@@ -26,6 +30,12 @@ export const mockUpdateCartItemQuantity = (params) => {
26
30
  // If quantity is 0, remove the item
27
31
  if (quantity === 0) {
28
32
  MOCK_CART.products.splice(productIndex, 1);
33
+ // Like the host's `dropCompositeLine`: the line's reservations leave with it and their holds are released.
34
+ const linked = (MOCK_CART.reservations ?? []).filter((reservation) => reservation.lineItemInternalId === internalId);
35
+ MOCK_CART.reservations = (MOCK_CART.reservations ?? []).filter((reservation) => !linked.includes(reservation));
36
+ for (const booking of MOCK_BOOKINGS.filter(({ id }) => linked.some(({ bookingId }) => bookingId === id))) {
37
+ booking.status = ReservationStatus.CANCELLED;
38
+ }
29
39
  // Recalculate totals
30
40
  const lineTotal = extendPrice(product.price, previousQuantity);
31
41
  MOCK_CART.subtotal -= lineTotal;
@@ -50,6 +60,12 @@ export const mockUpdateCartItemQuantity = (params) => {
50
60
  if (quantity > previousQuantity && (source?.notSoldInPos || variant?.notSoldInPos)) {
51
61
  throw new Error(`${source?.name} is not sold in POS`);
52
62
  }
63
+ // A raised composite line draws on its components' shelves, like the host's `compositeStockRefusal`.
64
+ if (quantity > previousQuantity && product.components?.length) {
65
+ const refusal = mockCompositeStockRefusal(MOCK_CART.products.map((line) => (line.internalId === internalId ? { ...line, quantity } : line)));
66
+ if (refusal)
67
+ throw new Error(refusal);
68
+ }
53
69
  // Update quantity
54
70
  product.quantity = quantity;
55
71
  // Difference of the two ROUNDED extensions — rounding the delta itself would let the
@@ -27,6 +27,11 @@ export const productsTopic = {
27
27
  name: 'Catalog Visibility Changed',
28
28
  description: 'Published when a product is hidden or shown at an outlet; re-ask getProducts',
29
29
  },
30
+ {
31
+ id: 'variant-unavailability-changed',
32
+ name: 'Variant Unavailability Changed',
33
+ description: 'Published when a variant is marked sold out at an outlet or unmarked; re-ask getProducts',
34
+ },
30
35
  {
31
36
  id: 'inventory-changed',
32
37
  name: 'Inventory Changed',
@@ -6,6 +6,7 @@ export * from './product-created/types';
6
6
  export * from './product-updated/types';
7
7
  export * from './composite-changed/types';
8
8
  export * from './catalog-visibility-changed/types';
9
+ export * from './variant-unavailability-changed/types';
9
10
  export * from './inventory-changed/types';
10
11
  export * from './product-set-active/types';
11
12
  export * from './product-get-active/types';
@@ -13,8 +14,9 @@ import type { ProductCreatedPayload } from './product-created/types';
13
14
  import type { ProductUpdatedPayload } from './product-updated/types';
14
15
  import type { CompositeChangedPayload } from './composite-changed/types';
15
16
  import type { CatalogVisibilityChangedPayload } from './catalog-visibility-changed/types';
17
+ import type { VariantUnavailabilityChangedPayload } from './variant-unavailability-changed/types';
16
18
  import type { InventoryChangedPayload } from './inventory-changed/types';
17
19
  import type { ProductSetActivePayload } from './product-set-active/types';
18
20
  import type { ProductGetActivePayload } from './product-get-active/types';
19
- export type ProductsEventPayload = ProductCreatedPayload | ProductUpdatedPayload | CompositeChangedPayload | CatalogVisibilityChangedPayload | InventoryChangedPayload | ProductSetActivePayload | ProductGetActivePayload;
20
- export type ProductsEventType = 'product-created' | 'product-updated' | 'composite-changed' | 'catalog-visibility-changed' | 'inventory-changed' | 'set-active-product' | 'get-active-product';
21
+ export type ProductsEventPayload = ProductCreatedPayload | ProductUpdatedPayload | CompositeChangedPayload | CatalogVisibilityChangedPayload | VariantUnavailabilityChangedPayload | InventoryChangedPayload | ProductSetActivePayload | ProductGetActivePayload;
22
+ export type ProductsEventType = 'product-created' | 'product-updated' | 'composite-changed' | 'catalog-visibility-changed' | 'variant-unavailability-changed' | 'inventory-changed' | 'set-active-product' | 'get-active-product';
@@ -7,6 +7,7 @@ export * from './product-created/types';
7
7
  export * from './product-updated/types';
8
8
  export * from './composite-changed/types';
9
9
  export * from './catalog-visibility-changed/types';
10
+ export * from './variant-unavailability-changed/types';
10
11
  export * from './inventory-changed/types';
11
12
  export * from './product-set-active/types';
12
13
  export * from './product-get-active/types';
@@ -0,0 +1,19 @@
1
+ import type { TopicEvent } from '../../../types';
2
+ /**
3
+ * Payload for variant-unavailability-changed: a variant was marked sold out at an outlet, or the mark was removed
4
+ * (`isDeleted`). `getProducts` reports the mark as `variant.unavailable` and a composite refuses a marked item —
5
+ * debounce, then re-ask `getProducts`.
6
+ */
7
+ export interface VariantUnavailabilityChangedPayload {
8
+ unavailability: {
9
+ _id: string;
10
+ productId: string;
11
+ variantId: string;
12
+ outletId: string;
13
+ isDeleted?: boolean;
14
+ };
15
+ }
16
+ /**
17
+ * Typed event for variant-unavailability-changed
18
+ */
19
+ export type VariantUnavailabilityChangedEvent = TopicEvent<VariantUnavailabilityChangedPayload>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@final-commerce/command-frame",
3
- "version": "0.10.0-staging.5",
3
+ "version": "0.10.0-staging.6",
4
4
  "description": "Commands Frame library",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -57,7 +57,7 @@
57
57
  "ignoreBranchesMissingTickets": true
58
58
  },
59
59
  "dependencies": {
60
- "@final-commerce/common": "2.6.0-staging.3"
60
+ "@final-commerce/common": "2.6.0-staging.4"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@commitlint/cli": "^19.0.0",