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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,24 +5,41 @@ export const mockAddBookingToCart = async (params) => {
5
5
  // One call, two effects — the window is claimed AND the service is in the cart. The claim goes
6
6
  // first and its refusal propagates: a cart line for a window somebody else took is worse than
7
7
  // no line at all.
8
- const { booking } = await mockHoldBooking(params);
8
+ const held = await mockHoldBooking(params);
9
+ if (!held.success || !held.booking) {
10
+ return { success: false, reason: held.reason, timestamp: new Date().toISOString() };
11
+ }
12
+ const booking = held.booking;
13
+ // An unknown product used to be sold as "Booking" at zero. The host refuses it, and a mock that
14
+ // quietly sells something no catalogue contains teaches a screen a case that cannot happen.
9
15
  const product = MOCK_PRODUCTS.find(({ _id }) => _id === params.productId);
16
+ if (!product) {
17
+ return { success: false, reason: `No product ${params.productId}`, timestamp: new Date().toISOString() };
18
+ }
19
+ // A named variant is honoured or refused, never substituted — the host does the same.
10
20
  const variant = params.variantId
11
- ? product?.variants?.find(({ _id }) => _id === params.variantId)
12
- : product?.variants?.[0];
13
- const price = variant?.price ?? product?.minPrice ?? 0;
21
+ ? product.variants?.find(({ _id }) => _id === params.variantId)
22
+ : product.variants?.[0];
23
+ if (params.variantId && !variant) {
24
+ return {
25
+ success: false,
26
+ reason: `${product.name} has no variant ${params.variantId}`,
27
+ timestamp: new Date().toISOString(),
28
+ };
29
+ }
30
+ const price = variant?.price ?? product.minPrice ?? 0;
14
31
  const reservation = {
15
32
  internalId: `res_${booking.id}`,
16
33
  bookingId: booking.id,
17
34
  productId: params.productId,
18
35
  variantId: variant?._id ?? null,
19
36
  resourceId: params.resourceId,
20
- name: product?.name ?? 'Booking',
37
+ name: product.name,
21
38
  resourceName: booking.resourceName,
22
39
  price,
23
40
  quantity: 1,
24
41
  total: price,
25
- taxTableId: product?.taxTable,
42
+ taxTableId: product.taxTable,
26
43
  startAt: booking.startAt,
27
44
  endAt: booking.endAt,
28
45
  bufferEndAt: booking.bufferEndAt,
@@ -37,6 +54,13 @@ export const mockAddBookingToCart = async (params) => {
37
54
  MOCK_CART.remainingBalance = MOCK_CART.total;
38
55
  // The cart topic, not the bookings one: a screen that only watches bookings still has to
39
56
  // repaint its cart, and every other cart mutation announces itself the same way.
40
- mockPublishEvent('cart', 'reservation-added', { reservation });
41
- return { booking, reservationInternalId: reservation.internalId, timestamp: new Date().toISOString() };
57
+ // `cart-created`, as the host publishes: the cart topic carries the whole cart, and a screen
58
+ // written against the host's event was not listening for a name only the mock used.
59
+ mockPublishEvent('cart', 'cart-created', { cart: MOCK_CART });
60
+ return {
61
+ success: true,
62
+ booking,
63
+ reservationInternalId: reservation.internalId,
64
+ timestamp: new Date().toISOString(),
65
+ };
42
66
  };
@@ -14,10 +14,14 @@ export interface AddBookingToCartParams {
14
14
  customerId?: string;
15
15
  }
16
16
  export interface AddBookingToCartResponse {
17
+ /** False when the command was refused — a taken window, a rule, a booking that is not yours. */
18
+ success: boolean;
19
+ /** Why it was refused, in words a cashier can act on. Absent on success. */
20
+ reason?: string;
17
21
  /** The claim the cart now stands on: `id` cancels or releases it, `expiresAt` is your clock. */
18
- booking: CFBooking;
22
+ booking?: CFBooking;
19
23
  /** Identity of the reservation inside the cart and, later, the order. */
20
- reservationInternalId: string;
24
+ reservationInternalId?: string;
21
25
  timestamp: string;
22
26
  }
23
27
  export type AddBookingToCart = (params: AddBookingToCartParams) => Promise<AddBookingToCartResponse>;
@@ -1,10 +1,31 @@
1
- import { MOCK_BOOKINGS } from '../../demo/database';
1
+ import { MOCK_BOOKINGS, MOCK_CART, mockPublishEvent } from '../../demo/database';
2
2
  import { ReservationStatus } from '@final-commerce/common';
3
3
  export const mockCancelBooking = async (params) => {
4
4
  console.log('[Mock] cancelBooking called', params);
5
5
  const booking = MOCK_BOOKINGS.find(({ id }) => id === params.bookingId);
6
- if (!booking)
7
- throw new Error(`No booking ${params.bookingId}`);
6
+ if (!booking) {
7
+ return { success: false, reason: `No booking ${params.bookingId}`, timestamp: new Date().toISOString() };
8
+ }
9
+ // A paid booking is not cancellable: the window would be freed while the money stays taken.
10
+ if (booking.status === ReservationStatus.CONFIRMED && booking.orderId) {
11
+ return {
12
+ success: false,
13
+ reason: `That booking is paid for on order ${booking.orderId} — refund the service instead`,
14
+ timestamp: new Date().toISOString(),
15
+ };
16
+ }
8
17
  booking.status = ReservationStatus.CANCELLED;
9
- return { booking, timestamp: new Date().toISOString() };
18
+ // A window given away may be the one an open cart is selling, so the cart follows — the host
19
+ // does this, and a mock that left the line behind sold time that had just been released.
20
+ const index = (MOCK_CART.reservations ?? []).findIndex((entry) => entry.bookingId === params.bookingId);
21
+ if (index >= 0) {
22
+ const [removed] = MOCK_CART.reservations.splice(index, 1);
23
+ const line = removed.total || removed.price * (removed.quantity ?? 1);
24
+ MOCK_CART.subtotal -= line;
25
+ MOCK_CART.total -= line;
26
+ MOCK_CART.amountToBeCharged = MOCK_CART.total;
27
+ MOCK_CART.remainingBalance = MOCK_CART.total;
28
+ mockPublishEvent('cart', 'cart-created', { cart: MOCK_CART });
29
+ }
30
+ return { success: true, booking, timestamp: new Date().toISOString() };
10
31
  };
@@ -4,7 +4,11 @@ export interface CancelBookingParams {
4
4
  bookingId: string;
5
5
  }
6
6
  export interface CancelBookingResponse {
7
- booking: CFBooking;
7
+ /** False when the command was refused — a taken window, a rule, a booking that is not yours. */
8
+ success: boolean;
9
+ /** Why it was refused, in words a cashier can act on. Absent on success. */
10
+ reason?: string;
11
+ booking?: CFBooking;
8
12
  timestamp: string;
9
13
  }
10
14
  export type CancelBooking = (params: CancelBookingParams) => Promise<CancelBookingResponse>;
@@ -1,6 +1,12 @@
1
1
  import { mockBookingAvailability } from '../../demo/database';
2
2
  export const mockGetBookingAvailability = async (params) => {
3
3
  console.log('[Mock] getBookingAvailability called', params);
4
- const availability = mockBookingAvailability(params.productId, new Date(params.from), new Date(params.to), params.resourceId);
5
- return { availability, timestamp: new Date().toISOString() };
4
+ // `fromDay` + `days` is the shape a calendar actually asks in; the mock resolves it on the
5
+ // machine's clock, which is the only one it has.
6
+ const start = params.fromDay ? new Date(`${params.fromDay}T00:00:00`) : params.from ? new Date(params.from) : new Date();
7
+ const end = params.to
8
+ ? new Date(params.to)
9
+ : new Date(start.getFullYear(), start.getMonth(), start.getDate() + Math.max(1, params.days ?? 1));
10
+ const availability = mockBookingAvailability(params.productId, start, end, params.resourceId);
11
+ return { success: true, availability, timestamp: new Date().toISOString() };
6
12
  };
@@ -2,17 +2,36 @@ import { CFBookingAvailability } from '../../CommonTypes';
2
2
  export interface GetBookingAvailabilityParams {
3
3
  /** The bookable product (`productType: 'booking'`) whose calendar you are drawing. */
4
4
  productId: string;
5
- /** Range start, ISO 8601. */
6
- from: string;
7
- /** Range end, ISO 8601. */
8
- to: string;
5
+ /**
6
+ * Range start, ISO 8601. Optional when `fromDay` is given.
7
+ *
8
+ * Prefer `fromDay`: a calendar asks about DAYS, and turning "this day at this shop" into two
9
+ * instants needs the shop's zone — which the host has and the caller has to go and find. A
10
+ * caller that widened the range by a day at each end and filtered the answer afterwards was
11
+ * working around the absence of `fromDay`.
12
+ */
13
+ from?: string;
14
+ /** Range end, ISO 8601. Optional when `fromDay` is given. */
15
+ to?: string;
16
+ /**
17
+ * First shop day to cover, `YYYY-MM-DD`. Resolved to local midnight in the shop's own zone,
18
+ * so a day that is 23 or 25 hours long because the clocks moved is still exactly one day.
19
+ * Omit both this and `from`/`to` to get today at the shop.
20
+ */
21
+ fromDay?: string;
22
+ /** How many shop days from `fromDay`. Default 1. */
23
+ days?: number;
9
24
  /** Narrow the answer to one resource — "only Marco", "only room 4". */
10
25
  resourceId?: string;
11
26
  /** Narrow to the resources standing at one outlet. */
12
27
  outletId?: string;
13
28
  }
14
29
  export interface GetBookingAvailabilityResponse {
15
- availability: CFBookingAvailability;
30
+ /** False when the command was refused — a taken window, a rule, a booking that is not yours. */
31
+ success: boolean;
32
+ /** Why it was refused, in words a cashier can act on. Absent on success. */
33
+ reason?: string;
34
+ availability?: CFBookingAvailability;
16
35
  timestamp: string;
17
36
  }
18
37
  export type GetBookingAvailability = (params: GetBookingAvailabilityParams) => Promise<GetBookingAvailabilityResponse>;
@@ -3,5 +3,5 @@ export const mockGetBookingResources = async (params) => {
3
3
  console.log('[Mock] getBookingResources called', params);
4
4
  // A resource knows nothing about outlets — where it works lives in its own records, which the
5
5
  // host reads. The mock has no such records, so `outletId` narrows nothing here.
6
- return { resources: MOCK_BOOKING_RESOURCES, timestamp: new Date().toISOString() };
6
+ return { success: true, resources: MOCK_BOOKING_RESOURCES, timestamp: new Date().toISOString() };
7
7
  };
@@ -6,7 +6,11 @@ export interface GetBookingResourcesParams {
6
6
  outletId?: string;
7
7
  }
8
8
  export interface GetBookingResourcesResponse {
9
- resources: CFBookingResource[];
9
+ /** False when the command was refused — a taken window, a rule, a booking that is not yours. */
10
+ success: boolean;
11
+ /** Why it was refused, in words a cashier can act on. Absent on success. */
12
+ reason?: string;
13
+ resources?: CFBookingResource[];
10
14
  timestamp: string;
11
15
  }
12
16
  export type GetBookingResources = (params?: GetBookingResourcesParams) => Promise<GetBookingResourcesResponse>;
@@ -16,5 +16,5 @@ export const mockGetBookings = async (params) => {
16
16
  const to = new Date(params.to);
17
17
  bookings = bookings.filter((entry) => new Date(entry.startAt) < to);
18
18
  }
19
- return { bookings, timestamp: new Date().toISOString() };
19
+ return { success: true, bookings, timestamp: new Date().toISOString() };
20
20
  };
@@ -12,7 +12,11 @@ export interface GetBookingsParams {
12
12
  includeExpired?: boolean;
13
13
  }
14
14
  export interface GetBookingsResponse {
15
- bookings: CFBooking[];
15
+ /** False when the command was refused — a taken window, a rule, a booking that is not yours. */
16
+ success: boolean;
17
+ /** Why it was refused, in words a cashier can act on. Absent on success. */
18
+ reason?: string;
19
+ bookings?: CFBooking[];
16
20
  timestamp: string;
17
21
  }
18
22
  export type GetBookings = (params?: GetBookingsParams) => Promise<GetBookingsResponse>;
@@ -15,5 +15,9 @@ 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.
18
22
  */
19
23
  export declare const mockGetRefundPlan: GetRefundPlan;
@@ -1,4 +1,5 @@
1
1
  import { MOCK_ORDERS } from '../../demo/database';
2
+ import { mockRefundBreakdown, mockRefundRows } from './mockRows';
2
3
  /**
3
4
  * Demo derivation of the runtime `getRefundPlan`. Builds the per-source rows
4
5
  * from the mock order's `paymentMethods`, mirroring the runtime's capacity
@@ -15,6 +16,10 @@ import { MOCK_ORDERS } from '../../demo/database';
15
16
  * and no company cash-rounding setting, so it always describes a FULL refund
16
17
  * with `rounding: 0` and no cash `payout`. Against real kaching the allocation
17
18
  * 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.
18
23
  */
19
24
  export const mockGetRefundPlan = async (params) => {
20
25
  console.log('[Mock] getRefundPlan called', params);
@@ -62,16 +67,19 @@ export const mockGetRefundPlan = async (params) => {
62
67
  requiresGiftCardDestination: s.paymentType === 'redeem',
63
68
  }));
