@final-commerce/command-frame 0.5.0-preprod.9 → 0.5.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.
package/README.md CHANGED
@@ -10,14 +10,16 @@ Command Frame provides a structured way to build integrations that run inside Fi
10
10
 
11
11
  The library provides three main capabilities:
12
12
 
13
- | Capability | Purpose | Scope |
14
- | ------------------- | ------------------------------------------------------------------------- | ----------------------------------------- |
15
- | **Commands** | Call host functions from the iframe (e.g. get products, open cash drawer) | Request/response per call |
16
- | **Pub/Sub** | Subscribe to real-time events from the host (e.g. cart changes, payments) | Page-scoped (while iframe is mounted) |
17
- | **Refund commands** | Refund payments to gift cards or redeem tenders via `redeemRefund`, or mixed-destination legs on `processPartialRefund`; query engine capacity with `getRefundPlan`; pre-gate UI with `checkPermission` (`issue_refunds` is enforced runtime-side) | Request/response per call |
13
+ | Capability | Purpose | Scope |
14
+ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
15
+ | **Commands** | Call host functions from the iframe (e.g. get products, open cash drawer) | Request/response per call |
16
+ | **Pub/Sub** | Subscribe to real-time events from the host (e.g. cart changes, payments) | Page-scoped (while iframe is mounted) |
17
+ | **Refund commands** | Refund payments to gift cards or redeem tenders via `redeemRefund`, or mixed-destination legs on `processPartialRefund`; query engine capacity with `getRefundPlan`; pre-gate UI with `checkPermission` (`issue_refunds` is enforced runtime-side) | Request/response per call |
18
18
 
19
19
  Domain models (orders, cart, customers, products, and related types) are documented in **[Types reference](./src/types/README.md)**.
20
20
 
21
+ The order lifecycle — payment × fulfillment state pairs, display labels, the transition guard chain, and the financial invariants — is documented in **[Order state machine reference](./docs/order-state-machine.md)**. Read it before building anything that reads or moves order state (`canTransition`, `getAvailableTransitions`, `applyTransition`, park/void/resume, payments, refunds).
22
+
21
23
  ## Installation
22
24
 
23
25
  ### From npm (public registry)
@@ -1,18 +1,21 @@
1
1
  import { MOCK_CART, mockPublishEvent } from "../../demo/database";
