@classytic/pos-ui 0.2.0 → 1.2.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.
Files changed (87) hide show
  1. package/LICENSE +75 -0
  2. package/dist/components/CartSidebar.d.ts +56 -0
  3. package/dist/components/CartSidebar.js +10 -9
  4. package/dist/components/PosTopBar.d.ts +19 -0
  5. package/dist/components/PosTopBar.js +4 -4
  6. package/dist/components/PrinterSettingsDialog.d.ts +8 -0
  7. package/dist/components/PrinterSettingsDialog.js +506 -20
  8. package/dist/components/ReceiptDeliveryDialog.d.ts +11 -0
  9. package/dist/components/ReceiptDeliveryDialog.js +98 -0
  10. package/dist/components/ReceiptReprintDialog.d.ts +10 -0
  11. package/dist/components/ReceiptReprintDialog.js +49 -22
  12. package/dist/components/index.d.ts +6 -0
  13. package/dist/components/index.js +7 -0
  14. package/dist/dashboard/components/CustomerLookupDialog.js +108 -90
  15. package/dist/dashboard/components/CustomerQuickAddDialog.js +3 -1
  16. package/dist/dashboard/components/DeliveryPanel.js +134 -0
  17. package/dist/dashboard/components/ProductCard.js +17 -7
  18. package/dist/dashboard/components/ProductsPanel.js +4 -4
  19. package/dist/dashboard/components/SplitPaymentPanel.js +5 -5
  20. package/dist/dashboard/components/VariantSelectorDialog.js +53 -11
  21. package/dist/dashboard/components/cart/AddChargeDialog.js +3 -2
  22. package/dist/dashboard/components/cart/CartItems.js +5 -5
  23. package/dist/dashboard/components/cart/CartSummary.js +6 -6
  24. package/dist/dashboard/components/cart/CustomerSection.js +3 -3
  25. package/dist/dashboard/components/cart/DiscountSection.js +4 -3
  26. package/dist/dashboard/components/cart/PointsRedemptionSection.js +5 -4
  27. package/dist/dashboard/pos.types.d.ts +32 -0
  28. package/dist/hardware/agent-adapter.js +127 -0
  29. package/dist/hardware/agent-pairing.d.ts +18 -0
  30. package/dist/hardware/agent-pairing.js +63 -0
  31. package/dist/hardware/context.d.ts +1 -5
  32. package/dist/hardware/index.d.ts +4 -3
  33. package/dist/hardware/index.js +3 -2
  34. package/dist/hardware/ports.d.ts +21 -4
  35. package/dist/hardware/tauri-adapter.d.ts +0 -1
  36. package/dist/hardware/tauri-adapter.js +19 -5
  37. package/dist/hardware/web-adapter.d.ts +22 -7
  38. package/dist/hardware/web-adapter.js +56 -14
  39. package/dist/hooks/branch-scoped-key.js +64 -0
  40. package/dist/hooks/index.d.ts +6 -0
  41. package/dist/hooks/index.js +7 -0
  42. package/dist/hooks/sale-attempt.js +62 -0
  43. package/dist/hooks/useManagerAuth.js +9 -1
  44. package/dist/hooks/usePosCart.d.ts +55 -0
  45. package/dist/hooks/usePosCart.js +224 -117
  46. package/dist/hooks/usePosCustomer.d.ts +31 -0
  47. package/dist/hooks/usePosCustomer.js +32 -78
  48. package/dist/hooks/usePosDelivery.d.ts +28 -0
  49. package/dist/hooks/usePosDelivery.js +133 -0
  50. package/dist/hooks/usePosMultiOrder.d.ts +22 -0
  51. package/dist/hooks/usePosMultiOrder.js +14 -4
  52. package/dist/hooks/usePosPayment.d.ts +22 -0
  53. package/dist/lib/delivery-payload.js +83 -0
  54. package/dist/lib/money.js +96 -23
  55. package/dist/node_modules/react-hook-form/dist/index.esm.js +468 -332
  56. package/dist/runtime/auth-port.d.ts +1 -5
  57. package/dist/runtime/branch-port.d.ts +1 -5
  58. package/dist/runtime/config.d.ts +43 -6
  59. package/dist/runtime/config.js +18 -1
  60. package/dist/runtime/index.d.ts +2 -2
  61. package/dist/runtime/index.js +2 -2
  62. package/dist/screens/OrderHistoryDrawer.d.ts +8 -0
  63. package/dist/screens/OrderHistoryDrawer.js +15 -15
  64. package/dist/screens/ParkedOrdersDrawer.d.ts +12 -0
  65. package/dist/screens/ParkedOrdersDrawer.js +3 -3
  66. package/dist/screens/PaymentScreen.d.ts +4 -0
  67. package/dist/screens/PaymentScreen.js +194 -33
  68. package/dist/screens/ProductScreen.d.ts +4 -0
  69. package/dist/screens/ProductScreen.js +13 -10
  70. package/dist/screens/ReceiptScreen.d.ts +4 -0
  71. package/dist/screens/ReceiptScreen.js +96 -72
  72. package/dist/screens/ShiftCloseScreen.d.ts +4 -0
  73. package/dist/screens/ShiftCloseScreen.js +18 -15
  74. package/dist/screens/ShiftOpenScreen.d.ts +4 -0
  75. package/dist/screens/ShiftOpenScreen.js +75 -19
  76. package/dist/screens/index.d.ts +8 -0
  77. package/dist/screens/index.js +9 -0
  78. package/dist/shell/index.d.ts +2 -0
  79. package/dist/shell/pos-shell.d.ts +41 -8
  80. package/dist/shell/pos-shell.js +25 -18
  81. package/dist/state/index.d.ts +3 -0
  82. package/dist/state/index.js +4 -0
  83. package/dist/state/pos-context.d.ts +13 -0
  84. package/dist/state/pos-state.d.ts +49 -0
  85. package/dist/state/pos-state.js +6 -7
  86. package/dist/utils/pos-helpers.js +137 -34
  87. package/package.json +112 -92