64
69
  const budget = legs.reduce((sum, l) => sum + l.amount, 0);
70
+ const rows = mockRefundRows(order);
65
71
  return {
66
72
  success: true,
67
73
  orderId: order._id,
68
74
  sources,
75
+ rows,
69
76
  allocation: {
70
77
  budget,
71
78
  // No cash rounding in the demo, so the goods value and the budget agree.
72
79
  itemTotal: budget,
73
80
  rounding: 0,
74
81
  legs,
82
+ breakdown: mockRefundBreakdown(rows, params?.items),
75
83
  },
76
84
  // Demo: no prior refunds, so remaining = captured minus the non-revenue load.
77
85
  remainingRefundable: Math.max(0, totalCaptured - nonRefundableLiability),
@@ -0,0 +1,6 @@
1
+ import type { CFActiveOrder } from '../../CommonTypes';
2
+ import type { GetRefundPlanParams, RefundPlanBreakdown, RefundPlanRow } from './types';
3
+ /** Every row of a demo order. The demo has no refund ledger, so everything is refundable. */
4
+ export declare function mockRefundRows(order: CFActiveOrder): RefundPlanRow[];
5
+ /** The selection's breakdown; no `items` means everything, like the demo's full-refund legs. */
6
+ export declare function mockRefundBreakdown(rows: RefundPlanRow[], items?: GetRefundPlanParams['items']): RefundPlanBreakdown;
@@ -0,0 +1,114 @@
1
+ // Demo derivation of `getRefundPlan`'s `rows` and `allocation.breakdown`, for
2
+ // local/standalone mode only. See the note on `mockGetRefundPlan`.
3
+ const minor = (v) => Math.round(Number(v ?? 0)) || 0;
4
+ /** A line's whole-quantity money, reading `total` as tax-inclusive (the engine's reading). */
5
+ function lineAmounts(line) {
6
+ const tax = minor(line.totalTax);
7
+ const itemDiscount = (line.discount?.itemDiscounts ?? []).reduce((s, d) => s + minor(d.amount), 0);
8
+ const cartDiscount = minor(line.discount?.cartDiscount?.amount);
9
+ const net = Math.max(0, minor(line.total) - tax);
10
+ const taxes = (line.taxes ?? []).map((t) => ({
11
+ name: String(t.name ?? 'Tax'),
12
+ percentage: t.percentage,
13
+ amount: minor(t.amount),
14
+ }));
15
+ return { subtotal: net + itemDiscount + cartDiscount, itemDiscount, cartDiscount, tax, taxes, total: net + tax };
16
+ }
17
+ function scale(a, share) {
18
+ if (share >= 1)
19
+ return a;
20
+ const r = (n) => Math.round(n * share);
21
+ const out = {
22
+ subtotal: r(a.subtotal),
23
+ itemDiscount: r(a.itemDiscount),
24
+ cartDiscount: r(a.cartDiscount),
25
+ tax: r(a.tax),
26
+ taxes: a.taxes.map((t) => ({ ...t, amount: r(t.amount) })),
27
+ };
28
+ return { ...out, total: out.subtotal - out.itemDiscount - out.cartDiscount + out.tax };
29
+ }
30
+ /** Every row of a demo order. The demo has no refund ledger, so everything is refundable. */
31
+ export function mockRefundRows(order) {
32
+ const rows = [];
33
+ for (const li of order.lineItems ?? []) {
34
+ const itemKey = String(li.internalId || li.variantId || '');
35
+ if (!itemKey || !(li.quantity > 0))
36
+ continue;
37
+ rows.push({
38
+ type: 'product',
39
+ itemKey,
40
+ label: String(li.name ?? 'Item'),
41
+ ...(li.sku ? { sku: li.sku } : {}),
42
+ ...(li.attributes ? { attributes: li.attributes } : {}),
43
+ quantity: li.quantity,
44
+ refundableQuantity: li.quantity,
45
+ amounts: lineAmounts(li),
46
+ });
47
+ }
48
+ for (const cs of order.customSales ?? []) {
49
+ if (!cs.customSaleId || !(cs.quantity > 0))
50
+ continue;
51
+ rows.push({
52
+ type: 'customSale',
53
+ itemKey: cs.customSaleId,
54
+ label: String(cs.name ?? 'Custom sale'),
55
+ quantity: cs.quantity,
56
+ refundableQuantity: cs.quantity,
57
+ amounts: lineAmounts(cs),
58
+ });
59
+ }
60
+ for (const fee of order.cartFees ?? []) {
61
+ const amount = minor(fee.amount);
62
+ const tax = minor(fee.tax);
63
+ rows.push({
64
+ type: 'fee',
65
+ itemKey: String(fee.id),
66
+ label: String(fee.label || 'Cart fee'),
67
+ quantity: 1,
68
+ refundableQuantity: 1,
69
+ amounts: { subtotal: amount, itemDiscount: 0, cartDiscount: 0, tax, taxes: [], total: amount + tax },
70
+ });
71
+ }
72
+ for (const pm of order.paymentMethods ?? []) {
73
+ const tip = minor(pm.tip?.amount);
74
+ if (tip <= 0)
75
+ continue;
76
+ rows.push({
77
+ type: 'tip',
78
+ itemKey: pm.transactionId,
79
+ label: 'Tip',
80
+ paymentType: pm.paymentType,
81
+ quantity: 1,
82
+ refundableQuantity: 1,
83
+ amounts: { subtotal: tip, itemDiscount: 0, cartDiscount: 0, tax: 0, taxes: [], total: tip },
84
+ });
85
+ }
86
+ return rows;
87
+ }
88
+ /** The selection's breakdown; no `items` means everything, like the demo's full-refund legs. */
89
+ export function mockRefundBreakdown(rows, items) {
90
+ const picked = new Map((items ?? []).map((i) => [i.itemKey, i.quantity]));
91
+ const selected = rows
92
+ .map((row) => {
93
+ const quantity = items?.length
94
+ ? Math.min(picked.get(row.itemKey) ?? 0, row.refundableQuantity)
95
+ : row.refundableQuantity;
96
+ return { type: row.type, itemKey: row.itemKey, quantity, amounts: scale(row.amounts, quantity / row.quantity) };
97
+ })
98
+ .filter((r) => r.quantity > 0);
99
+ const totals = { items: 0, discounts: 0, fees: 0, tax: 0, tip: 0, total: 0 };
100
+ for (const r of selected) {
101
+ const a = r.amounts;
102
+ if (r.type === 'fee')
103
+ totals.fees += a.subtotal;
104
+ else if (r.type === 'tip')
105
+ totals.tip += a.subtotal;
106
+ else {
107
+ totals.items += a.subtotal;
108
+ totals.discounts += a.itemDiscount + a.cartDiscount;
109
+ }
110
+ totals.tax += a.tax;
111
+ totals.total += a.total;
112
+ }
113
+ return { rows: selected, totals };
114
+ }
@@ -79,6 +79,81 @@ 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
+ }
82
157
  /**
83
158
  * The engine's own allocation of the CURRENT refund selection across the
84
159
  * order's captures — what a flow renders and submits instead of computing a
@@ -106,11 +181,25 @@ export interface RefundPlanAllocation {
106
181
  rounding: number;
107
182
  /** One leg per source that receives money. Submit as `legs`, unchanged. */