2
+ import { percentToFraction, requireMinorUnitsInteger } from "../../demo/units";
2
3
  export const mockAddCartDiscount = async (params) => {
3
4
  console.log("[Mock] addCartDiscount called", params);
4
5
  if (params) {
5
- // Mirror render: input is raw (50 = 50%, 5 = $5). Store percent as a
6
- // fraction (0.5) and fixed as minor units (500), like the real handler.
7
- const minorFactor = 10 ** (MOCK_CART.minorUnits ?? 2);
8
- const value = params.isPercent ? params.amount / 100 : Math.round(params.amount * minorFactor);
6
+ // FI-6991: a fixed amount arrives as an INTEGER in MINOR units (500 =
7
+ // $5.00) and is stored directly; a percent arrives raw (50 = 50%) and is
8
+ // stored as a fraction (0.5). Same as the real handler.
9
+ const value = params.isPercent
10
+ ? percentToFraction(params.amount)
11
+ : requireMinorUnitsInteger(params.amount, "Discount amount");
9
12
  MOCK_CART.discount = {
10
13
  value,
11
14
  isPercent: params.isPercent,
12
15
  label: params.label
13
16
  };
14
17
  if (params.isPercent) {
15
- MOCK_CART.total = MOCK_CART.subtotal * (1 - params.amount / 100);
18
+ MOCK_CART.total = MOCK_CART.subtotal * (1 - value);
16
19
  }
17
20
  else {
18
21
  MOCK_CART.total = MOCK_CART.subtotal - value;
@@ -1,13 +1,16 @@
1
1
  import { MOCK_CART } from "../../demo/database";
2
+ import { percentToFraction, requireMinorUnitsInteger } from "../../demo/units";
2
3
  export const mockAddCartFee = async (params) => {
3
4
  console.log("[Mock] addCartFee called", params);
4
5
  if (params) {
5
6
  if (!MOCK_CART.customFee)
6
7
  MOCK_CART.customFee = [];
7
- // Mirror render: input is raw (50 = 50%, 5 = $5). Store percent as a
8
- // fraction (0.5) and fixed as minor units (500), like the real handler.
9
- const minorFactor = 10 ** (MOCK_CART.minorUnits ?? 2);
10
- const storedAmount = params.isPercent ? params.amount / 100 : Math.round(params.amount * minorFactor);
8
+ // FI-6991: a fixed amount arrives as an INTEGER in MINOR units (500 =
9
+ // $5.00) and is stored directly; a percent arrives raw (50 = 50%) and is
10
+ // stored as a fraction (0.5). Same as the real handler.
11
+ const storedAmount = params.isPercent
12
+ ? percentToFraction(params.amount)
13
+ : requireMinorUnitsInteger(params.amount, "Fee amount");
11
14
  MOCK_CART.customFee.push({
12
15
  label: params.label || "Fee",
13
16
  amount: storedAmount,
@@ -15,7 +18,7 @@ export const mockAddCartFee = async (params) => {
15
18
  applyTaxes: params.applyTaxes || false,
16
19
  taxTableId: params.taxTableId
17
20
  });
18
- const feeAmount = params.isPercent ? MOCK_CART.subtotal * (params.amount / 100) : storedAmount;
21
+ const feeAmount = params.isPercent ? MOCK_CART.subtotal * storedAmount : storedAmount;
19
22
  MOCK_CART.total += feeAmount;
20
23
  MOCK_CART.amountToBeCharged = MOCK_CART.total;
21
24
  MOCK_CART.remainingBalance = MOCK_CART.total;
@@ -1,14 +1,14 @@
1
1
  import { MOCK_CART, mockPublishEvent } from '../../demo/database';
2
+ import { requireMinorUnitsInteger } from '../../demo/units';
2
3
  export const mockAddCustomSale = async (params) => {
3
4
  console.log('[Mock] addCustomSale called', params);
4
5
  if (!params)
5
6
  throw new Error('Params required');
6
7
  // Simple mock ID generation
7
8
  const mockId = 'sale_' + Math.random().toString(36).substr(2, 9);
8
- // Mirror render: the flow sends raw dollars ($4); render does toMinorUnits.
9
- // MOCK_CART tracks minor units, so convert here too.
10
- const minorFactor = 10 ** (MOCK_CART.minorUnits ?? 2);
11
- const price = Math.round(Number(params.price) * minorFactor);
9
+ // FI-6991: price arrives as an INTEGER in MINOR units (500 = $5.00) and is
10
+ // stored directly the engine throws on a fraction, so the mock does too.
11
+ const price = requireMinorUnitsInteger(params.price, 'price');
12
12
  const quantity = params.quantity ?? 1;
13
13
  const customSale = {
14
14
  id: mockId,
@@ -1,4 +1,5 @@
1
1
  import { MOCK_CART, mockPublishEvent } from '../../demo/database';
2
+ import { requireMinorUnitsInteger } from '../../demo/units';
2
3
  export const mockEditCustomSale = async (params) => {
3
4
  console.log('[Mock] editCustomSale called', params);
4
5
  if (!params)
@@ -13,10 +14,9 @@ export const mockEditCustomSale = async (params) => {
13
14
  if (params.label !== undefined)
14
15
  sale.name = params.label;
15
16
  if (params.price !== undefined) {
16
- // Mirror render: the flow sends raw dollars ($4); render does toMinorUnits.
17
- // MOCK_CART tracks minor units, so convert here too.
18
- const minorFactor = 10 ** (MOCK_CART.minorUnits ?? 2);
19
- sale.price = Math.round(Number(params.price) * minorFactor);
17
+ // FI-6991: price arrives as an INTEGER in MINOR units and is stored
18
+ // directly the engine throws on a fraction, so the mock does too.
19
+ sale.price = requireMinorUnitsInteger(params.price, 'price');
20
20
  }
21
21
  if (params.quantity !== undefined)
22
22
  sale.quantity = params.quantity;
@@ -1,8 +1,13 @@
1
- import type { GetAvailableTransitions } from "./types";
1
+ import type { GetAvailableTransitions } from './types';
2
2
  /**
3
3
  * Mock implementation: returns a fixed set of plausible transitions
4
4
  * so the demo app has data to render. Mirrors an UNPAID order under the
5
- * default (empty cross-axis rules) config: fulfillment can advance without
6
- * payment (FI-6383), plus the classic park/void moves.
5
+ * default (empty cross-axis rules) config.
6
+ *
7
+ * FULFILLMENT-AXIS ONLY (common 2.1.4 contract): the payment axis never
8
+ * appears as an available transition — it moves exclusively through the
9
+ * money operations (pay / refund / void), which derive their own landing
10
+ * pairs. Every row keeps `to.payment` equal to the order's current payment
11
+ * state, exactly like the host's `getAvailableTransitions`.
7
12
  */
8
13
  export declare const getAvailableTransitionsMock: GetAvailableTransitions;
@@ -1,38 +1,38 @@
1
1
  /**
2
2
  * Mock implementation: returns a fixed set of plausible transitions
3
3
  * so the demo app has data to render. Mirrors an UNPAID order under the
4
- * default (empty cross-axis rules) config: fulfillment can advance without
5
- * payment (FI-6383), plus the classic park/void moves.
4
+ * default (empty cross-axis rules) config.
5
+ *
6
+ * FULFILLMENT-AXIS ONLY (common 2.1.4 contract): the payment axis never
7
+ * appears as an available transition — it moves exclusively through the
8
+ * money operations (pay / refund / void), which derive their own landing
9
+ * pairs. Every row keeps `to.payment` equal to the order's current payment
10
+ * state, exactly like the host's `getAvailableTransitions`.
6
11
  */
7
12
  export const getAvailableTransitionsMock = () => Promise.resolve({
8
13
  transitions: [
9
14
  {
10
- to: { payment: "unpaid", fulfillment: "in_progress" },
11
- displayLabel: "Start Preparing",
12
- conditions: [{ met: true, description: "Order has items" }]
15
+ to: { payment: 'unpaid', fulfillment: 'in_progress' },
16
+ displayLabel: 'Start Preparing',
17
+ conditions: [{ met: true, description: 'Order has items' }],
13
18
  },
14
19
  {
15
- to: { payment: "unpaid", fulfillment: "fulfilled" },
16
- displayLabel: "Mark Fulfilled",
17
- conditions: [{ met: true, description: "Order has items" }]
20
+ to: { payment: 'unpaid', fulfillment: 'fulfilled' },
21
+ displayLabel: 'Mark Fulfilled',
22
+ conditions: [{ met: true, description: 'Order has items' }],
18
23
  },
19
24
  {
20
- to: { payment: "unpaid", fulfillment: "on_hold" },
21
- displayLabel: "Park Order",
22
- conditions: [{ met: true, description: "Order is open" }]
25
+ to: { payment: 'unpaid', fulfillment: 'on_hold' },
26
+ displayLabel: 'Park Order',
27
+ conditions: [{ met: true, description: 'Order is open' }],
23
28
  },
24
29
  {
25
- to: { payment: "paid", fulfillment: "fulfilled" },
26
- displayLabel: "Complete Payment",
27
- conditions: [{ met: true, description: "Balance due > 0" }]
28
- },
29
- {
30
- to: { payment: "voided", fulfillment: "cancelled" },
31
- displayLabel: "Void Order",
30
+ to: { payment: 'unpaid', fulfillment: 'cancelled' },
31
+ displayLabel: 'Cancel Order',
32
32
  conditions: [
33
- { met: true, description: "Order exists" },
34
- { met: false, description: "No payments captured (mock: skipped)" }
35
- ]
36
- }
37
- ]
33
+ { met: true, description: 'Order exists' },
34
+ { met: false, description: 'No payments captured (mock: skipped)' },
35
+ ],
36
+ },
37
+ ],
38
38
  });
@@ -58,6 +58,7 @@ export const mockGetRefundPlan = async (params) => {
58
58
  transactionId: s.transactionId,
59
59
  amount: s.maxRefundable,
60
60
  paymentType: s.paymentType,
61
+ requiresDestination: s.paymentType === 'redeem',
61
62
  requiresGiftCardDestination: s.paymentType === 'redeem',
62
63
  }));
63
64
  const budget = legs.reduce((sum, l) => sum + l.amount, 0);
@@ -52,7 +52,18 @@ export interface RefundPlanLeg {
52
52
  amount: number;
53
53
  /** `cash` / `card` / `redeem` / etc., copied from the source. */
54
54
  paymentType: string;
55
- /** True when the leg must carry a `giftCard` destination (a `redeem` source). */
55
+ /**
56
+ * True when the leg must carry a destination tender (a `redeem` source
57
+ * cannot be refunded to itself — the money needs somewhere to land, credited
58
+ * by the flow FIRST). The destination is usually a gift card but redeem is
59
+ * the general rail: loyalty and store-credit extensions ride it too.
60
+ */
61
+ requiresDestination: boolean;
62
+ /**
63
+ * @deprecated Same value as {@link RefundPlanLeg.requiresDestination} — the
64
+ * old name baked one extension (gift card) into a general redeem concept.
65
+ * Kept populated for existing callers; prefer `requiresDestination`.
66
+ */
56
67
  requiresGiftCardDestination: boolean;
57
68
  /**
58
69
  * Cash legs only: what the drawer actually pays after the company's
@@ -1,4 +1,5 @@
1
1
  import { MOCK_CART, mockPublishEvent } from "../../demo/database";
2
+ import { percentToFraction, requireMinorUnitsInteger } from "../../demo/units";
2
3
  export const mockPartialPayment = async (params) => {
3
4
  console.log("[Mock] partialPayment called", params);
4
5
  const openUI = params?.openUI ?? true;
@@ -18,10 +19,11 @@ export const mockPartialPayment = async (params) => {
18
19
  // untouched until the payment is actually taken (see applyMockPayment).
19
20
  const remaining = MOCK_CART.remainingBalance ?? MOCK_CART.total;
20
21
  const raw = params?.amount ?? 0;
21
- // Mirror render: fixed amount is raw dollars (render does toMinorUnits), so
22
- // convert to minor units here; percent is a percentage of the remaining total.
23
- const minorFactor = 10 ** (MOCK_CART.minorUnits ?? 2);
24
- const charge = params?.isPercent ? Math.round((remaining * raw) / 100) : Math.round(raw * minorFactor);
22
+ // FI-6991: a fixed amount is already an INTEGER in MINOR units and is used
23
+ // directly; a percent is raw 0-100 against the remaining total.
24
+ const charge = params?.isPercent
25
+ ? Math.round(remaining * percentToFraction(raw))
26
+ : requireMinorUnitsInteger(raw, "Payment amount");
25
27
  MOCK_CART.amountToBeCharged = Math.min(Math.max(0, charge), remaining);
26
28
  mockPublishEvent("cart", "partial-payment-set", { amountToBeCharged: MOCK_CART.amountToBeCharged });
27
29
  return {
@@ -1,2 +1,2 @@
1
- import { ProcessPartialRefund } from "./types";
1
+ import { ProcessPartialRefund } from './types';
2
2
  export declare const mockProcessPartialRefund: ProcessPartialRefund;
@@ -10,7 +10,7 @@ export const mockProcessPartialRefund = async (params) => {
10
10
  if (params?.legs && params?.giftCard) {
11
11
  throw new Error('refund.giftCardAndLegs: pass either `legs` (you allocate) or `giftCard` (the engine allocates), not both');
12
12
  }
13
- console.log("[Mock] processPartialRefund called", {
13
+ console.log('[Mock] processPartialRefund called', {
14
14
  ...params,
15
15
  openUI: params?.openUI ?? true,
16
16
  legs: params?.legs ?? null,
@@ -19,6 +19,7 @@ export const mockProcessPartialRefund = async (params) => {
19
19
  return {
20
20
  success: true,
21
21
  refundId: 'mock_refund_' + Date.now(),
22
- timestamp: new Date().toISOString()
22
+ modalRaised: false,
23
+ timestamp: new Date().toISOString(),
23
24
  };
24
25
  };
@@ -153,7 +153,25 @@ export interface ProcessPartialRefundParams {
153
153
  }
154
154
  export interface ProcessPartialRefundResponse {
155
155
  success: boolean;
156
- refundId: string;
156
+ /**
157
+ * The persisted Refund document's id — the REAL one, usable to look the
158
+ * refund up. `null` means nothing has committed: the split-payment modal
159
+ * was raised (`modalRaised: true`) and owns the commit from there.
160
+ *
161
+ * Before kaching 1.9.5-preprod.17 this was the hardcoded string
162
+ * `'processed'` regardless of outcome, so a truthiness check could not
163
+ * detect a refund that silently didn't happen. Guard on it now:
164
+ * `if (!res.refundId && !res.modalRaised) …` is unreachable (such paths
165
+ * throw instead), so `res.refundId` alone answers "did money move".
166
+ */
167
+ refundId: string | null;
168
+ /**
169
+ * True when a multi-tender order raised the split-payment refund modal
170
+ * (`openUI` omitted or `true`): the cashier allocates there and the modal
171
+ * drives the commit — this call wrote nothing. Headless calls
172
+ * (`openUI: false`) never raise it.
173
+ */
174
+ modalRaised: boolean;
157
175
  timestamp: string;
158
176
  }
159
177
  export type ProcessPartialRefund = (params?: ProcessPartialRefundParams) => Promise<ProcessPartialRefundResponse>;
@@ -1,7 +1,10 @@
1
1
  import { MOCK_CART, mockPublishEvent } from "../../demo/database";
2
2
  function feeContributionToTotal(fee, subtotal) {
3
+ // A percent fee is STORED as a fraction (10% -> 0.1), so it scales the
4
+ // subtotal directly. Dividing by 100 again credited back a hundredth of the
5
+ // fee, leaving the total permanently inflated after a remove.
3
6
  if (fee.isPercent) {
4
- return subtotal * (fee.amount / 100);
7
+ return subtotal * fee.amount;
5
8
  }
6
9
  return fee.amount;
7
10
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * FI-6991 money contract, mirrored for the mock host.
3
+ *
4
+ * The POS engine a published build boots (kaching) takes every FIXED money
5
+ * amount as an INTEGER in MINOR currency units and stores it DIRECTLY, throwing
6
+ * on a fraction; a percent arrives raw 0-100 and is stored as a FRACTION
7
+ * (10 -> 0.1). See kaching's `src/command-frame/utils/adjustmentValue.ts`.
8
+ *
9
+ * The mocks used to convert fixed amounts from MAJOR units — the pre-FI-6991
10
+ * contract — so an app that was correct against a real register read 100x wrong
11
+ * in preview, and an app tuned until preview looked right shipped 100x wrong.
12
+ * A mock host is only worth having if it stores, and rejects, exactly what the
13
+ * engine does.
14
+ */
15
+ /** A fixed money amount must be an integer count of minor units. */
16
+ export declare function requireMinorUnitsInteger(amount: number | string, what: string): number;
17
+ /** A percent arrives raw 0-100 on the wire and is STORED as a fraction. */
18
+ export declare function percentToFraction(amount: number): number;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * FI-6991 money contract, mirrored for the mock host.
3
+ *
4
+ * The POS engine a published build boots (kaching) takes every FIXED money
5
+ * amount as an INTEGER in MINOR currency units and stores it DIRECTLY, throwing
6
+ * on a fraction; a percent arrives raw 0-100 and is stored as a FRACTION
7
+ * (10 -> 0.1). See kaching's `src/command-frame/utils/adjustmentValue.ts`.
8
+ *
9
+ * The mocks used to convert fixed amounts from MAJOR units — the pre-FI-6991
10
+ * contract — so an app that was correct against a real register read 100x wrong
11
+ * in preview, and an app tuned until preview looked right shipped 100x wrong.
12
+ * A mock host is only worth having if it stores, and rejects, exactly what the
13
+ * engine does.
14
+ */
15
+ /** A fixed money amount must be an integer count of minor units. */
16
+ export function requireMinorUnitsInteger(amount, what) {
17
+ const n = Number(amount);
18
+ if (!Number.isFinite(n)) {
19
+ throw new Error(`${what} must be a valid number`);
20
+ }
21
+ if (!Number.isInteger(n)) {
22
+ throw new Error(`${what} must be an integer amount in minor currency units (e.g. 1575 = $15.75)`);
23
+ }
24
+ return n;
25
+ }
26
+ /** A percent arrives raw 0-100 on the wire and is STORED as a fraction. */
27
+ export function percentToFraction(amount) {
28
+ return Number(amount) / 100;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@final-commerce/command-frame",
3
- "version": "0.5.0-preprod.9",
3
+ "version": "0.5.1",
4
4
  "description": "Commands Frame library",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -63,6 +63,7 @@
63
63
  "@commitlint/cli": "^19.0.0",
64
64
  "@commitlint/config-conventional": "^19.0.0",
65
65
  "@eslint/js": "^9.0.0",
66
+ "@vitest/coverage-v8": "^4.1.11",
66
67
  "eslint": "^9.0.0",
67
68
  "husky": "^9.1.7",
68
69
  "jira-prepare-commit-msg": "^1.7.2",