@classytic/pos-ui 0.2.0 → 1.3.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.
- package/LICENSE +75 -0
- package/dist/components/CartSidebar.d.ts +56 -0
- package/dist/components/CartSidebar.js +10 -9
- package/dist/components/PosTopBar.d.ts +19 -0
- package/dist/components/PosTopBar.js +4 -4
- package/dist/components/PrinterSettingsDialog.d.ts +8 -0
- package/dist/components/PrinterSettingsDialog.js +506 -20
- package/dist/components/ReceiptDeliveryDialog.d.ts +11 -0
- package/dist/components/ReceiptDeliveryDialog.js +98 -0
- package/dist/components/ReceiptReprintDialog.d.ts +10 -0
- package/dist/components/ReceiptReprintDialog.js +49 -22
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +7 -0
- package/dist/dashboard/components/CustomerLookupDialog.js +108 -90
- package/dist/dashboard/components/CustomerQuickAddDialog.js +3 -1
- package/dist/dashboard/components/DeliveryPanel.js +134 -0
- package/dist/dashboard/components/ProductCard.js +17 -7
- package/dist/dashboard/components/ProductsPanel.js +4 -4
- package/dist/dashboard/components/SplitPaymentPanel.js +5 -5
- package/dist/dashboard/components/VariantSelectorDialog.js +53 -11
- package/dist/dashboard/components/cart/AddChargeDialog.js +3 -2
- package/dist/dashboard/components/cart/CartItems.js +5 -5
- package/dist/dashboard/components/cart/CartSummary.js +6 -6
- package/dist/dashboard/components/cart/CustomerSection.js +3 -3
- package/dist/dashboard/components/cart/DiscountSection.js +4 -3
- package/dist/dashboard/components/cart/PointsRedemptionSection.js +5 -4
- package/dist/dashboard/pos.types.d.ts +32 -0
- package/dist/hardware/agent-adapter.js +127 -0
- package/dist/hardware/agent-pairing.d.ts +18 -0
- package/dist/hardware/agent-pairing.js +63 -0
- package/dist/hardware/context.d.ts +1 -5
- package/dist/hardware/index.d.ts +4 -3
- package/dist/hardware/index.js +3 -2
- package/dist/hardware/ports.d.ts +21 -4
- package/dist/hardware/tauri-adapter.d.ts +0 -1
- package/dist/hardware/tauri-adapter.js +19 -5
- package/dist/hardware/web-adapter.d.ts +22 -7
- package/dist/hardware/web-adapter.js +56 -14
- package/dist/hooks/branch-scoped-key.js +64 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/sale-attempt.js +62 -0
- package/dist/hooks/useManagerAuth.js +9 -1
- package/dist/hooks/usePosCart.d.ts +55 -0
- package/dist/hooks/usePosCart.js +224 -117
- package/dist/hooks/usePosCustomer.d.ts +31 -0
- package/dist/hooks/usePosCustomer.js +32 -78
- package/dist/hooks/usePosDelivery.d.ts +28 -0
- package/dist/hooks/usePosDelivery.js +133 -0
- package/dist/hooks/usePosMultiOrder.d.ts +22 -0
- package/dist/hooks/usePosMultiOrder.js +14 -4
- package/dist/hooks/usePosPayment.d.ts +22 -0
- package/dist/lib/delivery-payload.js +83 -0
- package/dist/lib/money.js +96 -23
- package/dist/node_modules/react-hook-form/dist/index.esm.js +468 -332
- package/dist/runtime/auth-port.d.ts +1 -5
- package/dist/runtime/branch-port.d.ts +1 -5
- package/dist/runtime/config.d.ts +43 -6
- package/dist/runtime/config.js +18 -1
- package/dist/runtime/index.d.ts +2 -2
- package/dist/runtime/index.js +2 -2
- package/dist/screens/OrderHistoryDrawer.d.ts +8 -0
- package/dist/screens/OrderHistoryDrawer.js +15 -15
- package/dist/screens/ParkedOrdersDrawer.d.ts +12 -0
- package/dist/screens/ParkedOrdersDrawer.js +3 -3
- package/dist/screens/PaymentScreen.d.ts +4 -0
- package/dist/screens/PaymentScreen.js +194 -33
- package/dist/screens/ProductScreen.d.ts +4 -0
- package/dist/screens/ProductScreen.js +13 -10
- package/dist/screens/ReceiptScreen.d.ts +4 -0
- package/dist/screens/ReceiptScreen.js +96 -72
- package/dist/screens/ShiftCloseScreen.d.ts +4 -0
- package/dist/screens/ShiftCloseScreen.js +18 -15
- package/dist/screens/ShiftOpenScreen.d.ts +4 -0
- package/dist/screens/ShiftOpenScreen.js +75 -19
- package/dist/screens/index.d.ts +8 -0
- package/dist/screens/index.js +9 -0
- package/dist/shell/index.d.ts +2 -0
- package/dist/shell/pos-shell.d.ts +41 -8
- package/dist/shell/pos-shell.js +25 -18
- package/dist/state/index.d.ts +3 -0
- package/dist/state/index.js +4 -0
- package/dist/state/pos-context.d.ts +13 -0
- package/dist/state/pos-state.d.ts +49 -0
- package/dist/state/pos-state.js +6 -7
- package/dist/utils/pos-helpers.js +137 -34
- package/package.json +112 -92
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { branchScopedKey } from "./branch-scoped-key.js";
|
|
4
|
+
import { useCallback } from "react";
|
|
5
|
+
import { useLocalStorage } from "@classytic/fluid/client/hooks";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
|
|
8
|
+
//#region src/hooks/usePosDelivery.ts
|
|
9
|
+
/**
|
|
10
|
+
* Delivery and payment TERM for the sale being rung.
|
|
11
|
+
*
|
|
12
|
+
* Two independent decisions that a till has never been able to express, and which
|
|
13
|
+
* the backend now models end to end:
|
|
14
|
+
*
|
|
15
|
+
* - **where the goods go** — carryout (the cashier hands them over) or delivery
|
|
16
|
+
* (a courier does, later, to an address);
|
|
17
|
+
* - **when the customer pays** — now, or on delivery.
|
|
18
|
+
*
|
|
19
|
+
* They are orthogonal. A phone order can be prepaid by bKash and shipped; a
|
|
20
|
+
* walk-in can pay cash for something delivered tomorrow; and a COD order is the
|
|
21
|
+
* one where no money exists yet.
|
|
22
|
+
*
|
|
23
|
+
* ## Persisted, branch-scoped, for the reason the cart is
|
|
24
|
+
*
|
|
25
|
+
* `ProductScreen` and `PaymentScreen` each mount their own hook instances — the POS
|
|
26
|
+
* state machine swaps one screen for the other — so per-instance `useState` is wiped
|
|
27
|
+
* on navigation. A delivery address typed on one screen and silently empty on the
|
|
28
|
+
* next is the same class of loss `usePosCustomer` records, and here it would land as
|
|
29
|
+
* a refused sale (`DELIVERY_ADDRESS_UNUSABLE`) with the customer at the counter.
|
|
30
|
+
*
|
|
31
|
+
* The namespace goes through `branchScopedKey`, so an unresolved branch gets the
|
|
32
|
+
* sentinel and every mutator refuses rather than writing to a namespace about to be
|
|
33
|
+
* abandoned. See `branch-scoped-key.ts`.
|
|
34
|
+
*
|
|
35
|
+
* ## The address shape
|
|
36
|
+
*
|
|
37
|
+
* `line1` + `city` are what the host's normalizer REQUIRES (be-prod's
|
|
38
|
+
* `toFulfillmentAddress`; country defaults). The rest is evidence a courier and a
|
|
39
|
+
* dispatcher actually need. The server normalizes and REFUSES an undispatchable
|
|
40
|
+
* delivery before it takes payment or holds stock, so this form's job is to collect
|
|
41
|
+
* enough that the refusal never fires — not to re-implement the validation.
|
|
42
|
+
*
|
|
43
|
+
* NOTE: this is the seventh hand-rolled address surface in the repo. A shared
|
|
44
|
+
* `addressFields()` builder is planned (`tasks/pos-plan.md`); pos-ui depends on
|
|
45
|
+
* neither `@classytic/formkit` nor `@classytic/commerce-ui` today, so it cannot
|
|
46
|
+
* consume it yet. When it lands, this component is a migration target — the shape
|
|
47
|
+
* below is deliberately the same field set.
|
|
48
|
+
*/
|
|
49
|
+
/** Match the cart's window: an abandoned sale's delivery details expire with it. */
|
|
50
|
+
const DELIVERY_TTL = 864e5;
|
|
51
|
+
const EMPTY_ADDRESS = {
|
|
52
|
+
recipientName: "",
|
|
53
|
+
recipientPhone: "",
|
|
54
|
+
addressLine1: "",
|
|
55
|
+
city: "",
|
|
56
|
+
area: "",
|
|
57
|
+
note: ""
|
|
58
|
+
};
|
|
59
|
+
/** Major-unit fee. A blank field is no fee, not a zero-valued fee row. */
|
|
60
|
+
function parseFee(input) {
|
|
61
|
+
const n = Number.parseFloat(input);
|
|
62
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
63
|
+
}
|
|
64
|
+
function usePosDelivery(branchId) {
|
|
65
|
+
const { key, ready } = branchScopedKey("pos:delivery", branchId);
|
|
66
|
+
const [method, setMethodState] = useLocalStorage(`${key}:method`, "pickup", DELIVERY_TTL);
|
|
67
|
+
const [address, setAddress] = useLocalStorage(`${key}:address`, EMPTY_ADDRESS, DELIVERY_TTL);
|
|
68
|
+
const [feeInput, setFeeInputState] = useLocalStorage(`${key}:fee`, "", DELIVERY_TTL);
|
|
69
|
+
const [paymentTerm, setPaymentTermState] = useLocalStorage(`${key}:paymentTerm`, "prepaid", DELIVERY_TTL);
|
|
70
|
+
/**
|
|
71
|
+
* The one guard every mutator calls first — the same shape `usePosCart` uses, and
|
|
72
|
+
* loud on purpose. The alternative is a typed address that is about to be orphaned
|
|
73
|
+
* when the branch resolves and the key changes under it.
|
|
74
|
+
*/
|
|
75
|
+
const refuseIfBranchUnresolved = useCallback(() => {
|
|
76
|
+
if (ready) return false;
|
|
77
|
+
toast.error("Branch not ready", { description: "The register is still resolving its branch. Try again in a moment." });
|
|
78
|
+
return true;
|
|
79
|
+
}, [ready]);
|
|
80
|
+
return {
|
|
81
|
+
method,
|
|
82
|
+
address,
|
|
83
|
+
feeInput,
|
|
84
|
+
paymentTerm,
|
|
85
|
+
setMethod: useCallback((next) => {
|
|
86
|
+
if (refuseIfBranchUnresolved()) return;
|
|
87
|
+
setMethodState(next);
|
|
88
|
+
/**
|
|
89
|
+
* Switching back to carryout clears collect-on-delivery.
|
|
90
|
+
*
|
|
91
|
+
* "Collect on delivery" names a delivery that has not happened yet. On a
|
|
92
|
+
* carryout sale there is no later collection — the customer is standing there
|
|
93
|
+
* — so leaving the term set would submit a sale that owes money to nobody, and
|
|
94
|
+
* the server would refuse it after the cashier had already moved on.
|
|
95
|
+
*/
|
|
96
|
+
if (next === "pickup") setPaymentTermState("prepaid");
|
|
97
|
+
}, [
|
|
98
|
+
refuseIfBranchUnresolved,
|
|
99
|
+
setMethodState,
|
|
100
|
+
setPaymentTermState
|
|
101
|
+
]),
|
|
102
|
+
setAddressField: useCallback((field, value) => {
|
|
103
|
+
if (refuseIfBranchUnresolved()) return;
|
|
104
|
+
setAddress((prev) => ({
|
|
105
|
+
...prev,
|
|
106
|
+
[field]: value
|
|
107
|
+
}));
|
|
108
|
+
}, [refuseIfBranchUnresolved, setAddress]),
|
|
109
|
+
setFeeInput: useCallback((value) => {
|
|
110
|
+
if (refuseIfBranchUnresolved()) return;
|
|
111
|
+
setFeeInputState(value);
|
|
112
|
+
}, [refuseIfBranchUnresolved, setFeeInputState]),
|
|
113
|
+
setPaymentTerm: useCallback((term) => {
|
|
114
|
+
if (refuseIfBranchUnresolved()) return;
|
|
115
|
+
setPaymentTermState(term);
|
|
116
|
+
}, [refuseIfBranchUnresolved, setPaymentTermState]),
|
|
117
|
+
reset: useCallback(() => {
|
|
118
|
+
setMethodState("pickup");
|
|
119
|
+
setAddress(EMPTY_ADDRESS);
|
|
120
|
+
setFeeInputState("");
|
|
121
|
+
setPaymentTermState("prepaid");
|
|
122
|
+
}, [
|
|
123
|
+
setMethodState,
|
|
124
|
+
setAddress,
|
|
125
|
+
setFeeInputState,
|
|
126
|
+
setPaymentTermState
|
|
127
|
+
]),
|
|
128
|
+
fee: parseFee(feeInput)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
//#endregion
|
|
133
|
+
export { usePosDelivery };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { PosCartItem } from "../dashboard/pos.types.js";
|
|
2
|
+
//#region src/hooks/usePosMultiOrder.d.ts
|
|
3
|
+
interface ParkedOrder {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
cart: PosCartItem[];
|
|
7
|
+
customerName: string;
|
|
8
|
+
customerPhone: string;
|
|
9
|
+
membershipCardId: string;
|
|
10
|
+
discountInput: string;
|
|
11
|
+
parkedAt: number;
|
|
12
|
+
}
|
|
13
|
+
interface UsePosMultiOrderReturn {
|
|
14
|
+
orders: ParkedOrder[];
|
|
15
|
+
parkOrder: (order: Omit<ParkedOrder, "id" | "parkedAt">) => string;
|
|
16
|
+
resumeOrder: (id: string) => ParkedOrder | null;
|
|
17
|
+
deleteOrder: (id: string) => void;
|
|
18
|
+
canPark: boolean;
|
|
19
|
+
}
|
|
20
|
+
declare function usePosMultiOrder(branchId?: string): UsePosMultiOrderReturn;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { ParkedOrder, UsePosMultiOrderReturn, usePosMultiOrder };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { branchScopedKey } from "./branch-scoped-key.js";
|
|
3
4
|
import { useCallback } from "react";
|
|
4
5
|
import { useLocalStorage } from "@classytic/fluid/client/hooks";
|
|
5
6
|
|
|
@@ -11,13 +12,22 @@ import { useLocalStorage } from "@classytic/fluid/client/hooks";
|
|
|
11
12
|
* Each parked order saves: cart items, customer info, discount state.
|
|
12
13
|
*/
|
|
13
14
|
const MAX_PARKED_ORDERS = 5;
|
|
14
|
-
const PARKED_TTL =
|
|
15
|
+
const PARKED_TTL = 288e5;
|
|
15
16
|
function usePosMultiOrder(branchId) {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Per-BRANCH, and an unresolved branch is not a branch — the same defect the
|
|
19
|
+
* cart had, with a longer fuse: a sale parked under `"default"` was orphaned the
|
|
20
|
+
* moment the branch resolved, and parked sales are the ONE cart the cashier
|
|
21
|
+
* expects to still be there after walking away. `branch-scoped-key.ts` has the
|
|
22
|
+
* full account.
|
|
23
|
+
*/
|
|
24
|
+
const { key, ready } = branchScopedKey("pos:parked", branchId);
|
|
25
|
+
const [orders, setOrders] = useLocalStorage(key, [], PARKED_TTL);
|
|
26
|
+
const canPark = ready && orders.length < MAX_PARKED_ORDERS;
|
|
18
27
|
return {
|
|
19
28
|
orders,
|
|
20
29
|
parkOrder: useCallback((order) => {
|
|
30
|
+
if (!ready) return "";
|
|
21
31
|
const id = `parked_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
22
32
|
const parked = {
|
|
23
33
|
...order,
|
|
@@ -26,7 +36,7 @@ function usePosMultiOrder(branchId) {
|
|
|
26
36
|
};
|
|
27
37
|
setOrders((prev) => [...prev, parked]);
|
|
28
38
|
return id;
|
|
29
|
-
}, [setOrders]),
|
|
39
|
+
}, [setOrders, ready]),
|
|
30
40
|
resumeOrder: useCallback((id) => {
|
|
31
41
|
let found = null;
|
|
32
42
|
setOrders((prev) => {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { PaymentOption, PaymentState, PosPaymentMethod, SplitPaymentEntry } from "@classytic/commerce-sdk/sales";
|
|
2
|
+
//#region src/hooks/usePosPayment.d.ts
|
|
3
|
+
declare function paymentNeedsReference(posMethod: PosPaymentMethod): boolean;
|
|
4
|
+
interface UsePosPaymentReturn {
|
|
5
|
+
options: PaymentOption[];
|
|
6
|
+
isLoading: boolean;
|
|
7
|
+
state: PaymentState;
|
|
8
|
+
selectedOption: PaymentOption | null;
|
|
9
|
+
selectPayment: (key: string) => void;
|
|
10
|
+
setMode: (mode: "single" | "split") => void;
|
|
11
|
+
setCashReceived: (value: string) => void;
|
|
12
|
+
setReference: (value: string) => void;
|
|
13
|
+
addSplit: (paymentKey?: string) => void;
|
|
14
|
+
updateSplit: (id: string, patch: Partial<Pick<SplitPaymentEntry, "paymentKey" | "amount" | "reference">>) => void;
|
|
15
|
+
removeSplit: (id: string) => void;
|
|
16
|
+
validateSplitEntry: (id: string) => string | undefined;
|
|
17
|
+
validateAllSplits: () => boolean;
|
|
18
|
+
reset: () => void;
|
|
19
|
+
}
|
|
20
|
+
declare function usePosPayment(token?: string, total?: number): UsePosPaymentReturn;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { type PaymentOption, type SplitPaymentEntry, UsePosPaymentReturn, paymentNeedsReference, usePosPayment };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
//#region src/lib/delivery-payload.ts
|
|
2
|
+
/**
|
|
3
|
+
* What the customer owes: goods (after discounts) PLUS the delivery charge.
|
|
4
|
+
*
|
|
5
|
+
* The server computes the same sum and validates the tender against it, so a screen
|
|
6
|
+
* quoting only the cart total collects too little and the sale is refused AFTER the
|
|
7
|
+
* cashier has counted cash. Every payable figure on the screen reads this — numpad
|
|
8
|
+
* target, split check, cash suggestions, both button labels.
|
|
9
|
+
*
|
|
10
|
+
* The fee only counts on a DELIVERY sale: a charge left in the box from a previous
|
|
11
|
+
* selection must not silently inflate a carryout.
|
|
12
|
+
*/
|
|
13
|
+
function payableTotalOf(cartTotal, decision) {
|
|
14
|
+
return decision.method === "delivery" && decision.fee > 0 ? cartTotal + decision.fee : cartTotal;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Does this sale take money at the till?
|
|
18
|
+
*
|
|
19
|
+
* Both conditions, not just the term. "Collect on delivery" names a delivery that
|
|
20
|
+
* has not happened yet; on a carryout the customer is standing at the counter, so a
|
|
21
|
+
* COD flag there would tell the cashier not to collect money that is owed right now.
|
|
22
|
+
*/
|
|
23
|
+
function isCollectOnDeliverySale(decision) {
|
|
24
|
+
return decision.paymentTerm === "collect_on_delivery" && decision.method === "delivery";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Why this sale cannot be submitted, or `null`.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors the server's refusal so the cashier is stopped at the field rather than at
|
|
30
|
+
* the end of checkout. `addressLine1` + `city` are what the host normalizer requires;
|
|
31
|
+
* the server re-checks and refuses (`DELIVERY_ADDRESS_UNUSABLE`) before taking a
|
|
32
|
+
* tender or holding stock, so this is an affordance, never the enforcement.
|
|
33
|
+
*/
|
|
34
|
+
function deliveryBlocker(decision) {
|
|
35
|
+
if (decision.method !== "delivery") return null;
|
|
36
|
+
if (!decision.address.addressLine1?.trim()) return "Enter the delivery address";
|
|
37
|
+
if (!decision.address.city?.trim()) return "Enter the delivery city or town";
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The delivery fields for the create payload.
|
|
42
|
+
*
|
|
43
|
+
* Three omissions are deliberate and each has a consequence:
|
|
44
|
+
*
|
|
45
|
+
* - **no `deliveryAddress` on a carryout** — there is nothing to dispatch to, and
|
|
46
|
+
* a stale address from a previous selection would ride along;
|
|
47
|
+
* - **no `deliveryPrice` when the fee is zero** — free delivery is real (a
|
|
48
|
+
* promotion, a loyalty perk), and an explicit `0` writes a priced shipping row
|
|
49
|
+
* for a sale that has none rather than leaving it identical to a carryout;
|
|
50
|
+
* - **blank address fields dropped** — an empty string is not a recipient name.
|
|
51
|
+
*
|
|
52
|
+
* `paymentTerm` is ALWAYS sent, prepaid included: the till knows the answer, and a
|
|
53
|
+
* stated fact beats one every downstream reader infers from absence — absence is
|
|
54
|
+
* also what an order written before the field existed looks like, and those are
|
|
55
|
+
* different claims.
|
|
56
|
+
*/
|
|
57
|
+
function deliveryPayloadOf(decision) {
|
|
58
|
+
const base = {
|
|
59
|
+
deliveryMethod: decision.method,
|
|
60
|
+
paymentTerm: decision.paymentTerm
|
|
61
|
+
};
|
|
62
|
+
if (decision.method !== "delivery") return base;
|
|
63
|
+
const a = decision.address;
|
|
64
|
+
const address = {};
|
|
65
|
+
const put = (key, value) => {
|
|
66
|
+
const trimmed = value?.trim();
|
|
67
|
+
if (trimmed) address[key] = trimmed;
|
|
68
|
+
};
|
|
69
|
+
put("addressLine1", a.addressLine1);
|
|
70
|
+
put("city", a.city);
|
|
71
|
+
put("recipientName", a.recipientName);
|
|
72
|
+
put("recipientPhone", a.recipientPhone);
|
|
73
|
+
put("area", a.area);
|
|
74
|
+
put("note", a.note);
|
|
75
|
+
return {
|
|
76
|
+
...base,
|
|
77
|
+
deliveryAddress: address,
|
|
78
|
+
...decision.fee > 0 ? { deliveryPrice: decision.fee } : {}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { deliveryBlocker, deliveryPayloadOf, isCollectOnDeliverySale, payableTotalOf };
|
package/dist/lib/money.js
CHANGED
|
@@ -1,25 +1,62 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { minorUnitFactor } from "@classytic/primitives/currency";
|
|
2
|
+
import { MinorUnits, money, toMajor, toMajor as toMajor$1 } from "@classytic/primitives/money";
|
|
2
3
|
|
|
3
4
|
//#region src/lib/money.ts
|
|
4
5
|
/**
|
|
5
6
|
* Money for POS — composed on `@classytic/primitives` (the canonical Money type
|
|
6
|
-
* + arithmetic authority), with thin
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* + arithmetic authority), with thin display formatters on top. Arithmetic and
|
|
8
|
+
* the `Money` type always come from primitives — this module never
|
|
9
|
+
* re-implements them.
|
|
10
|
+
*
|
|
11
|
+
* ## The tenant seam is the ONE fork knob
|
|
12
|
+
*
|
|
13
|
+
* `@classytic/pos-ui` is a country-agnostic package: it is published under the
|
|
14
|
+
* neutral `@classytic` scope and mounted by hosts in any jurisdiction. The
|
|
15
|
+
* deployment's currency, locale and symbol therefore live in the three
|
|
16
|
+
* constants below, env-driven with a BD default, exactly as
|
|
17
|
+
* `commerce-ui/src/lib/tenant.ts` does it. A fork sets the env vars; it does
|
|
18
|
+
* not edit code.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the exported names are `Minor` / `Major`, never `Paisa` / `Bdt`
|
|
21
|
+
*
|
|
22
|
+
* This module used to export `Paisa`, `formatPaisa`, `formatPaisaPlain`,
|
|
23
|
+
* `paisaToMoney`, `formatBdt` and `formatBdtFixed`. Every one of those is
|
|
24
|
+
* currency-neutral arithmetic wearing a Bangladeshi name, and the names are
|
|
25
|
+
* what the next author reads. A `formatBdt` in a package that claims to serve
|
|
26
|
+
* any country reads as though the package knows what currency it holds — so a
|
|
27
|
+
* USD deployment either believes it must fork, or calls `formatBdt` on dollars
|
|
28
|
+
* and ships a function name that lies about the money in it.
|
|
29
|
+
*
|
|
30
|
+
* The subunit name belongs to the DEPLOYMENT (paisa for BDT, cents for USD,
|
|
31
|
+
* fils for KWD). The package only ever knows "minor units" and "major units".
|
|
32
|
+
* Hosts that want a local alias declare their own — `be-prod/src/shared/money.ts`
|
|
33
|
+
* is the reference: it is the Bangladesh host, so `Paisa` is correct there.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Exported so callers that need the CURRENCY itself (a minor-unit factor, an
|
|
37
|
+
* `Intl` call) read it from the seam instead of re-declaring a literal — a
|
|
38
|
+
* second `"BDT"` elsewhere is a fork knob nobody knows to turn.
|
|
10
39
|
*/
|
|
11
40
|
const TENANT_CURRENCY = "BDT";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const TENANT_SYMBOL =
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
const TENANT_LOCALE = process.env.NEXT_PUBLIC_LOCALE ?? "en-BD";
|
|
42
|
+
/**
|
|
43
|
+
* Exported so a LABEL or placeholder can name the currency without a literal.
|
|
44
|
+
* `"Amount (BDT)"` hardcoded in JSX is the same defect as a `Paisa` type name:
|
|
45
|
+
* it survives a fork silently and then lies to the cashier.
|
|
46
|
+
*/
|
|
47
|
+
const TENANT_CURRENCY_SYMBOL = process.env.NEXT_PUBLIC_CURRENCY_SYMBOL ?? "৳";
|
|
48
|
+
const TENANT_SYMBOL = TENANT_CURRENCY_SYMBOL;
|
|
49
|
+
/**
|
|
50
|
+
* Cast a raw number to `TenantMinorUnits`. Use at SDK boundaries when the
|
|
51
|
+
* response type isn't yet branded, or for arithmetic results that guarantee
|
|
52
|
+
* minor units. One cast per source — not per callsite.
|
|
53
|
+
*/
|
|
54
|
+
function TenantMinorUnits(n) {
|
|
55
|
+
return MinorUnits(n, "BDT");
|
|
56
|
+
}
|
|
57
|
+
/** Adapt a minor-unit wire value into a structured `Money`. */
|
|
58
|
+
function minorToMoney(minor, currency = "BDT") {
|
|
59
|
+
return money(minor, currency);
|
|
23
60
|
}
|
|
24
61
|
/** Canonical formatter — structured `Money` → `৳1,234.00`. */
|
|
25
62
|
function formatMoney(m) {
|
|
@@ -28,14 +65,50 @@ function formatMoney(m) {
|
|
|
28
65
|
currency: m.currency
|
|
29
66
|
}).format(toMajor(m));
|
|
30
67
|
}
|
|
31
|
-
/** Format a wire-format
|
|
32
|
-
function
|
|
33
|
-
return formatMoney(
|
|
68
|
+
/** Format a wire-format minor-unit value: `formatMinor(489900)` → `৳4,899.00`. */
|
|
69
|
+
function formatMinor(minor, currency = "BDT") {
|
|
70
|
+
return formatMoney(minorToMoney(minor, currency));
|
|
71
|
+
}
|
|
72
|
+
/** Compact MAJOR-unit formatter, no forced decimals: `formatMajor(4899)` → `৳4,899`. */
|
|
73
|
+
function formatMajor(amount) {
|
|
74
|
+
return `${TENANT_SYMBOL}${(Number.isFinite(amount) ? amount : 0).toLocaleString(TENANT_LOCALE)}`;
|
|
34
75
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Minor units in ONE major unit of the tenant currency, from
|
|
78
|
+
* `@classytic/primitives/currency` — never a hardcoded `100`. BDT/USD are 100,
|
|
79
|
+
* KWD is 1000, JPY is 1, so any rate derived by dividing by a literal is wrong
|
|
80
|
+
* for a third of the world.
|
|
81
|
+
*/
|
|
82
|
+
function minorPerMajorUnit(currency = "BDT") {
|
|
83
|
+
return minorUnitFactor(currency);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Minor units -> major units, via the CURRENCY's factor.
|
|
87
|
+
*
|
|
88
|
+
* Replaces a hand-written `/ 100` at five call sites (variant absolute prices in the
|
|
89
|
+
* cart and the variant dialog, cash counted at shift open/close, the hardware
|
|
90
|
+
* adapter's amount). `100` is right for BDT and USD and wrong for KWD (1000) and JPY
|
|
91
|
+
* (1) — and each site had derived it independently, so they could drift apart as
|
|
92
|
+
* easily as they were all wrong together.
|
|
93
|
+
*/
|
|
94
|
+
function minorToMajor(minor, currency = "BDT") {
|
|
95
|
+
return toMajor(minorToMoney(Number.isFinite(minor) ? minor : 0, currency));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Major units -> integer minor units.
|
|
99
|
+
*
|
|
100
|
+
* Rounds, because the input is typically a parsed decimal from an operator's keypad
|
|
101
|
+
* and IEEE division leaves values like `4899.999999999999`. A truncating conversion
|
|
102
|
+
* there loses a minor unit on ordinary amounts.
|
|
103
|
+
*
|
|
104
|
+
* The `currency` parameter exists to make the factor OBSERVABLE. Without it the tests
|
|
105
|
+
* could not tell this apart from a hardcoded `100` — BDT's factor is 100, so
|
|
106
|
+
* substituting the literal kept every assertion green. A guard that cannot fail for
|
|
107
|
+
* the reason it exists is decoration; callers still omit the argument.
|
|
108
|
+
*/
|
|
109
|
+
function majorToMinor(major, currency = "BDT") {
|
|
110
|
+
return Math.round((Number.isFinite(major) ? major : 0) * minorUnitFactor(currency));
|
|
38
111
|
}
|
|
39
112
|
|
|
40
113
|
//#endregion
|
|
41
|
-
export {
|
|
114
|
+
export { TENANT_CURRENCY_SYMBOL, TenantMinorUnits, formatMajor, formatMinor, formatMoney, majorToMinor, minorPerMajorUnit, minorToMajor, minorToMoney, toMajor$1 as toMajor };
|