108
183
  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;
109
190
  }
110
191
  export interface GetRefundPlanResponse {
111
192
  success: boolean;
112
193
  orderId: string;
113
194
  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[];
114
203
  /**
115
204
  * Ready-to-submit allocation of the current selection. Present only when a
116
205
  * refund selection exists on the active order. See {@link RefundPlanAllocation}.
@@ -2,6 +2,7 @@ import { MOCK_BOOKINGS, MOCK_BOOKING_RESOURCES, mockLiveBookings } from '../../d
2
2
  import { ReservationStatus } from '@final-commerce/common';
3
3
  const BUFFER_MINUTES = 5;
4
4
  const HOLD_MINUTES = 10;
5
+ let held = 0;
5
6
  export const mockHoldBooking = async (params) => {
6
7
  console.log('[Mock] holdBooking called', params);
7
8
  const startAt = new Date(params.startAt);
@@ -12,10 +13,13 @@ export const mockHoldBooking = async (params) => {
12
13
  const clash = mockLiveBookings().some((entry) => entry.resourceId === params.resourceId &&
13
14
  new Date(entry.startAt) < bufferEndAt &&
14
15
  new Date(entry.bufferEndAt) > startAt);
15
- if (clash)
16
- throw new Error('That window has just been taken — pick another slot.');
16
+ if (clash) {
17
+ return { success: false, reason: 'That window has just been taken — pick another slot.', timestamp: new Date().toISOString() };
18
+ }
17
19
  const booking = {
18
- id: `bk_mock_${Date.now()}`,
20
+ // A counter, not a clock: two holds in the same millisecond used to share an id, and cancel
21
+ // or remove then acted on the wrong row. Scripted tests hit that routinely.
22
+ id: `bk_mock_${(held += 1)}`,
19
23
  productId: params.productId,
20
24
  resourceId: params.resourceId,
21
25
  variantId: params.variantId,
@@ -29,5 +33,5 @@ export const mockHoldBooking = async (params) => {
29
33
  };
30
34
  // Held in the same list availability reads, so the slot really does disappear.
31
35
  MOCK_BOOKINGS.push(booking);
32
- return { booking, timestamp: new Date().toISOString() };
36
+ return { success: true, booking, timestamp: new Date().toISOString() };
33
37
  };
@@ -7,7 +7,7 @@ export interface HoldBookingParams {
7
7
  startAt: string;
8
8
  /**
9
9
  * End of the window — the `endAt` of the slot you picked, or of the last slot in a stay.
10
- * Required: the server refuses a window it cannot line up with its own grid, and the till
10
+ * Required: a window that cannot be lined up with the grid is refused, and the till
11
11
  * has no local copy of the rules to derive it from.
12
12
  */
13
13
  endAt: string;
@@ -17,8 +17,12 @@ export interface HoldBookingParams {
17
17
  customerId?: string;
18
18
  }
19
19
  export interface HoldBookingResponse {
20
+ /** False when the command was refused — a taken window, a rule, a booking that is not yours. */
21
+ success: boolean;
22
+ /** Why it was refused, in words a cashier can act on. Absent on success. */
23
+ reason?: string;
20
24
  /** The claim. `expiresAt` says how long it survives unpaid; `id` is what you confirm or cancel. */
21
- booking: CFBooking;
25
+ booking?: CFBooking;
22
26
  timestamp: string;
23
27
  }
24
28
  export type HoldBooking = (params: HoldBookingParams) => Promise<HoldBookingResponse>;
@@ -1,3 +1,4 @@
1
+ import { ReservationStatus } from '@final-commerce/common';
1
2
  import { MOCK_BOOKINGS, MOCK_CART, mockPublishEvent } from '../../demo/database';
2
3
  export const mockRemoveBookingFromCart = async (params) => {
3
4
  console.log('[Mock] removeBookingFromCart called', params);
@@ -10,15 +11,28 @@ export const mockRemoveBookingFromCart = async (params) => {
10
11
  MOCK_CART.total -= line;
11
12
  MOCK_CART.amountToBeCharged = MOCK_CART.total;
12
13
  MOCK_CART.remainingBalance = MOCK_CART.total;
13
- mockPublishEvent('cart', 'reservation-removed', { reservation: removed });
14
+ mockPublishEvent('cart', 'cart-created', { cart: MOCK_CART });
14
15
  }
15
16
  // The hold has to go back, or the mock is a trap: `addBookingToCart` pushes into the very list
16
17
  // availability reads, so add → remove → add the same slot used to refuse forever with "that
17
18
  // window has just been taken". The reservation id is `res_<booking.id>`, the link back to the row.
18
19
  const bookingId = removed?.bookingId ?? params.reservationInternalId.replace(/^res_/, '');
19
- const index = MOCK_BOOKINGS.findIndex(({ id }) => id === bookingId);
20
- const [released] = index >= 0 ? MOCK_BOOKINGS.splice(index, 1) : [];
20
+ // CANCELLED, not deleted — the host releases a window by moving the row, and a mock that
21
+ // erased it hid both the trail and the fact that a released booking is still a booking.
22
+ const released = MOCK_BOOKINGS.find(({ id }) => id === bookingId);
23
+ if (released)
24
+ released.status = ReservationStatus.CANCELLED;
25
+ // An id the cart does not have is a refusal, as it is on the host — resolving with an empty
26
+ // booking made a failed remove look like a successful one.
27
+ if (!removed) {
28
+ return {
29
+ success: false,
30
+ reason: `Reservation ${params.reservationInternalId} is not in the cart`,
31
+ timestamp: new Date().toISOString(),
32
+ };
33
+ }
21
34
  return {
35
+ success: true,
22
36
  reservationInternalId: params.reservationInternalId,
23
37
  booking: released,
24
38
  timestamp: new Date().toISOString(),
@@ -4,7 +4,11 @@ export interface RemoveBookingFromCartParams {
4
4
  reservationInternalId: string;
5
5
  }
6
6
  export interface RemoveBookingFromCartResponse {
7
- reservationInternalId: string;
7
+ /** False when the command was refused — a taken window, a rule, a booking that is not yours. */
8
+ success: boolean;
9
+ /** Why it was refused, in words a cashier can act on. Absent on success. */
10
+ reason?: string;
11
+ reservationInternalId?: string;
8
12
  /** The released hold, so the caller can see it is no longer held. */
9
13
  booking?: CFBooking;
10
14
  timestamp: string;
@@ -94,6 +94,12 @@ export declare const createOrderFromCart: (paymentType: string, amount: number,
94
94
  export declare const applyMockPayment: (amount: number, paymentType: string, processor?: string) => CFActiveOrder | null;
95
95
  export declare const MOCK_BOOKING_RULES_ID = "rule_salon_30";
96
96
  export declare const MOCK_BOOKING_RESOURCES: CFBookingResource[];
97
+ /**
98
+ * The clock this mock shop keeps — the same one `getContext` reports. They used to disagree: the
99
+ * slot hours were built with `setHours`, which is the MACHINE's clock, while the context claimed
100
+ * Vancouver. On a machine in Paris a correct screen showed a salon open from midnight to nine.
101
+ */
102
+ export declare const MOCK_SHOP_TIME_ZONE = "America/Vancouver";
97
103
  export declare const MOCK_BOOKINGS: CFBooking[];
98
104
  /** Live = confirmed, or held and not yet expired. An expired hold occupies nothing. */
99
105
  export declare const mockLiveBookings: () => CFBooking[];
@@ -928,6 +928,18 @@ export const createOrderFromCart = (paymentType, amount, processor = 'cash') =>
928
928
  createdAt: new Date().toISOString(),
929
929
  };
930
930
  MOCK_ORDERS.push(newOrder);
931
+ // Payment is what turns a held window into a kept appointment, so the ROW moves too — the
932
+ // comment above said CONFIRMED while nothing set it. Left held, a paid appointment dropped out
933
+ // of `getBookings` when its deadline passed and the window could be taken again, which is the
934
+ // one transition a booking screen most needs to be able to test.
935
+ for (const reservation of newOrder.reservations ?? []) {
936
+ const row = MOCK_BOOKINGS.find(({ id }) => id === reservation.bookingId);
937
+ if (row) {
938
+ row.status = ReservationStatus.CONFIRMED;
939
+ row.orderId = newOrder._id;
940
+ row.expiresAt = undefined;
941
+ }
942
+ }
931
943
  resetMockCart();
932
944
  // Publish cart-created event after cart is reset (simulates new empty cart)
933
945
  mockPublishEvent('cart', 'cart-created', {});
@@ -970,14 +982,38 @@ const CLOSE_HOUR = 18;
970
982
  // Rooms, not people: a resource is whatever is scarce, and a room is the case that
971
983
  // reads the same in every vertical a dataset might describe.
972
984
  export const MOCK_BOOKING_RESOURCES = [
973
- { id: 'res_room_1', name: 'Room 1', kind: BookingResourceKind.ROOM },
974
- { id: 'res_room_2', name: 'Room 2', kind: BookingResourceKind.ROOM },
985
+ // Two different photos, from the assets this demo already ships: a screen that picks a
986
+ // resource by sight has to be fed two that look apart, not one repeated.
987
+ { id: 'res_room_1', name: 'Room 1', kind: BookingResourceKind.ROOM, image: beetImg },
988
+ { id: 'res_room_2', name: 'Room 2', kind: BookingResourceKind.ROOM, image: roastedTomatoImg },
975
989
  ];
990
+ /**
991
+ * The clock this mock shop keeps — the same one `getContext` reports. They used to disagree: the
992
+ * slot hours were built with `setHours`, which is the MACHINE's clock, while the context claimed
993
+ * Vancouver. On a machine in Paris a correct screen showed a salon open from midnight to nine.
994
+ */
995
+ export const MOCK_SHOP_TIME_ZONE = 'America/Vancouver';
976
996
  const at = (dayOffset, hour, minute = 0) => {
977
- const date = new Date();
978
- date.setDate(date.getDate() + dayOffset);
979
- date.setHours(hour, minute, 0, 0);
980
- return date;
997
+ // The shop's wall clock, not this machine's. Two passes, because the offset is a function of
998
+ // the instant and the instant is what we are solving for.
999
+ const naive = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate() + dayOffset, hour, minute);
1000
+ const offsetAt = (instant) => {
1001
+ const parts = {};
1002
+ for (const part of new Intl.DateTimeFormat('en-US', {
1003
+ timeZone: MOCK_SHOP_TIME_ZONE,
1004
+ hour12: false,
1005
+ year: 'numeric',
1006
+ month: '2-digit',
1007
+ day: '2-digit',
1008
+ hour: '2-digit',
1009
+ minute: '2-digit',
1010
+ }).formatToParts(new Date(instant))) {
1011
+ parts[part.type] = part.value;
1012
+ }
1013
+ const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute));
1014
+ return asUtc - instant;
1015
+ };
1016
+ return new Date(naive - offsetAt(naive - offsetAt(naive)));
981
1017
  };
982
1018
  const booking = (id, resourceId, start, status, customerName) => ({
983
1019
  id,
@@ -1000,6 +1036,19 @@ export const MOCK_BOOKINGS = [
1000
1036
  export const mockLiveBookings = () => MOCK_BOOKINGS.filter(({ status, expiresAt }) => status === ReservationStatus.CONFIRMED ||
1001
1037
  (status === ReservationStatus.HELD && (!expiresAt || new Date(expiresAt) > new Date())));
1002
1038
  const takenBy = (resourceId, startAt, bufferEndAt) => mockLiveBookings().some((entry) => entry.resourceId === resourceId && new Date(entry.startAt) < bufferEndAt && new Date(entry.bufferEndAt) > startAt);
1039
+ /** The mock's shop day for an instant, `YYYY-MM-DD`, built from parts so it never reads as D/M/Y. */
1040
+ const shopDayOf = (instant) => {
1041
+ const parts = {};
1042
+ for (const part of new Intl.DateTimeFormat('en-CA', {
1043
+ timeZone: MOCK_SHOP_TIME_ZONE,
1044
+ year: 'numeric',
1045
+ month: '2-digit',
1046
+ day: '2-digit',
1047
+ }).formatToParts(instant)) {
1048
+ parts[part.type] = part.value;
1049
+ }
1050
+ return `${parts.year}-${parts.month}-${parts.day}`;
1051
+ };
1003
1052
  export const mockBookingAvailability = (productId, from, to, resourceId) => {
1004
1053
  const serving = resourceId
1005
1054
  ? MOCK_BOOKING_RESOURCES.filter((resource) => resource.id === resourceId)
@@ -1008,9 +1057,12 @@ export const mockBookingAvailability = (productId, from, to, resourceId) => {
1008
1057
  const step = (SLOT_MINUTES + BUFFER_MINUTES) * 60000;
1009
1058
  for (let day = 0; day < 14; day += 1) {
1010
1059
  const open = at(day, OPEN_HOUR);
1011
- if (open < from || open > to)
1012
- continue;
1013
1060
  const close = at(day, CLOSE_HOUR);
1061
+ // Overlap, not containment. Asking from noon used to return nothing for today, because the
1062
+ // day's opening hour lay before the range start — so an afternoon question got an empty
1063
+ // calendar and read as "fully booked".
1064
+ if (close <= from || open >= to)
1065
+ continue;
1014
1066
  for (let start = open.getTime(); start + SLOT_MINUTES * 60000 <= close.getTime(); start += step) {
1015
1067
  const startAt = new Date(start);
1016
1068
  const endAt = new Date(start + SLOT_MINUTES * 60000);
@@ -1030,6 +1082,10 @@ export const mockBookingAvailability = (productId, from, to, resourceId) => {
1030
1082
  booked: resources.reduce((sum, entry) => sum + entry.booked, 0),
1031
1083
  free: resources.reduce((sum, entry) => sum + entry.free, 0),
1032
1084
  resources,
1085
+ // The shop day, as the real host stamps it. The mock keeps one shop on the machine's
1086
+ // own clock, so here that is the machine's day — the point is that the FIELD is there,
1087
+ // so a screen built against the mock never learns to work the day out for itself.
1088
+ dayKey: shopDayOf(startAt),
1033
1089
  });
1034
1090
  }
1035
1091
  }
@@ -1039,6 +1095,8 @@ export const mockBookingAvailability = (productId, from, to, resourceId) => {
1039
1095
  bookingType: BookingType.APPOINTMENT,
1040
1096
  ratePeriod: BookingRatePeriod.SLOT,
1041
1097
  slots,
1098
+ timeZone: MOCK_SHOP_TIME_ZONE,
1099
+ days: [...new Set(slots.map((slot) => slot.dayKey))],
1042
1100
  };
1043
1101
  };
1044
1102
  /**
package/dist/index.d.ts CHANGED
@@ -160,7 +160,7 @@ export type { CalculateRefundTotal, CalculateRefundTotalParams, CalculateRefundT
160
160
  export type { GetRemainingRefundableQuantities, GetRemainingRefundableQuantitiesParams, GetRemainingRefundableQuantitiesResponse, } from './actions/get-remaining-refundable-quantities/types';
161
161
  export type { ProcessPartialRefund, ProcessPartialRefundParams, ProcessPartialRefundResponse, } from './actions/process-partial-refund/types';
162
162
  export type { RedeemRefund, RedeemRefundParams, RedeemRefundResponse } from './actions/redeem-refund/types';
163
- export type { GetRefundPlan, GetRefundPlanParams, GetRefundPlanResponse, RefundPlanSource, RefundPlanAllocation, RefundPlanLeg, } from './actions/get-refund-plan/types';
163
+ export type { GetRefundPlan, GetRefundPlanParams, GetRefundPlanResponse, RefundPlanSource, RefundPlanAllocation, RefundPlanLeg, RefundPlanRowType, RefundPlanTaxLine, RefundPlanAmounts, RefundPlanRow, RefundPlanSelectedRow, RefundPlanTotals, RefundPlanBreakdown, } from './actions/get-refund-plan/types';
164
164
  export type { CheckPermission, CheckPermissionParams, CheckPermissionResponse } from './actions/check-permission/types';
165
165
  export type { InitiateRefund, InitiateRefundParams, InitiateRefundResponse } from './actions/initiate-refund/types';
166
166
  export type { GetCurrentCart, GetCurrentCartResponse } from './actions/get-current-cart/types';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@final-commerce/command-frame",
3
- "version": "0.8.0-staging.1",
3
+ "version": "0.8.0",
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.4.0-staging.1"
60
+ "@final-commerce/common": "2.4.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@commitlint/cli": "^19.0.0",
@@ -73,5 +73,8 @@
73
73
  "typescript-eslint": "^8.0.0",
74
74
  "vitest": "^4.0.0"
75
75
  },
76
+ "overrides": {
77
+ "js-yaml": "^4.3.2"
78
+ },
76
79
  "prettier": "@final-commerce/common/prettier"
77
80
  }