@@ -1,8 +1,39 @@
1
1
  import { PosHardware } from "../hardware/ports.js";
2
+ import "../hardware/index.js";
2
3
  import { PosAuthPort } from "../runtime/auth-port.js";
3
- import { PosRuntime } from "../runtime/config.js";
4
+ import { PosRuntime, PosSalesCapabilities } from "../runtime/config.js";
4
5
  import { PosBranchPort } from "../runtime/branch-port.js";
6
+ import "../runtime/index.js";
7
+ import { PosScreen } from "../state/pos-state.js";
8
+ import "../state/index.js";
9
+ import { ReactNode } from "react";
5
10
  //#region src/shell/pos-shell.d.ts
11
+ /**
12
+ * Where a vertical injects its own UI WITHOUT forking the shell.
13
+ *
14
+ * The shell owns the register's spine — the screen state machine, hotkeys,
15
+ * wake-lock, shift rehydration — which is identical everywhere. What differs
16
+ * per vertical is decoration and, occasionally, one whole screen: a gym wants a
17
+ * "Find member" action and a guest banner; a hotel wants "Charge to room".
18
+ * Those are slots, not forks.
19
+ */
20
+ interface PosShellSlots {
21
+ /** Extra top-bar actions — e.g. member lookup, day pass. */
22
+ topBarActions?: ReactNode;
23
+ /** Above the active screen — e.g. the checked-in member / guest banner. */
24
+ aboveScreen?: ReactNode;
25
+ /** Below the active screen — e.g. a scanner/turnstile status strip. */
26
+ belowScreen?: ReactNode;
27
+ /** Always mounted — the host's own drawers and dialogs. */
28
+ overlays?: ReactNode;
29
+ /**
30
+ * Wrap or REPLACE a screen. Receives the screen key and the default node;
31
+ * return the default to keep it, or anything else to take it over. This is
32
+ * the escape hatch that makes forking unnecessary — a gym can replace only
33
+ * `products` and inherit every other screen's fixes.
34
+ */
35
+ renderScreen?: (screen: PosScreen, defaultNode: ReactNode) => ReactNode;
36
+ }
6
37
  interface PosShellProps {
7
38
  /** Auth seam — host wires it to its auth client. */
8
39
  auth: PosAuthPort;
@@ -12,12 +43,14 @@ interface PosShellProps {
12
43
  runtime: PosRuntime;
13
44
  /** Hardware port; defaults to the web adapter. */
14
45
  hardware?: PosHardware;
46
+ /** Vertical-specific UI injected into the shell. See `PosShellSlots`. */
47
+ slots?: PosShellSlots;
48
+ /**
49
+ * Sales capabilities this deployment has enabled. Every flag defaults to OFF,
50
+ * so an existing host that omits this keeps exactly its current surface.
51
+ */
52
+ sales?: PosSalesCapabilities;
15
53
  }
16
- declare function PosShell({
17
- auth,
18
- branch,
19
- runtime,
20
- hardware
21
- }: PosShellProps): import("react").JSX.Element;
54
+ declare function PosShell({ auth, branch, runtime, hardware, slots, sales }: PosShellProps): import("react").JSX.Element;
22
55
  //#endregion
23
- export { PosShell, PosShellProps };
56
+ export { PosShell, PosShellProps, PosShellSlots };
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { PosAuthProvider, usePosAuth } from "../runtime/auth-port.js";
4
- import { PosRuntimeProvider } from "../runtime/config.js";
4
+ import { PosRuntimeProvider, PosSalesCapabilitiesProvider } from "../runtime/config.js";
5
5
  import { PosBranchProvider } from "../runtime/branch-port.js";
6
6
  import { createWebHardware } from "../hardware/web-adapter.js";
7
7
  import { HardwareProvider } from "../hardware/context.js";
@@ -31,7 +31,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
31
31
  *
32
32
  * Screen flow: idle (shift open) → products → payment → receipt → products
33
33
  */
34
- function PosScreenRouter() {
34
+ function PosScreenRouter({ slots }) {
35
35
  const { state, dispatch } = usePosContext();
36
36
  const sessionUser = usePosAuth().useSessionUser();
37
37
  const { shift: activeShift } = usePosCurrentShift();
@@ -41,7 +41,10 @@ function PosScreenRouter() {
41
41
  const [printerSettingsOpen, setPrinterSettingsOpen] = useState(false);
42
42
  useWakeLock({ enabled: state.shiftOpen });
43
43
  useEffect(() => {
44
- if (state.screen === "idle" && activeShift?.state === "open" && !state.shiftOpen) dispatch({ type: "OPEN_SHIFT" });
44
+ if (state.screen === "idle" && activeShift?.state === "open" && !state.shiftOpen) dispatch({
45
+ type: "OPEN_SHIFT",
46
+ registerId: activeShift.registerId ?? null
47
+ });
45
48
  }, [
46
49
  state.screen,
47
50
  state.shiftOpen,
@@ -74,26 +77,26 @@ function PosScreenRouter() {
74
77
  const handleNewSale = useCallback(() => {
75
78
  if (state.screen === "receipt") dispatch({ type: "NEW_SALE" });
76
79
  }, [state.screen, dispatch]);
80
+ const defaultScreen = state.screen === "idle" ? /* @__PURE__ */ jsx(ShiftOpenScreen, {}) : state.screen === "products" ? /* @__PURE__ */ jsx(ProductScreen, {}) : state.screen === "payment" ? /* @__PURE__ */ jsx(PaymentScreen, {}) : state.screen === "receipt" ? /* @__PURE__ */ jsx(ReceiptScreen, {}) : state.screen === "closing" ? /* @__PURE__ */ jsx(ShiftCloseScreen, {}) : null;
81
+ const screenNode = slots.renderScreen ? slots.renderScreen(state.screen, defaultScreen) : defaultScreen;
77
82
  return /* @__PURE__ */ jsxs("div", {
78
83
  className: "flex flex-col h-screen",
79
84
  children: [
80
85
  state.screen !== "idle" && /* @__PURE__ */ jsx(PosTopBar, {
86
+ actions: slots.topBarActions,
81
87
  userName,
82
88
  onNewSale: state.screen === "products" ? handleNewSale : void 0,
83
89
  onOpenHistory: () => setHistoryOpen(true),
84
90
  onOpenHelp: () => setHelpOpen(true),
85
91
  onOpenPrinterSettings: () => setPrinterSettingsOpen(true)
86
92
  }),
87
- /* @__PURE__ */ jsxs("div", {
93
+ slots.aboveScreen,
94
+ /* @__PURE__ */ jsx("div", {
88
95
  className: "flex-1 min-h-0",
89
- children: [
90
- state.screen === "idle" && /* @__PURE__ */ jsx(ShiftOpenScreen, {}),
91
- state.screen === "products" && /* @__PURE__ */ jsx(ProductScreen, {}),
92
- state.screen === "payment" && /* @__PURE__ */ jsx(PaymentScreen, {}),
93
- state.screen === "receipt" && /* @__PURE__ */ jsx(ReceiptScreen, {}),
94
- state.screen === "closing" && /* @__PURE__ */ jsx(ShiftCloseScreen, {})
95
- ]
96
+ children: screenNode
96
97
  }),
98
+ slots.belowScreen,
99
+ slots.overlays,
97
100
  /* @__PURE__ */ jsx(OrderHistoryDrawer, {
98
101
  open: historyOpen,
99
102
  onOpenChange: setHistoryOpen
@@ -109,19 +112,23 @@ function PosScreenRouter() {
109
112
  ]
110
113
  });
111
114
  }
112
- function PosShell({ auth, branch, runtime, hardware }) {
115
+ function PosShell({ auth, branch, runtime, hardware, slots, sales }) {
116
+ const hw = useMemo(() => hardware ?? createWebHardware(), [hardware]);
113
117
  return /* @__PURE__ */ jsx(PosAuthProvider, {
114
118
  auth,
115
119
  children: /* @__PURE__ */ jsx(PosBranchProvider, {
116
120
  branch,
117
121
  children: /* @__PURE__ */ jsx(PosRuntimeProvider, {
118
122
  runtime,
119
- children: /* @__PURE__ */ jsx(DensityProvider, {
120
- density: "auto",
121
- children: /* @__PURE__ */ jsx(HotkeyRegistryProvider, { children: /* @__PURE__ */ jsx(HardwareProvider, {
122
- hardware: useMemo(() => hardware ?? createWebHardware(), [hardware]),
123
- children: /* @__PURE__ */ jsx(PosProvider, { children: /* @__PURE__ */ jsx(PosScreenRouter, {}) })
124
- }) })
123
+ children: /* @__PURE__ */ jsx(PosSalesCapabilitiesProvider, {
124
+ sales: sales ?? {},
125
+ children: /* @__PURE__ */ jsx(DensityProvider, {
126
+ density: "auto",
127
+ children: /* @__PURE__ */ jsx(HotkeyRegistryProvider, { children: /* @__PURE__ */ jsx(HardwareProvider, {
128
+ hardware: hw,
129
+ children: /* @__PURE__ */ jsx(PosProvider, { children: /* @__PURE__ */ jsx(PosScreenRouter, { slots: slots ?? {} }) })
130
+ }) })
131
+ })
125
132
  })
126
133
  })
127
134
  })
@@ -0,0 +1,3 @@
1
+ import { INITIAL_POS_STATE, PosAction, PosScreen, PosState, posReducer } from "./pos-state.js";
2
+ import { PosProvider, usePosContext } from "./pos-context.js";
3
+ export { INITIAL_POS_STATE, type PosAction, PosProvider, type PosScreen, type PosState, posReducer, usePosContext };
@@ -0,0 +1,4 @@
1
+ import { INITIAL_POS_STATE, posReducer } from "./pos-state.js";
2
+ import { PosProvider, usePosContext } from "./pos-context.js";
3
+
4
+ export { INITIAL_POS_STATE, PosProvider, posReducer, usePosContext };
@@ -0,0 +1,13 @@
1
+ import { PosAction, PosState } from "./pos-state.js";
2
+ import { ReactNode } from "react";
3
+ //#region src/state/pos-context.d.ts
4
+ interface PosContextValue {
5
+ state: PosState;
6
+ dispatch: (action: PosAction) => void;
7
+ }
8
+ declare function PosProvider({ children }: {
9
+ children: ReactNode;
10
+ }): import("react").JSX.Element;
11
+ declare function usePosContext(): PosContextValue;
12
+ //#endregion
13
+ export { PosProvider, usePosContext };
@@ -0,0 +1,49 @@
1
+ import { PosReceiptData } from "@classytic/commerce-sdk/sales";
2
+ //#region src/state/pos-state.d.ts
3
+ type PosScreen = "idle" | "products" | "payment" | "receipt" | "closing";
4
+ type PosAction = {
5
+ type: "OPEN_SHIFT";
6
+ registerId?: string | null;
7
+ } | {
8
+ type: "GO_TO_PAYMENT";
9
+ } | {
10
+ type: "GO_BACK";
11
+ } | {
12
+ type: "COMPLETE_SALE";
13
+ receipt: PosReceiptData | null;
14
+ } | {
15
+ type: "NEW_SALE";
16
+ } | {
17
+ type: "CLOSE_SHIFT";
18
+ } | {
19
+ type: "CONFIRM_CLOSE";
20
+ } | {
21
+ type: "CANCEL_CLOSE";
22
+ };
23
+ interface PosState {
24
+ screen: PosScreen;
25
+ /**
26
+ * Last completed order receipt (for ReceiptScreen).
27
+ *
28
+ * Typed, not `unknown`. It was `unknown` and every consumer cast it back
29
+ * (`state.lastReceipt as PosReceiptData | null` in ReceiptScreen), which is
30
+ * the worst of both: no compile-time contract AND the appearance of one. The
31
+ * shape is the SDK's — `transformOrderToReceipt` already produces exactly it,
32
+ * including a `null` for an unparseable response, so the null is real state
33
+ * and not a placeholder.
34
+ */
35
+ lastReceipt: PosReceiptData | null;
36
+ /** Whether a shift is currently open */
37
+ shiftOpen: boolean;
38
+ /**
39
+ * Register (`@classytic/pos` Register `_id`) the open shift is bound to.
40
+ * Threads into `/current` scoping + POS order placement so a sale hits the
41
+ * right register's shift when a branch runs several. Null before open / when
42
+ * the branch has a single register the backend resolves implicitly.
43
+ */
44
+ activeRegisterId: string | null;
45
+ }
46
+ declare const INITIAL_POS_STATE: PosState;
47
+ declare function posReducer(state: PosState, action: PosAction): PosState;
48
+ //#endregion
49
+ export { INITIAL_POS_STATE, PosAction, PosScreen, PosState, posReducer };
@@ -2,8 +2,8 @@
2
2
  const INITIAL_POS_STATE = {
3
3
  screen: "idle",
4
4
  lastReceipt: null,
5
- lastCashReceived: null,
6
- shiftOpen: false
5
+ shiftOpen: false,
6
+ activeRegisterId: null
7
7
  };
8
8
  function posReducer(state, action) {
9
9
  switch (action.type) {
@@ -12,7 +12,8 @@ function posReducer(state, action) {
12
12
  return {
13
13
  ...state,
14
14
  screen: "products",
15
- shiftOpen: true
15
+ shiftOpen: true,
16
+ activeRegisterId: action.registerId ?? state.activeRegisterId
16
17
  };
17
18
  case "GO_TO_PAYMENT":
18
19
  if (state.screen !== "products") return state;
@@ -28,8 +29,7 @@ function posReducer(state, action) {
28
29
  if (state.screen === "receipt") return {
29
30
  ...state,
30
31
  screen: "products",
31
- lastReceipt: null,
32
- lastCashReceived: null
32
+ lastReceipt: null
33
33
  };
34
34
  return state;
35
35
  case "COMPLETE_SALE":
@@ -44,8 +44,7 @@ function posReducer(state, action) {
44
44
  return {
45
45
  ...state,
46
46
  screen: "products",
47
- lastReceipt: null,
48
- lastCashReceived: null
47
+ lastReceipt: null
49
48
  };
50
49
  case "CLOSE_SHIFT":
51
50
  if (!state.shiftOpen) return state;
@@ -1,17 +1,25 @@
1
- import { Paisa } from "../lib/money.js";
2
- import { isValidPhone } from "@classytic/commerce-sdk/client";
1
+ import { TenantMinorUnits, formatMinor, minorPerMajorUnit, minorToMoney, toMajor } from "../lib/money.js";
2
+ import { getReceiptLocale, isValidPhone } from "@classytic/commerce-sdk/client";
3
3
 
4
4
  //#region src/utils/pos-helpers.ts
5
- /** Brand a paisa value read out of an unknown response. The number is
6
- * expected to already be in paisa (be-prod canonical unit); this helper
7
- * just re-asserts the brand after a `typeof` guard. */
8
- const paisaOr0 = (v) => Paisa(typeof v === "number" ? v : 0);
5
+ /** Brand a MINOR-unit value read out of an unknown response. The number is
6
+ * expected to already be in minor units (the server's canonical unit paisa
7
+ * on a BDT deployment); this helper just re-asserts the brand after a
8
+ * `typeof` guard. */
9
+ const minorOr0 = (v) => TenantMinorUnits(typeof v === "number" ? v : 0);
9
10
  /**
10
- * Parse cash received input to number
11
+ * Parse the cash-received field to a non-negative number.
12
+ *
13
+ * Clamps at 0 as well as guarding NaN. Negative cash is meaningless, and an
14
+ * unclamped negative propagates into `calculateAmountDue`, which then shows MORE
15
+ * owed than the sale total — a cashier reading "৳150 due" on a ৳100 sale. The
16
+ * function is named for a quantity that cannot be negative, so it should not be
17
+ * able to return one.
11
18
  */
12
19
  function parseCashReceived(value) {
13
20
  const n = Number(value);
14
- return Number.isFinite(n) ? n : 0;
21
+ if (!Number.isFinite(n)) return 0;
22
+ return n > 0 ? n : 0;
15
23
  }
16
24
  /**
17
25
  * Parse input to positive number (min 0)
@@ -35,6 +43,23 @@ function calculateCartTotals(items, discountInput) {
35
43
  itemCount: items.length
36
44
  };
37
45
  }
46
+ /**
47
+ * Points per ONE MAJOR unit, derived from the config's `pointValueMinor`
48
+ * (minor units one point redeems for).
49
+ *
50
+ * ONE function because there were TWO copies of this derivation in this file —
51
+ * `validateRedemption` and `calculateOrderTotals` — each with its own hardcoded
52
+ * `100`. Two copies of one rate is how a redemption cap and a redemption charge
53
+ * come to disagree, and neither would throw. The factor now comes from the
54
+ * currency (KWD is 1000, JPY is 1), so the arithmetic is not BDT-only either.
55
+ *
56
+ * Returns 1 when the rate is absent or non-positive — there is no correct answer
57
+ * without a configured rate, which is why the UI must not print a money figure
58
+ * in that state (see `PointsRedemptionSection`).
59
+ */
60
+ function pointsPerMajorUnit(pointValueMinor) {
61
+ return typeof pointValueMinor === "number" && pointValueMinor > 0 ? minorPerMajorUnit() / pointValueMinor : 1;
62
+ }
38
63
  function validateRedemption(pointsToRedeem, customerPoints, orderTotal, membershipConfig) {
39
64
  if (!membershipConfig?.enabled) return {
40
65
  valid: false,
@@ -53,23 +78,38 @@ function validateRedemption(pointsToRedeem, customerPoints, orderTotal, membersh
53
78
  valid: false,
54
79
  error: `Insufficient points. Available: ${customerPoints}`
55
80
  };
56
- if (orderTotal < (config.minOrderAmount ?? 0)) return {
81
+ /**
82
+ * `minOrderAmount` is integer MINOR units (be-prod's `loyalty.membership`
83
+ * contract), while `orderTotal` reaching this function is MAJOR units — so the
84
+ * two were compared across denominations. A ৳500 minimum is `50000` paisa, and
85
+ * `orderTotal < 50000` is true for every realistic basket, so redemption was
86
+ * refused on essentially every sale while the message read "৳50000 required".
87
+ * Nothing threw; the cashier just saw an impossible threshold.
88
+ *
89
+ * Converted through primitives (`toMajor(minorToMoney(...))`) rather than a
90
+ * `/ 100`, so the factor is the CURRENCY's — KWD is 1000, JPY is 1 — and the
91
+ * message is formatted by the same `Intl`-backed formatter as every other
92
+ * amount instead of a hand-glued symbol.
93
+ */
94
+ const minOrderMinor = config.minOrderAmount ?? 0;
95
+ if (orderTotal < toMajor(minorToMoney(minOrderMinor))) return {
57
96
  valid: false,
58
- error: `Minimum order of ৳${config.minOrderAmount} required`
97
+ error: `Minimum order of ${formatMinor(minOrderMinor)} required`
59
98
  };
99
+ const pointsPerMajor = pointsPerMajorUnit(config.pointValueMinor);
60
100
  const maxDiscount = Math.floor(orderTotal * (config.maxRedeemPercent ?? 100) / 100);
61
- const requestedDiscount = Math.floor(pointsToRedeem / (config.pointsPerBdt ?? 1));
101
+ const requestedDiscount = Math.floor(pointsToRedeem / pointsPerMajor);
62
102
  let actualDiscount = requestedDiscount;
63
103
  let actualPoints = pointsToRedeem;
64
104
  if (requestedDiscount > maxDiscount) {
65
105
  actualDiscount = maxDiscount;
66
- actualPoints = maxDiscount * (config.pointsPerBdt ?? 1);
106
+ actualPoints = maxDiscount * pointsPerMajor;
67
107
  }
68
108
  return {
69
109
  valid: true,
70
110
  discountAmount: actualDiscount,
71
111
  pointsToRedeem: actualPoints,
72
- maxAllowedPoints: maxDiscount * (config.pointsPerBdt ?? 1)
112
+ maxAllowedPoints: maxDiscount * pointsPerMajor
73
113
  };
74
114
  }
75
115
  function calculateOrderTotals({ items, manualDiscountInput, membershipConfig, customer, pointsToRedeemInput }) {
@@ -92,7 +132,7 @@ function calculateOrderTotals({ items, manualDiscountInput, membershipConfig, cu
92
132
  maxAllowedPoints = result.maxAllowedPoints ?? 0;
93
133
  } else redemptionError = result.error ?? "Redemption not available";
94
134
  }
95
- if (!maxAllowedPoints && membershipConfig?.enabled && membershipConfig?.redemption?.enabled) maxAllowedPoints = Math.floor(preliminaryTotal * (membershipConfig.redemption.maxRedeemPercent ?? 100) / 100) * (membershipConfig.redemption.pointsPerBdt ?? 1);
135
+ if (!maxAllowedPoints && membershipConfig?.enabled && membershipConfig?.redemption?.enabled) maxAllowedPoints = Math.floor(preliminaryTotal * (membershipConfig.redemption.maxRedeemPercent ?? 100) / 100) * pointsPerMajorUnit(membershipConfig.redemption.pointValueMinor);
96
136
  const total = Math.max(0, preliminaryTotal - redemptionDiscount);
97
137
  return {
98
138
  subtotal,
@@ -129,23 +169,11 @@ function calculateAmountDue(cashReceived, total) {
129
169
  function isValidBDPhone(phone) {
130
170
  return isValidPhone(phone);
131
171
  }
132
- /**
133
- * Check if a search string looks like a phone number. `exact` uses the
134
- * deployment's phone pattern (locale-driven); `likely` is a loose digit-run
135
- * heuristic that stays country-neutral.
136
- */
137
- function isPhoneSearch(query) {
138
- const trimmed = query.trim();
139
- return {
140
- exact: isValidPhone(trimmed),
141
- likely: /^\d{4,}$/.test(trimmed)
142
- };
143
- }
144
172
  /** Read paisa from a Money struct `{ amount, currency }` returned by the
145
173
  * order kernel. Falls back to a bare number for legacy shapes. */
146
174
  const moneyAmount = (m) => {
147
- if (m && typeof m === "object" && "amount" in m && typeof m.amount === "number") return Paisa(m.amount);
148
- return paisaOr0(m);
175
+ if (m && typeof m === "object" && "amount" in m && typeof m.amount === "number") return TenantMinorUnits(m.amount);
176
+ return minorOr0(m);
149
177
  };
150
178
  /**
151
179
  * Transform a POS order response to receipt format.
@@ -185,9 +213,18 @@ function transformOrderToReceipt(orderResponse, branchInfo) {
185
213
  const meta = order.metadata ?? {};
186
214
  const membership = meta.membership ?? {};
187
215
  const vat = meta.vat ?? {};
188
- const primaryPayment = (meta.payments ?? [])[0];
216
+ const primaryPayment = (order.tenders ?? [])[0];
217
+ /**
218
+ * Change handed back, from the placement's own record. Over-tender is modelled rather than
219
+ * absorbed (`tenders[].amount` is the APPLIED share and the excess is change), so the
220
+ * receipt can finally show it — the one place the customer actually checks it.
221
+ */
222
+ const tenderMeta = meta.tender ?? {};
223
+ const changeMinor = typeof tenderMeta.changeMinor === "number" && tenderMeta.changeMinor > 0 ? tenderMeta.changeMinor : void 0;
189
224
  const itemVatAmount = (line) => {
190
- return Paisa(Number(moneyAmount(line.unitTax)) * (typeof line.quantity === "number" ? line.quantity : 1));
225
+ const unitTax = Number(moneyAmount(line.unitTax));
226
+ const qty = typeof line.quantity === "number" ? line.quantity : 1;
227
+ return TenantMinorUnits(unitTax * qty);
191
228
  };
192
229
  return {
193
230
  orderId: String(order._id || ""),
@@ -204,7 +241,7 @@ function transformOrderToReceipt(orderResponse, branchInfo) {
204
241
  cardId: String(membership.cardId),
205
242
  tier: membership.tier ? String(membership.tier) : void 0,
206
243
  pointsEarned: typeof membership.pointsEarned === "number" ? membership.pointsEarned : void 0,
207
- tierDiscount: paisaOr0(membership.tierDiscountApplied)
244
+ tierDiscount: minorOr0(membership.tierDiscountApplied)
208
245
  } : void 0,
209
246
  items: lines.map((line) => {
210
247
  const snap = line.snapshot ?? {};
@@ -225,18 +262,84 @@ function transformOrderToReceipt(orderResponse, branchInfo) {
225
262
  applicable: !!vat.applicable,
226
263
  rate: typeof vat.rate === "number" ? vat.rate : 0,
227
264
  amount: moneyAmount(totals.tax),
228
- taxableAmount: paisaOr0(vat.taxableAmount),
265
+ taxableAmount: minorOr0(vat.taxableAmount),
229
266
  sellerBin: vat.sellerBin ? String(vat.sellerBin) : void 0,
230
267
  pricesIncludeVat: typeof vat.pricesIncludeVat === "boolean" ? vat.pricesIncludeVat : true
231
268
  },
232
269
  total: moneyAmount(totals.grandTotal),
233
270
  payment: {
271
+ /**
272
+ * The real tender method from `order.tenders[0]`. `'cash'` remains the fallback for an
273
+ * order with no tender at all (a comped or unpaid sale), NOT for one whose method simply
274
+ * lived somewhere this mapper had stopped looking.
275
+ */
234
276
  method: primaryPayment?.method ?? "cash",
235
277
  ...primaryPayment?.reference ? { reference: primaryPayment.reference } : {},
236
278
  amount: moneyAmount(paymentState.totalCharged) || moneyAmount(totals.grandTotal)
237
- }
279
+ },
280
+ /**
281
+ * Change, when there was any. Printing it is the point of modelling it — the customer
282
+ * verifies the change against the receipt at the counter.
283
+ */
284
+ ...changeMinor !== void 0 ? { changeDue: changeMinor } : {}
285
+ };
286
+ }
287
+ /**
288
+ * `PosReceiptData` (either the fresh-checkout shape or `transformOrderToReceipt`'s
289
+ * reprint shape — both converge on this) → the structured `ReceiptPayload` the
290
+ * hardware port's agent/Tauri adapters need. Shared by `ReceiptScreen` (live
291
+ * checkout) and `ReceiptReprintDialog` (historical reprint) so both paths build
292
+ * the SAME payload shape for a real thermal printer — previously only the live
293
+ * screen went through the printer port at all; a reprint called
294
+ * `printDocument()` directly and could never reach a paired ESC/POS printer.
295
+ */
296
+ function buildReceiptPayload(receipt, branchName, branchPhone) {
297
+ const items = receipt.items ?? [];
298
+ const customerName = receipt.customer?.name?.trim() ?? "";
299
+ const showCustomer = customerName.length > 0 && customerName !== "Walk-in Customer";
300
+ const customerPhoneRaw = receipt.customer?.phone ?? null;
301
+ const showPhone = Boolean(customerPhoneRaw && !customerPhoneRaw.startsWith("pending_"));
302
+ return {
303
+ branch: {
304
+ name: branchName,
305
+ phone: branchPhone
306
+ },
307
+ shift: {
308
+ id: "",
309
+ cashierId: "",
310
+ cashierName: receipt.cashier ?? ""
311
+ },
312
+ order: {
313
+ number: String(receipt.orderNumber ?? ""),
314
+ placedAt: receipt.date ? new Date(receipt.date) : /* @__PURE__ */ new Date(),
315
+ lines: items.map((item) => ({
316
+ name: item.name,
317
+ variant: item.variant,
318
+ qty: item.quantity,
319
+ unitPrice: item.unitPrice ?? 0,
320
+ total: item.total,
321
+ taxRate: item.vatRate,
322
+ taxAmount: item.vatAmount
323
+ })),
324
+ subtotal: receipt.subtotal ?? 0,
325
+ tax: receipt.vat?.amount ?? 0,
326
+ discount: receipt.discount,
327
+ deliveryFee: receipt.deliveryCharge,
328
+ grandTotal: receipt.total ?? 0,
329
+ currency: getReceiptLocale().currency
330
+ },
331
+ payments: receipt.payment ? [{
332
+ method: receipt.payment.method,
333
+ amount: receipt.payment.amount ?? receipt.total ?? 0,
334
+ reference: receipt.payment.reference
335
+ }] : [],
336
+ customer: showCustomer ? {
337
+ name: customerName,
338
+ phone: showPhone ? customerPhoneRaw ?? void 0 : void 0,
339
+ loyaltyId: receipt.membership?.cardId
340
+ } : void 0
238
341
  };
239
342
  }
240
343
 
241
344
  //#endregion
242
- export { calculateAmountDue, calculateCartTotals, calculateChange, calculateOrderTotals, isPhoneSearch, isValidBDPhone, parseCashReceived, parsePositiveNumber, transformOrderToReceipt, validateRedemption };
345
+ export { buildReceiptPayload, calculateAmountDue, calculateCartTotals, calculateChange, calculateOrderTotals, isValidBDPhone, parseCashReceived, parsePositiveNumber, transformOrderToReceipt, validateRedemption };