@tallyui/pos 0.1.0 → 2.0.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 +21 -0
- package/dist/index.d.ts +1770 -42
- package/dist/index.js +3422 -204
- package/package.json +21 -10
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +45 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +5 -0
- package/src/order/order-builder.ts +240 -95
- package/src/order/order-manager.ts +23 -42
- package/src/order/types.ts +67 -15
- package/src/outbox/http-transport.ts +66 -0
- package/src/outbox/index.ts +7 -0
- package/src/outbox/order-outbox.ts +198 -0
- package/src/outbox/types.ts +22 -0
- package/src/outbox/use-order-outbox.ts +161 -0
- package/src/pos-order/command.ts +35 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +81 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +11 -0
- package/src/pos-order/open.ts +206 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +100 -0
- package/src/pos-order/types.ts +67 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +29 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +36 -22
- package/src/receipt/types.ts +16 -10
- package/src/register/__fixtures__/closure-local-row.json +61 -0
- package/src/register/__fixtures__/closure.json +197 -0
- package/src/register/__fixtures__/corrections-dst.json +63 -0
- package/src/register/closure-document.ts +270 -0
- package/src/register/closure-rows.ts +65 -0
- package/src/register/document-labels.ts +46 -0
- package/src/register/expected.ts +66 -0
- package/src/register/export-csv.ts +74 -0
- package/src/register/facts.ts +109 -0
- package/src/register/index.ts +30 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -0
- package/src/register/register-count.denominations.ts +11 -0
- package/src/register/register-count.helpers.ts +64 -0
- package/src/register/register-document.ts +243 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +541 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +425 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +22 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +337 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +13 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +69 -0
- package/src/tax/exact.ts +155 -0
- package/src/tax/index.ts +3 -2
- package/src/tax/tax-provider.tsx +13 -12
- package/src/tax/types.ts +3 -7
- package/src/tender/index.ts +2 -0
- package/src/tender/tender-state.ts +326 -0
- package/src/currency/currency-provider.test.tsx +0 -36
- package/src/currency/format-currency.test.ts +0 -36
- package/src/currency/format-currency.ts +0 -18
- package/src/logging/logger.test.ts +0 -154
- package/src/logging/sinks.test.ts +0 -60
- package/src/order/discount-engine.test.ts +0 -152
- package/src/order/order-builder.test.ts +0 -246
- package/src/order/order-manager.test.ts +0 -209
- package/src/order/payment.test.ts +0 -91
- package/src/receipt/build-receipt-data.test.ts +0 -166
- package/src/repository/create-repository.test.ts +0 -166
- package/src/tax/calculate.test.ts +0 -59
- package/src/tax/calculate.ts +0 -32
- package/src/tax/tax-provider.test.tsx +0 -51
package/dist/index.js
CHANGED
|
@@ -82,26 +82,9 @@ function callbackSink(options) {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
// src/currency/format-currency.ts
|
|
86
|
-
var formatters = /* @__PURE__ */ new Map();
|
|
87
|
-
function getFormatter(currencyCode, locale) {
|
|
88
|
-
const key = `${currencyCode}:${locale ?? "default"}`;
|
|
89
|
-
let formatter = formatters.get(key);
|
|
90
|
-
if (!formatter) {
|
|
91
|
-
formatter = new Intl.NumberFormat(locale, {
|
|
92
|
-
style: "currency",
|
|
93
|
-
currency: currencyCode
|
|
94
|
-
});
|
|
95
|
-
formatters.set(key, formatter);
|
|
96
|
-
}
|
|
97
|
-
return formatter;
|
|
98
|
-
}
|
|
99
|
-
function formatCurrency(amount, currencyCode, locale) {
|
|
100
|
-
return getFormatter(currencyCode, locale).format(amount);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
85
|
// src/currency/currency-provider.tsx
|
|
104
86
|
import { createContext, useContext, useMemo, useCallback } from "react";
|
|
87
|
+
import { formatMoney } from "@tallyui/core";
|
|
105
88
|
import { jsx } from "react/jsx-runtime";
|
|
106
89
|
var CurrencyCtx = createContext(null);
|
|
107
90
|
function CurrencyProvider({ currencyCode, locale, children }) {
|
|
@@ -114,55 +97,131 @@ function useCurrencyFormatter() {
|
|
|
114
97
|
throw new Error("useCurrencyFormatter() must be used within a <CurrencyProvider>");
|
|
115
98
|
}
|
|
116
99
|
return useCallback(
|
|
117
|
-
(
|
|
118
|
-
[ctx.
|
|
100
|
+
(money) => formatMoney(money, ctx.locale) ?? `${money.amount} ${money.currency}`,
|
|
101
|
+
[ctx.locale]
|
|
119
102
|
);
|
|
120
103
|
}
|
|
104
|
+
function useCurrencyCode() {
|
|
105
|
+
const ctx = useContext(CurrencyCtx);
|
|
106
|
+
if (!ctx) {
|
|
107
|
+
throw new Error("useCurrencyCode() must be used within a <CurrencyProvider>");
|
|
108
|
+
}
|
|
109
|
+
return ctx.currencyCode;
|
|
110
|
+
}
|
|
121
111
|
|
|
122
|
-
// src/tax/
|
|
123
|
-
|
|
124
|
-
|
|
112
|
+
// src/tax/exact.ts
|
|
113
|
+
var MICROS_PER_MINOR = 1000000n;
|
|
114
|
+
function ratePpmFromPercent(percent) {
|
|
115
|
+
const value = String(percent);
|
|
116
|
+
const match = /^(\d+)(?:\.(\d{1,4}))?$/.exec(value);
|
|
117
|
+
if (!match || match[0] !== value) {
|
|
118
|
+
throw new RangeError("Percent must be a plain decimal with at most four fractional digits");
|
|
119
|
+
}
|
|
120
|
+
const ratePpm = BigInt(match[1]) * 10000n + BigInt((match[2] ?? "").padEnd(4, "0"));
|
|
121
|
+
if (ratePpm > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
122
|
+
throw new RangeError("Rate must be a safe integer");
|
|
123
|
+
}
|
|
124
|
+
return Number(ratePpm);
|
|
125
125
|
}
|
|
126
|
-
function
|
|
127
|
-
|
|
126
|
+
function taxMicros(amountMinor, ratePpm, pricesIncludeTax) {
|
|
127
|
+
if (!Number.isSafeInteger(amountMinor) || !Number.isSafeInteger(ratePpm) || ratePpm < 0) {
|
|
128
|
+
throw new RangeError("Amount must be a safe integer and rate a non-negative safe integer");
|
|
129
|
+
}
|
|
130
|
+
const tax = BigInt(amountMinor) * BigInt(ratePpm);
|
|
131
|
+
if (!pricesIncludeTax) return tax;
|
|
132
|
+
const numerator = tax * MICROS_PER_MINOR;
|
|
133
|
+
const denominator = MICROS_PER_MINOR + BigInt(ratePpm);
|
|
134
|
+
const sign = numerator < 0n ? -1n : 1n;
|
|
135
|
+
return sign * ((sign * numerator + denominator / 2n) / denominator);
|
|
128
136
|
}
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
function roundMicrosToMinor(micros) {
|
|
138
|
+
const sign = micros < 0n ? -1n : 1n;
|
|
139
|
+
const minor2 = sign * ((sign * micros + MICROS_PER_MINOR / 2n) / MICROS_PER_MINOR);
|
|
140
|
+
if (minor2 < -BigInt(Number.MAX_SAFE_INTEGER) || minor2 > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
141
|
+
throw new RangeError("Rounded amount must be a safe integer");
|
|
142
|
+
}
|
|
143
|
+
return Number(minor2);
|
|
132
144
|
}
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
145
|
+
function computeOrderTax(lines, pricesIncludeTax) {
|
|
146
|
+
let amountTotal = 0n;
|
|
147
|
+
let taxTotal = 0n;
|
|
148
|
+
const lineTaxMicros = [];
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
if (!Number.isSafeInteger(line.quantity) || line.quantity < 1) {
|
|
151
|
+
throw new RangeError("Quantity must be a safe integer >= 1");
|
|
152
|
+
}
|
|
153
|
+
let tax = taxMicros(line.unitPriceMinor, line.ratePpm, false) * BigInt(line.quantity);
|
|
154
|
+
const amount = BigInt(line.unitPriceMinor) * BigInt(line.quantity);
|
|
155
|
+
if (pricesIncludeTax) {
|
|
156
|
+
const numerator = tax * MICROS_PER_MINOR;
|
|
157
|
+
const denominator = MICROS_PER_MINOR + BigInt(line.ratePpm);
|
|
158
|
+
const sign = numerator < 0n ? -1n : 1n;
|
|
159
|
+
tax = sign * ((sign * numerator + denominator / 2n) / denominator);
|
|
160
|
+
}
|
|
161
|
+
amountTotal += amount;
|
|
162
|
+
taxTotal += tax;
|
|
163
|
+
lineTaxMicros.push(tax);
|
|
164
|
+
}
|
|
165
|
+
const taxMinor = taxTotal / MICROS_PER_MINOR + BigInt(roundMicrosToMinor(taxTotal % MICROS_PER_MINOR));
|
|
166
|
+
const subtotalMinor = pricesIncludeTax ? amountTotal - taxMinor : amountTotal;
|
|
167
|
+
const totalMinor = pricesIncludeTax ? amountTotal : amountTotal + taxMinor;
|
|
168
|
+
for (const value of [subtotalMinor, taxMinor, totalMinor]) {
|
|
169
|
+
if (value < -BigInt(Number.MAX_SAFE_INTEGER) || value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
170
|
+
throw new RangeError("Order totals must be safe integers");
|
|
171
|
+
}
|
|
141
172
|
}
|
|
142
|
-
const taxAmount = round2(price * rate);
|
|
143
173
|
return {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
subtotalMinor: Number(subtotalMinor),
|
|
175
|
+
taxMinor: Number(taxMinor),
|
|
176
|
+
totalMinor: Number(totalMinor),
|
|
177
|
+
lineTaxMicros
|
|
147
178
|
};
|
|
148
179
|
}
|
|
180
|
+
function taxLinesByRate(lines, orderTaxMinor, taxLabels) {
|
|
181
|
+
const byRate = /* @__PURE__ */ new Map();
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
const lineTaxMicros = line.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n);
|
|
184
|
+
const base = line.taxInclusive ? line.netMinor - roundMicrosToMinor(lineTaxMicros) : line.netMinor;
|
|
185
|
+
for (const tax of line.taxLines) {
|
|
186
|
+
const key = JSON.stringify([tax.code ?? "", tax.ratePpm]);
|
|
187
|
+
const existing = byRate.get(key);
|
|
188
|
+
byRate.set(key, {
|
|
189
|
+
code: tax.code,
|
|
190
|
+
ratePpm: tax.ratePpm,
|
|
191
|
+
micros: (existing?.micros ?? 0n) + BigInt(tax.taxMicros),
|
|
192
|
+
netMinor: (existing?.netMinor ?? 0) + base
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const groups = Array.from(byRate.values()).map(({ code, ratePpm, micros, netMinor }) => {
|
|
197
|
+
const floor = micros / MICROS_PER_MINOR - (micros < 0n && micros % MICROS_PER_MINOR !== 0n ? 1n : 0n);
|
|
198
|
+
return {
|
|
199
|
+
line: { label: taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 1e4}%`, code, ratePpm, netMinor, amountMinor: Number(floor) },
|
|
200
|
+
remainder: micros - floor * MICROS_PER_MINOR
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
const leftover = orderTaxMinor - groups.reduce((sum, group) => sum + group.line.amountMinor, 0);
|
|
204
|
+
const ranked = [...groups].sort((a, b) => a.remainder === b.remainder ? b.line.ratePpm - a.line.ratePpm : a.remainder > b.remainder ? -1 : 1);
|
|
205
|
+
for (const group of ranked.slice(0, leftover)) group.line.amountMinor += 1;
|
|
206
|
+
return groups.map((group) => group.line);
|
|
207
|
+
}
|
|
149
208
|
|
|
150
209
|
// src/tax/tax-provider.tsx
|
|
151
210
|
import { createContext as createContext2, useContext as useContext2, useMemo as useMemo2 } from "react";
|
|
152
211
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
153
212
|
var TaxCtx = createContext2(null);
|
|
154
|
-
function TaxProvider({
|
|
213
|
+
function TaxProvider({ ratesPpm, pricesIncludeTax, children }) {
|
|
155
214
|
const value = useMemo2(
|
|
156
|
-
() =>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
[
|
|
215
|
+
() => {
|
|
216
|
+
if (Object.values(ratesPpm).some((rate) => !Number.isSafeInteger(rate) || rate < 0)) {
|
|
217
|
+
throw new RangeError("TaxProvider: rates must be integer ppm");
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
getTaxRatePpm: (taxClass) => (taxClass === void 0 ? void 0 : ratesPpm[taxClass]) ?? ratesPpm.default ?? 0,
|
|
221
|
+
pricesIncludeTax
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
[ratesPpm, pricesIncludeTax]
|
|
166
225
|
);
|
|
167
226
|
return /* @__PURE__ */ jsx2(TaxCtx.Provider, { value, children });
|
|
168
227
|
}
|
|
@@ -174,12 +233,89 @@ function useTax() {
|
|
|
174
233
|
return ctx;
|
|
175
234
|
}
|
|
176
235
|
|
|
236
|
+
// src/store-settings/resolve-store-settings.ts
|
|
237
|
+
import { StoreSettingsError } from "@tallyui/core";
|
|
238
|
+
function narrowInitial(tried, choices) {
|
|
239
|
+
const region = !choices.regions || choices.regions.some((r) => r.id === tried.region) ? tried.region : void 0;
|
|
240
|
+
const country = !choices.countries || tried.country !== void 0 && choices.countries.includes(tried.country) ? tried.country : void 0;
|
|
241
|
+
const channel = !choices.channels || choices.channels.some((c) => c.id === tried.channel) ? tried.channel : void 0;
|
|
242
|
+
const initial = { ...region !== void 0 && { region }, ...country !== void 0 && { country }, ...channel !== void 0 && { channel } };
|
|
243
|
+
return Object.keys(initial).length > 0 ? initial : void 0;
|
|
244
|
+
}
|
|
245
|
+
async function resolveStoreSettings(options, choice) {
|
|
246
|
+
const { connector, context, loadChoice, saveChoice, onSaveError } = options;
|
|
247
|
+
if (!connector.storeSettings) return { status: "unsupported" };
|
|
248
|
+
const tried = choice ?? await Promise.resolve().then(loadChoice).catch(() => void 0);
|
|
249
|
+
let settings;
|
|
250
|
+
try {
|
|
251
|
+
settings = await connector.storeSettings(context, tried);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error instanceof StoreSettingsError && error.code === "choice_required") {
|
|
254
|
+
const offered = error.choices ?? {};
|
|
255
|
+
const initial = tried && narrowInitial(tried, offered);
|
|
256
|
+
return { status: "choose", choices: offered, ...initial && { initial } };
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
if (choice) await Promise.resolve().then(() => saveChoice(choice)).catch((error) => onSaveError?.(error));
|
|
261
|
+
return { status: "ready", settings, ...tried && { choice: tried } };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/store-settings/map-store-settings.ts
|
|
265
|
+
function withPricingContext(context, settings) {
|
|
266
|
+
const { pricingContext: _previous, ...rest } = context;
|
|
267
|
+
return settings.pricingContext ? { ...rest, pricingContext: settings.pricingContext } : rest;
|
|
268
|
+
}
|
|
269
|
+
function taxProviderProps(settings) {
|
|
270
|
+
return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/store-settings/use-store-settings.ts
|
|
274
|
+
import { useCallback as useCallback2, useEffect, useRef, useState } from "react";
|
|
275
|
+
var LOADING = { state: "loading" };
|
|
276
|
+
function useStoreSettings(options) {
|
|
277
|
+
const { connector, context } = options;
|
|
278
|
+
const optionsRef = useRef(options);
|
|
279
|
+
optionsRef.current = options;
|
|
280
|
+
const requestRef = useRef(0);
|
|
281
|
+
const [state, setState] = useState(LOADING);
|
|
282
|
+
const resolve = useCallback2((choice, from) => {
|
|
283
|
+
if (from !== void 0 && from !== requestRef.current) return;
|
|
284
|
+
const request = ++requestRef.current;
|
|
285
|
+
const current = () => request === requestRef.current;
|
|
286
|
+
setState(LOADING);
|
|
287
|
+
resolveStoreSettings(optionsRef.current, choice).then(
|
|
288
|
+
(result) => {
|
|
289
|
+
if (!current()) return;
|
|
290
|
+
if (result.status === "ready") setState({ state: "ready", settings: result.settings, choice: result.choice });
|
|
291
|
+
else if (result.status === "choose")
|
|
292
|
+
setState({ state: "choose", choices: result.choices, initial: result.initial, choose: (pick) => resolve(pick, request) });
|
|
293
|
+
else setState({ state: "unsupported" });
|
|
294
|
+
},
|
|
295
|
+
// A retry keeps the choice that failed: a new pick is saved only once it resolves.
|
|
296
|
+
(error) => {
|
|
297
|
+
if (current()) setState({ state: "error", error, retry: () => resolve(choice, request) });
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
}, []);
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
resolve();
|
|
303
|
+
return () => {
|
|
304
|
+
requestRef.current += 1;
|
|
305
|
+
};
|
|
306
|
+
}, [connector, context, resolve]);
|
|
307
|
+
return state;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/rxdb/index.ts
|
|
311
|
+
import { readFresh, countFresh, watchFresh } from "@tallyui/core/rxdb";
|
|
312
|
+
|
|
177
313
|
// src/repository/create-repository.ts
|
|
178
314
|
import { map } from "rxjs";
|
|
179
315
|
function createRepository(collection) {
|
|
180
316
|
return {
|
|
181
|
-
findById$(
|
|
182
|
-
return collection.findOne(
|
|
317
|
+
findById$(id2) {
|
|
318
|
+
return collection.findOne(id2).$.pipe(
|
|
183
319
|
map((doc) => doc ? doc.toJSON() : null)
|
|
184
320
|
);
|
|
185
321
|
},
|
|
@@ -213,15 +349,15 @@ function createRepository(collection) {
|
|
|
213
349
|
const doc = await collection.insert(data);
|
|
214
350
|
return doc.toJSON();
|
|
215
351
|
},
|
|
216
|
-
async update(
|
|
217
|
-
const doc = await collection.findOne(
|
|
218
|
-
if (!doc) throw new Error(`Document ${
|
|
352
|
+
async update(id2, patch) {
|
|
353
|
+
const doc = await collection.findOne(id2).exec();
|
|
354
|
+
if (!doc) throw new Error(`Document ${id2} not found`);
|
|
219
355
|
const updated = await doc.incrementalPatch(patch);
|
|
220
356
|
return updated.toJSON();
|
|
221
357
|
},
|
|
222
|
-
async remove(
|
|
223
|
-
const doc = await collection.findOne(
|
|
224
|
-
if (!doc) throw new Error(`Document ${
|
|
358
|
+
async remove(id2) {
|
|
359
|
+
const doc = await collection.findOne(id2).exec();
|
|
360
|
+
if (!doc) throw new Error(`Document ${id2} not found`);
|
|
225
361
|
await doc.remove();
|
|
226
362
|
},
|
|
227
363
|
async bulkCreate(items) {
|
|
@@ -236,12 +372,59 @@ function createRepository(collection) {
|
|
|
236
372
|
|
|
237
373
|
// src/order/order-builder.ts
|
|
238
374
|
import { BehaviorSubject } from "rxjs";
|
|
375
|
+
import { resolvePrice } from "@tallyui/core";
|
|
376
|
+
|
|
377
|
+
// src/order/allocate-order-discount.ts
|
|
378
|
+
function allocateOrderDiscount(lineAmountsMinor, totalMinor) {
|
|
379
|
+
if (!Number.isSafeInteger(totalMinor) || totalMinor < 0 || !lineAmountsMinor.every(Number.isSafeInteger)) {
|
|
380
|
+
throw new RangeError("Amounts must be safe integers and the discount non-negative");
|
|
381
|
+
}
|
|
382
|
+
const weights = lineAmountsMinor.map((amount) => BigInt(Math.max(0, amount)));
|
|
383
|
+
const weightTotal = weights.reduce((sum, weight) => sum + weight, 0n);
|
|
384
|
+
const total = BigInt(totalMinor);
|
|
385
|
+
if (total > weightTotal) throw new RangeError("Order discount exceeds the lines it applies to");
|
|
386
|
+
if (total === 0n) return weights.map(() => 0);
|
|
387
|
+
const shares = weights.map((weight) => total * weight / weightTotal);
|
|
388
|
+
const remainders = weights.map((weight) => total * weight % weightTotal);
|
|
389
|
+
let leftover = total - shares.reduce((sum, share) => sum + share, 0n);
|
|
390
|
+
const ranked = remainders.map((_, index) => index).sort((a, b) => remainders[a] === remainders[b] ? a - b : remainders[a] > remainders[b] ? -1 : 1);
|
|
391
|
+
for (const index of ranked) {
|
|
392
|
+
if (leftover === 0n) break;
|
|
393
|
+
shares[index] += 1n;
|
|
394
|
+
leftover -= 1n;
|
|
395
|
+
}
|
|
396
|
+
return shares.map(Number);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/order/order-builder.ts
|
|
239
400
|
var nextId = 0;
|
|
240
401
|
function uid() {
|
|
241
402
|
return `${Date.now()}-${++nextId}`;
|
|
242
403
|
}
|
|
404
|
+
function roundHalfAway(n) {
|
|
405
|
+
return Math.sign(n) * Math.round(Math.abs(n));
|
|
406
|
+
}
|
|
407
|
+
function sumLines(lines) {
|
|
408
|
+
const netMinor = lines.reduce((sum, li) => sum + li.netMinor, 0);
|
|
409
|
+
const lineTaxMicros = lines.reduce((sum, li) => sum + BigInt(li.taxMicros), 0n);
|
|
410
|
+
const exclusiveTaxMicros = lines.reduce((sum, li) => li.taxInclusive ? sum : sum + BigInt(li.taxMicros), 0n);
|
|
411
|
+
const taxMinor = roundMicrosToMinor(lineTaxMicros);
|
|
412
|
+
const linesTotal = netMinor + roundMicrosToMinor(exclusiveTaxMicros);
|
|
413
|
+
return { subtotalMinor: linesTotal - taxMinor, taxMinor, totalMinor: Math.max(0, linesTotal) };
|
|
414
|
+
}
|
|
415
|
+
function toDisplayMode(line, amountMinor, displayInclusive) {
|
|
416
|
+
if (line.taxInclusive === displayInclusive) return amountMinor;
|
|
417
|
+
const ratePpm = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
|
|
418
|
+
const taxMinor = roundMicrosToMinor(taxMicros(amountMinor, ratePpm, line.taxInclusive));
|
|
419
|
+
return line.taxInclusive ? amountMinor - taxMinor : amountMinor + taxMinor;
|
|
420
|
+
}
|
|
421
|
+
function assertDisplayResidue(residue, convertedLines, conversions) {
|
|
422
|
+
const bound = convertedLines === 0 ? 0 : Math.floor(conversions / 2) + 1;
|
|
423
|
+
if (Math.abs(residue) > bound) throw new Error(`Display residue ${residue} exceeds its bound of ${bound} (ADR-063)`);
|
|
424
|
+
}
|
|
243
425
|
function createOrderBuilder(options) {
|
|
244
|
-
const {
|
|
426
|
+
const { taxContext } = options;
|
|
427
|
+
const currency = options.currency.toUpperCase();
|
|
245
428
|
const orderId = options.id ?? uid();
|
|
246
429
|
let lineItems = [];
|
|
247
430
|
let orderDiscounts = [];
|
|
@@ -250,65 +433,131 @@ function createOrderBuilder(options) {
|
|
|
250
433
|
let note = "";
|
|
251
434
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
252
435
|
const subject = new BehaviorSubject(buildOrder());
|
|
253
|
-
function recalculateLine(line) {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
436
|
+
function recalculateLine(line, orderDiscountMinor = 0) {
|
|
437
|
+
const grossMinor = line.unitPriceMinor * line.quantity;
|
|
438
|
+
let remaining = grossMinor;
|
|
439
|
+
const recalcedDiscounts = line.discounts.map((d) => {
|
|
440
|
+
const amountMinor = Math.min(remaining, computeDiscountAmount(d, d.type === "percentage" ? grossMinor : remaining));
|
|
441
|
+
remaining -= amountMinor;
|
|
442
|
+
return { ...d, amountMinor };
|
|
443
|
+
});
|
|
444
|
+
const lineDiscountMinor = Math.min(grossMinor, recalcedDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
|
|
445
|
+
const discountMinor = lineDiscountMinor + orderDiscountMinor;
|
|
446
|
+
const netMinor = grossMinor - discountMinor;
|
|
447
|
+
const combinedRate = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
|
|
448
|
+
const inclusiveTax = line.taxInclusive ? taxMicros(netMinor, combinedRate, true) : 0n;
|
|
449
|
+
let allocatedTax = 0n;
|
|
450
|
+
const taxLines = line.taxLines.map((tax, index) => {
|
|
451
|
+
const micros = line.taxInclusive ? index === line.taxLines.length - 1 ? inclusiveTax - allocatedTax : combinedRate === 0 ? 0n : inclusiveTax * BigInt(tax.ratePpm) / BigInt(combinedRate) : taxMicros(netMinor, tax.ratePpm, false);
|
|
452
|
+
allocatedTax += micros;
|
|
453
|
+
return { ...tax, taxMicros: micros.toString() };
|
|
454
|
+
});
|
|
262
455
|
return {
|
|
263
456
|
...line,
|
|
264
457
|
discounts: recalcedDiscounts,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
458
|
+
discountMinor,
|
|
459
|
+
orderDiscountMinor,
|
|
460
|
+
netMinor,
|
|
461
|
+
taxLines,
|
|
462
|
+
taxMicros: allocatedTax.toString()
|
|
268
463
|
};
|
|
269
464
|
}
|
|
270
465
|
function computeDiscountAmount(discount, base) {
|
|
271
466
|
if (discount.type === "percentage") {
|
|
272
|
-
return Math.
|
|
467
|
+
return Math.max(0, roundHalfAway(base * discount.value / 100));
|
|
273
468
|
}
|
|
274
|
-
|
|
469
|
+
if (!Number.isInteger(discount.value)) throw new RangeError("Fixed discount must be integer minor units");
|
|
470
|
+
return Math.max(0, Math.min(discount.value, base));
|
|
275
471
|
}
|
|
276
472
|
function buildOrder() {
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return
|
|
284
|
-
}
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
473
|
+
const lineAmounts = lineItems.map((li) => li.netMinor);
|
|
474
|
+
const base = lineAmounts.reduce((sum, amount) => sum + Math.max(0, amount), 0);
|
|
475
|
+
let remaining = base;
|
|
476
|
+
const recalcedOrderDiscounts = orderDiscounts.map((d) => {
|
|
477
|
+
const amountMinor = Math.min(remaining, computeDiscountAmount(d, d.type === "percentage" ? base : remaining));
|
|
478
|
+
remaining -= amountMinor;
|
|
479
|
+
return { ...d, amountMinor };
|
|
480
|
+
});
|
|
481
|
+
const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
|
|
482
|
+
const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
|
|
483
|
+
const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines);
|
|
484
|
+
const discountMinor = lines.reduce((sum, li) => sum + li.discountMinor, 0);
|
|
485
|
+
const taxInclusive = taxContext.pricesIncludeTax;
|
|
486
|
+
const figures = lines.map((li) => {
|
|
487
|
+
const returned = li.unitPriceMinor < 0;
|
|
488
|
+
const discounts = returned ? [] : li.discounts.map((d) => ({
|
|
489
|
+
discountId: d.id,
|
|
490
|
+
...d.label !== void 0 ? { label: d.label } : {},
|
|
491
|
+
amountMinor: toDisplayMode(li, d.amountMinor, taxInclusive)
|
|
492
|
+
}));
|
|
493
|
+
const shareMinor = toDisplayMode(li, li.orderDiscountMinor, taxInclusive);
|
|
494
|
+
const remainingMinor = toDisplayMode(li, li.netMinor, taxInclusive);
|
|
495
|
+
const amountMinor = li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity : discounts.reduce((sum, d) => sum + d.amountMinor, remainingMinor + shareMinor);
|
|
496
|
+
return { line: { lineId: li.id, amountMinor, discounts }, shareMinor, remainingMinor, returned };
|
|
497
|
+
});
|
|
498
|
+
const displayLines = figures.map((f) => f.line);
|
|
499
|
+
const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
|
|
500
|
+
const displayDiscount = displayLines.reduce(
|
|
501
|
+
(sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum),
|
|
502
|
+
orderDiscountDisplay
|
|
503
|
+
);
|
|
504
|
+
const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount;
|
|
505
|
+
const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
|
|
506
|
+
const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
|
|
507
|
+
const conversions = converted.reduce((count, index) => count + [
|
|
508
|
+
lines[index].netMinor,
|
|
509
|
+
lines[index].orderDiscountMinor,
|
|
510
|
+
...lines[index].discounts.map((d) => d.amountMinor)
|
|
511
|
+
].filter((x) => x !== 0).length, 0);
|
|
512
|
+
assertDisplayResidue(residue, converted.length, conversions);
|
|
513
|
+
let left = residue;
|
|
514
|
+
const takers = converted.filter((index) => !figures[index].returned);
|
|
515
|
+
for (const index of takers.sort((a, b) => figures[b].remainingMinor - figures[a].remainingMinor || a - b)) {
|
|
516
|
+
if (left === 0) break;
|
|
517
|
+
const take = left > 0 ? left : Math.max(left, -figures[index].remainingMinor);
|
|
518
|
+
displayLines[index].amountMinor += take;
|
|
519
|
+
left -= take;
|
|
520
|
+
}
|
|
521
|
+
if (left !== 0) throw new Error(`Display residue ${left} left over with no converted line to take it (ADR-063)`);
|
|
522
|
+
if (displayDiscount < 0) throw new Error(`Display discount ${displayDiscount} is negative (ADR-063)`);
|
|
523
|
+
for (const { line, shareMinor, returned } of figures) {
|
|
524
|
+
if (returned && line.discounts.length > 0) throw new Error(`Return line ${line.lineId} shows discount rows (ADR-063)`);
|
|
525
|
+
if (returned) continue;
|
|
526
|
+
const rows = line.discounts.reduce((sum, d) => sum + d.amountMinor, 0);
|
|
527
|
+
if (shareMinor < 0 || line.discounts.some((d) => d.amountMinor < 0) || line.amountMinor < rows + shareMinor) {
|
|
528
|
+
throw new Error(`Display line ${line.lineId} shows ${line.amountMinor}, less than its rows ${rows} and share ${shareMinor} (ADR-063)`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const display = {
|
|
532
|
+
taxInclusive,
|
|
533
|
+
subtotalMinor: displaySubtotal,
|
|
534
|
+
discountMinor: displayDiscount,
|
|
535
|
+
taxMinor,
|
|
536
|
+
totalMinor,
|
|
537
|
+
lines: displayLines,
|
|
538
|
+
orderDiscountMinor: orderDiscountDisplay
|
|
539
|
+
};
|
|
540
|
+
const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
|
|
541
|
+
const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
|
|
542
|
+
const changeDueMinor = Math.max(0, paidMinor - totalMinor);
|
|
297
543
|
return {
|
|
298
544
|
id: orderId,
|
|
299
545
|
status: "draft",
|
|
300
|
-
lineItems:
|
|
546
|
+
lineItems: lines,
|
|
301
547
|
discounts: recalcedOrderDiscounts,
|
|
302
548
|
payments: [...payments],
|
|
303
549
|
customer,
|
|
304
550
|
note,
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
551
|
+
subtotalMinor,
|
|
552
|
+
discountMinor,
|
|
553
|
+
taxMinor,
|
|
554
|
+
totalMinor,
|
|
555
|
+
display,
|
|
556
|
+
paidMinor,
|
|
557
|
+
balanceDueMinor,
|
|
558
|
+
changeDueMinor,
|
|
311
559
|
currency,
|
|
560
|
+
pricesIncludeTax: taxContext.pricesIncludeTax,
|
|
312
561
|
createdAt: now,
|
|
313
562
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
314
563
|
};
|
|
@@ -316,45 +565,72 @@ function createOrderBuilder(options) {
|
|
|
316
565
|
function emit() {
|
|
317
566
|
subject.next(buildOrder());
|
|
318
567
|
}
|
|
568
|
+
function addLine(input) {
|
|
569
|
+
const { productId, variantId, unitPrice } = input;
|
|
570
|
+
const quantity = input.quantity ?? 1;
|
|
571
|
+
if (unitPrice.currency !== currency) throw new RangeError("Line currency must match " + currency);
|
|
572
|
+
if (!Number.isInteger(unitPrice.amount)) throw new RangeError("Price must be integer minor units");
|
|
573
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError("Quantity must be an integer >= 1");
|
|
574
|
+
const taxRates = input.taxRates ?? [{ ratePpm: taxContext.getTaxRatePpm() }];
|
|
575
|
+
const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
|
|
576
|
+
const existing = lineItems.find(
|
|
577
|
+
(li) => li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm)
|
|
578
|
+
);
|
|
579
|
+
if (existing) {
|
|
580
|
+
lineItems = lineItems.map(
|
|
581
|
+
(li) => li.id === existing.id ? recalculateLine({ ...li, quantity: li.quantity + quantity }) : li
|
|
582
|
+
);
|
|
583
|
+
emit();
|
|
584
|
+
return existing.id;
|
|
585
|
+
}
|
|
586
|
+
const lineId = uid();
|
|
587
|
+
const line = {
|
|
588
|
+
id: lineId,
|
|
589
|
+
productId,
|
|
590
|
+
variantId,
|
|
591
|
+
name: input.name,
|
|
592
|
+
sku: input.sku ?? "",
|
|
593
|
+
imageUrl: input.imageUrl,
|
|
594
|
+
unitPriceMinor: unitPrice.amount,
|
|
595
|
+
quantity,
|
|
596
|
+
taxLines: taxRates.map((tax) => ({ ...tax, taxMicros: "0" })),
|
|
597
|
+
discounts: [],
|
|
598
|
+
discountMinor: 0,
|
|
599
|
+
orderDiscountMinor: 0,
|
|
600
|
+
netMinor: 0,
|
|
601
|
+
taxMicros: "0",
|
|
602
|
+
taxInclusive,
|
|
603
|
+
// TV4b derives the store-level flag with Medusa's own precedence (region
|
|
604
|
+
// preference, then currency), so a disagreement should be rare. It can
|
|
605
|
+
// still happen, for example when a price preference changes after the
|
|
606
|
+
// settings were read. Keeping the line in its own mode means the customer
|
|
607
|
+
// pays exactly the shelf price, as Medusa's checkout would.
|
|
608
|
+
...taxInclusive !== taxContext.pricesIncludeTax ? { priceTaxModeConverted: taxInclusive ? "inclusive-to-exclusive" : "exclusive-to-inclusive" } : {}
|
|
609
|
+
};
|
|
610
|
+
lineItems = [...lineItems, recalculateLine(line)];
|
|
611
|
+
emit();
|
|
612
|
+
return lineId;
|
|
613
|
+
}
|
|
319
614
|
return {
|
|
320
615
|
order$: subject.asObservable(),
|
|
616
|
+
addLine,
|
|
321
617
|
addProduct(doc, traits, opts) {
|
|
322
618
|
const productId = traits.getId(doc);
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
);
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
lineItems = lineItems.map(
|
|
331
|
-
(li) => li.id === existing.id ? recalculateLine(existing) : li
|
|
332
|
-
);
|
|
333
|
-
emit();
|
|
334
|
-
return existing.id;
|
|
335
|
-
}
|
|
336
|
-
const priceStr = traits.getPrice(doc);
|
|
337
|
-
const price = priceStr ? parseFloat(priceStr) : 0;
|
|
338
|
-
const taxRate = taxContext.getTaxRate();
|
|
339
|
-
const lineId = uid();
|
|
340
|
-
const line = {
|
|
341
|
-
id: lineId,
|
|
619
|
+
if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
|
|
620
|
+
const variant = opts?.variantId !== void 0 && traits.getVariants ? traits.getVariants(doc, { currency }).find((variant2) => variant2.id === opts.variantId) : void 0;
|
|
621
|
+
if (opts?.variantId !== void 0 && traits.getVariants && !variant)
|
|
622
|
+
throw new Error(`Unknown variant ${opts.variantId} for product ${productId}`);
|
|
623
|
+
const unitPrice = resolvePrice(variant ? variant.prices : traits.getPrices(doc, { currency }), currency)?.current;
|
|
624
|
+
if (!unitPrice) throw new Error("No price in " + currency + " for product " + productId);
|
|
625
|
+
return addLine({
|
|
342
626
|
productId,
|
|
343
|
-
variantId,
|
|
627
|
+
variantId: opts?.variantId,
|
|
344
628
|
name: traits.getName(doc),
|
|
345
|
-
sku: traits.getSku(doc)
|
|
629
|
+
sku: variant?.sku ?? traits.getSku(doc),
|
|
346
630
|
imageUrl: traits.getImageUrl(doc),
|
|
347
|
-
|
|
348
|
-
quantity
|
|
349
|
-
|
|
350
|
-
taxAmount: 0,
|
|
351
|
-
discounts: [],
|
|
352
|
-
discountAmount: 0,
|
|
353
|
-
lineTotal: 0
|
|
354
|
-
};
|
|
355
|
-
lineItems = [...lineItems, recalculateLine(line)];
|
|
356
|
-
emit();
|
|
357
|
-
return lineId;
|
|
631
|
+
unitPrice,
|
|
632
|
+
quantity: opts?.quantity
|
|
633
|
+
});
|
|
358
634
|
},
|
|
359
635
|
updateQuantity(lineId, quantity) {
|
|
360
636
|
if (quantity <= 0) {
|
|
@@ -373,11 +649,11 @@ function createOrderBuilder(options) {
|
|
|
373
649
|
applyLineDiscount(lineId, discount) {
|
|
374
650
|
lineItems = lineItems.map((li) => {
|
|
375
651
|
if (li.id !== lineId) return li;
|
|
376
|
-
const base = li.
|
|
652
|
+
const base = li.unitPriceMinor * li.quantity;
|
|
377
653
|
const applied = {
|
|
378
654
|
...discount,
|
|
379
655
|
id: uid(),
|
|
380
|
-
|
|
656
|
+
amountMinor: computeDiscountAmount(discount, base)
|
|
381
657
|
};
|
|
382
658
|
return recalculateLine({ ...li, discounts: [...li.discounts, applied] });
|
|
383
659
|
});
|
|
@@ -387,7 +663,7 @@ function createOrderBuilder(options) {
|
|
|
387
663
|
const applied = {
|
|
388
664
|
...discount,
|
|
389
665
|
id: uid(),
|
|
390
|
-
|
|
666
|
+
amountMinor: computeDiscountAmount(discount, 0)
|
|
391
667
|
};
|
|
392
668
|
orderDiscounts = [...orderDiscounts, applied];
|
|
393
669
|
emit();
|
|
@@ -404,10 +680,10 @@ function createOrderBuilder(options) {
|
|
|
404
680
|
emit();
|
|
405
681
|
},
|
|
406
682
|
addPayment(payment) {
|
|
407
|
-
const
|
|
408
|
-
payments = [...payments, { ...payment, id }];
|
|
683
|
+
const id2 = uid();
|
|
684
|
+
payments = [...payments, { ...payment, id: id2 }];
|
|
409
685
|
emit();
|
|
410
|
-
return
|
|
686
|
+
return id2;
|
|
411
687
|
},
|
|
412
688
|
removePayment(paymentId) {
|
|
413
689
|
payments = payments.filter((p) => p.id !== paymentId);
|
|
@@ -438,7 +714,8 @@ function createOrderBuilder(options) {
|
|
|
438
714
|
// src/order/order-manager.ts
|
|
439
715
|
import { BehaviorSubject as BehaviorSubject2, map as map2 } from "rxjs";
|
|
440
716
|
function createOrderManager(options) {
|
|
441
|
-
const {
|
|
717
|
+
const { taxContext, draftsCollection } = options;
|
|
718
|
+
const currency = options.currency.toUpperCase();
|
|
442
719
|
let activeBuilder = createOrderBuilder({ currency, taxContext });
|
|
443
720
|
const activeSubject = new BehaviorSubject2(activeBuilder);
|
|
444
721
|
const parkedOrders$ = draftsCollection.find().$.pipe(
|
|
@@ -449,7 +726,7 @@ function createOrderManager(options) {
|
|
|
449
726
|
id: json.id,
|
|
450
727
|
customerName: json.customerName || void 0,
|
|
451
728
|
itemCount: json.itemCount ?? 0,
|
|
452
|
-
|
|
729
|
+
totalMinor: json.total ?? 0,
|
|
453
730
|
parkedAt: json.parkedAt ?? "",
|
|
454
731
|
source: "local"
|
|
455
732
|
};
|
|
@@ -471,7 +748,7 @@ function createOrderManager(options) {
|
|
|
471
748
|
data: JSON.stringify(snapshot),
|
|
472
749
|
customerName: snapshot.customer?.name ?? "",
|
|
473
750
|
itemCount: snapshot.lineItems.length,
|
|
474
|
-
total: snapshot.
|
|
751
|
+
total: snapshot.totalMinor,
|
|
475
752
|
parkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
476
753
|
});
|
|
477
754
|
activeBuilder = createOrderBuilder({ currency, taxContext });
|
|
@@ -495,43 +772,23 @@ function createOrderManager(options) {
|
|
|
495
772
|
builder.setNote(savedOrder.note);
|
|
496
773
|
}
|
|
497
774
|
for (const line of savedOrder.lineItems) {
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
getName: (d) => d.name,
|
|
501
|
-
getSku: (d) => d.sku,
|
|
502
|
-
getPrice: (d) => String(d.price),
|
|
503
|
-
getRegularPrice: (d) => String(d.price),
|
|
504
|
-
getSalePrice: () => void 0,
|
|
505
|
-
isOnSale: () => false,
|
|
506
|
-
getImageUrl: (d) => d.imageUrl,
|
|
507
|
-
getImageUrls: (d) => d.imageUrl ? [d.imageUrl] : [],
|
|
508
|
-
getDescription: () => void 0,
|
|
509
|
-
getStockStatus: () => "instock",
|
|
510
|
-
getStockQuantity: () => null,
|
|
511
|
-
hasVariants: () => false,
|
|
512
|
-
getType: () => "simple",
|
|
513
|
-
getBarcode: () => void 0,
|
|
514
|
-
getCategoryNames: () => []
|
|
515
|
-
};
|
|
516
|
-
builder.addProduct(line, syntheticTraits, {
|
|
775
|
+
const lineId = builder.addLine({
|
|
776
|
+
productId: line.productId,
|
|
517
777
|
variantId: line.variantId,
|
|
518
|
-
|
|
778
|
+
name: line.name,
|
|
779
|
+
sku: line.sku,
|
|
780
|
+
imageUrl: line.imageUrl,
|
|
781
|
+
unitPrice: { amount: line.unitPriceMinor, currency, taxInclusive: line.taxInclusive },
|
|
782
|
+
quantity: line.quantity,
|
|
783
|
+
taxRates: line.taxLines.map(({ code, ratePpm }) => ({ code, ratePpm }))
|
|
519
784
|
});
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
for (const discount of savedLine.discounts) {
|
|
528
|
-
builder.applyLineDiscount(newLine.id, {
|
|
529
|
-
type: discount.type,
|
|
530
|
-
value: discount.value,
|
|
531
|
-
label: discount.label,
|
|
532
|
-
couponCode: discount.couponCode
|
|
533
|
-
});
|
|
534
|
-
}
|
|
785
|
+
for (const discount of line.discounts) {
|
|
786
|
+
builder.applyLineDiscount(lineId, {
|
|
787
|
+
type: discount.type,
|
|
788
|
+
value: discount.value,
|
|
789
|
+
label: discount.label,
|
|
790
|
+
couponCode: discount.couponCode
|
|
791
|
+
});
|
|
535
792
|
}
|
|
536
793
|
}
|
|
537
794
|
for (const discount of savedOrder.discounts) {
|
|
@@ -545,7 +802,9 @@ function createOrderManager(options) {
|
|
|
545
802
|
for (const payment of savedOrder.payments) {
|
|
546
803
|
builder.addPayment({
|
|
547
804
|
method: payment.method,
|
|
548
|
-
|
|
805
|
+
amountMinor: payment.amountMinor,
|
|
806
|
+
tenderedMinor: payment.tenderedMinor,
|
|
807
|
+
changeMinor: payment.changeMinor,
|
|
549
808
|
reference: payment.reference
|
|
550
809
|
});
|
|
551
810
|
}
|
|
@@ -563,16 +822,20 @@ function createOrderManager(options) {
|
|
|
563
822
|
|
|
564
823
|
// src/receipt/build-receipt-data.ts
|
|
565
824
|
function buildReceiptData(order, config) {
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
825
|
+
const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels).map(
|
|
826
|
+
({ label: label2, code, ratePpm, amountMinor }) => ({ label: label2, code, ratePpm, amountMinor })
|
|
827
|
+
);
|
|
828
|
+
let taxSoFar = order.lineItems.reduce(
|
|
829
|
+
(sum, li) => li.taxInclusive || li.priceTaxModeConverted ? sum : sum + BigInt(li.taxMicros),
|
|
830
|
+
0n
|
|
831
|
+
);
|
|
832
|
+
const lineTotals = order.lineItems.map((li) => {
|
|
833
|
+
if (!li.priceTaxModeConverted) return li.netMinor;
|
|
834
|
+
const share = roundMicrosToMinor(taxSoFar + BigInt(li.taxMicros)) - roundMicrosToMinor(taxSoFar);
|
|
835
|
+
taxSoFar += BigInt(li.taxMicros);
|
|
836
|
+
return li.taxInclusive ? li.netMinor - share : li.netMinor + share;
|
|
837
|
+
});
|
|
838
|
+
const label = (d) => d.label ?? d.couponCode ?? `${d.type} discount`;
|
|
576
839
|
return {
|
|
577
840
|
header: {
|
|
578
841
|
storeName: config.storeName,
|
|
@@ -582,30 +845,36 @@ function buildReceiptData(order, config) {
|
|
|
582
845
|
cashier: config.cashier,
|
|
583
846
|
register: config.register
|
|
584
847
|
},
|
|
585
|
-
lineItems: order.lineItems.map((li) => ({
|
|
848
|
+
lineItems: order.lineItems.map((li, index) => ({
|
|
586
849
|
name: li.name,
|
|
587
850
|
sku: li.sku,
|
|
588
851
|
quantity: li.quantity,
|
|
589
|
-
|
|
590
|
-
|
|
852
|
+
unitPriceMinor: li.unitPriceMinor,
|
|
853
|
+
lineTotalMinor: lineTotals[index],
|
|
854
|
+
// Line plus allocated order discounts, in the line's own mode; lineTotalMinor is already after it (ADR-062).
|
|
855
|
+
...li.discountMinor > 0 ? { discountMinor: li.discountMinor } : {},
|
|
856
|
+
displayAmountMinor: order.display.lines[index].amountMinor,
|
|
857
|
+
displayDiscounts: order.display.lines[index].discounts.map((row, i) => ({ label: label(li.discounts[i]), amountMinor: row.amountMinor }))
|
|
591
858
|
})),
|
|
592
859
|
discounts: order.discounts.map((d) => ({
|
|
593
|
-
label:
|
|
594
|
-
|
|
860
|
+
label: label(d),
|
|
861
|
+
amountMinor: d.amountMinor
|
|
595
862
|
})),
|
|
863
|
+
orderDiscountMinor: order.display.orderDiscountMinor,
|
|
596
864
|
totals: {
|
|
597
|
-
|
|
598
|
-
|
|
865
|
+
taxInclusive: order.display.taxInclusive,
|
|
866
|
+
subtotalMinor: order.display.subtotalMinor,
|
|
867
|
+
discountMinor: order.display.discountMinor,
|
|
599
868
|
taxLines,
|
|
600
|
-
|
|
601
|
-
|
|
869
|
+
taxMinor: order.display.taxMinor,
|
|
870
|
+
totalMinor: order.display.totalMinor
|
|
602
871
|
},
|
|
603
872
|
payments: order.payments.map((p) => ({
|
|
604
873
|
method: p.method,
|
|
605
|
-
|
|
874
|
+
amountMinor: p.amountMinor,
|
|
606
875
|
reference: p.reference
|
|
607
876
|
})),
|
|
608
|
-
|
|
877
|
+
changeDueMinor: order.changeDueMinor,
|
|
609
878
|
footer: {
|
|
610
879
|
note: order.note || void 0,
|
|
611
880
|
barcode: order.id
|
|
@@ -613,20 +882,2969 @@ function buildReceiptData(order, config) {
|
|
|
613
882
|
currency: order.currency
|
|
614
883
|
};
|
|
615
884
|
}
|
|
885
|
+
|
|
886
|
+
// src/product/search-products.ts
|
|
887
|
+
function searchProducts(docs, term, traits) {
|
|
888
|
+
const query = term.trim().toLowerCase();
|
|
889
|
+
if (!query) return docs;
|
|
890
|
+
const codes = (doc) => [traits.getSku(doc), traits.getBarcode(doc)].filter((code) => Boolean(code)).map((code) => code.toLowerCase());
|
|
891
|
+
const exact = docs.filter((doc) => codes(doc).includes(query));
|
|
892
|
+
if (exact.length) return exact;
|
|
893
|
+
const words = query.split(/\s+/);
|
|
894
|
+
return docs.filter((doc) => {
|
|
895
|
+
const haystack = [traits.getName(doc).toLowerCase(), ...codes(doc)].join(" ");
|
|
896
|
+
return words.every((word) => haystack.includes(word));
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// src/product/stock.ts
|
|
901
|
+
import { map as map3 } from "rxjs";
|
|
902
|
+
import { STOCK_LEVELS_LAST_PASS } from "@tallyui/core";
|
|
903
|
+
import { withStockOverlay, getProductStock } from "@tallyui/core";
|
|
904
|
+
function stockOverlay$(collection) {
|
|
905
|
+
return collection.find().$.pipe(
|
|
906
|
+
map3((rows) => new Map(rows.map((row) => [row.primary, row.get("value")])))
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
function stockOverlayAsOf$(collection) {
|
|
910
|
+
return collection.getLocal$(STOCK_LEVELS_LAST_PASS).pipe(
|
|
911
|
+
map3((doc) => doc?.get("completedAt"))
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/sale/use-sale.ts
|
|
916
|
+
import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
|
|
917
|
+
|
|
918
|
+
// src/pos-order/uuidv7.ts
|
|
919
|
+
function format(ms, bytes) {
|
|
920
|
+
let timestamp = BigInt(ms);
|
|
921
|
+
for (let i = 5; i >= 0; i--) {
|
|
922
|
+
bytes[i] = Number(timestamp & 0xffn);
|
|
923
|
+
timestamp >>= 8n;
|
|
924
|
+
}
|
|
925
|
+
bytes[6] = bytes[6] & 15 | 112;
|
|
926
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
927
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
928
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
929
|
+
}
|
|
930
|
+
function createUuidv7(options) {
|
|
931
|
+
let lastMs = -Infinity;
|
|
932
|
+
let lastRand = 0n;
|
|
933
|
+
return () => {
|
|
934
|
+
const randomBytes = options?.randomBytes;
|
|
935
|
+
if (!randomBytes && !globalThis.crypto?.getRandomValues) throw new Error("uuidv7: no random source");
|
|
936
|
+
let ms = Math.max((options?.now ?? Date.now)(), lastMs);
|
|
937
|
+
let rand = lastRand + 1n;
|
|
938
|
+
if (ms === lastMs && rand === 2n ** 74n) ms = lastMs + 1;
|
|
939
|
+
if (ms !== lastMs) {
|
|
940
|
+
const bytes2 = randomBytes ? randomBytes(16) : globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
941
|
+
rand = BigInt(bytes2[6] & 15) << 8n | BigInt(bytes2[7]);
|
|
942
|
+
rand = rand << 6n | BigInt(bytes2[8] & 63);
|
|
943
|
+
for (let i = 9; i < 16; i++) rand = rand << 8n | BigInt(bytes2[i]);
|
|
944
|
+
}
|
|
945
|
+
lastMs = ms;
|
|
946
|
+
lastRand = rand;
|
|
947
|
+
const bytes = new Uint8Array(16);
|
|
948
|
+
for (let i = 15; i >= 9; i--) {
|
|
949
|
+
bytes[i] = Number(rand & 0xffn);
|
|
950
|
+
rand >>= 8n;
|
|
951
|
+
}
|
|
952
|
+
bytes[8] = Number(rand & 0x3fn);
|
|
953
|
+
rand >>= 6n;
|
|
954
|
+
bytes[7] = Number(rand & 0xffn);
|
|
955
|
+
bytes[6] = Number(rand >> 8n);
|
|
956
|
+
return format(ms, bytes);
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
var monotonic = createUuidv7();
|
|
960
|
+
function uuidv7(now, randomBytes) {
|
|
961
|
+
if (now === void 0 && randomBytes === void 0) return monotonic();
|
|
962
|
+
const ms = now ?? Date.now();
|
|
963
|
+
if (!randomBytes && !globalThis.crypto?.getRandomValues) throw new Error("uuidv7: no random source");
|
|
964
|
+
return format(ms, randomBytes ? randomBytes(16) : globalThis.crypto.getRandomValues(new Uint8Array(16)));
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// src/pos-order/finalize.ts
|
|
968
|
+
function finalizeOrder(order, options = {}) {
|
|
969
|
+
if (!order.lineItems.length) throw new Error("finalize: no lines");
|
|
970
|
+
if (order.discountMinor < 0 || order.lineItems.some((line) => line.discountMinor < 0) || order.discounts.some((d) => d.amountMinor < 0)) {
|
|
971
|
+
throw new Error("finalize: negative discount");
|
|
972
|
+
}
|
|
973
|
+
const hasDiscount = order.discountMinor !== 0 || order.lineItems.some((line) => line.discountMinor !== 0) || order.discounts.some((d) => d.amountMinor !== 0);
|
|
974
|
+
if (hasDiscount && (options.capabilities?.orderCreate ?? 1) < 2) {
|
|
975
|
+
throw new Error("finalize: discounts are not supported by the server yet (order.create v2)");
|
|
976
|
+
}
|
|
977
|
+
for (const payment of order.payments) {
|
|
978
|
+
if (payment.method !== "cash" && payment.method !== "external") {
|
|
979
|
+
throw new Error(`finalize: unsupported payment method ${payment.method}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (order.paidMinor < order.totalMinor) throw new Error("finalize: underpaid");
|
|
983
|
+
let change = order.paidMinor - order.totalMinor;
|
|
984
|
+
const cash = order.payments.reduce((sum, p) => sum + (p.method === "cash" ? p.amountMinor : 0), 0);
|
|
985
|
+
if (change > cash) throw new Error("finalize: change exceeds cash");
|
|
986
|
+
const newId = options.newId ?? uuidv7;
|
|
987
|
+
const id2 = newId();
|
|
988
|
+
const lines = order.lineItems.map((line) => ({
|
|
989
|
+
id: newId(),
|
|
990
|
+
productId: line.productId,
|
|
991
|
+
...line.variantId !== void 0 ? { variantId: line.variantId } : {},
|
|
992
|
+
name: line.name,
|
|
993
|
+
sku: line.sku,
|
|
994
|
+
quantity: line.quantity,
|
|
995
|
+
unitPriceMinor: line.unitPriceMinor,
|
|
996
|
+
discountMinor: line.discountMinor,
|
|
997
|
+
netMinor: line.netMinor,
|
|
998
|
+
taxLines: line.taxLines.map((tax) => ({ ...tax })),
|
|
999
|
+
...line.priceTaxModeConverted ? { taxInclusive: line.taxInclusive } : {}
|
|
1000
|
+
}));
|
|
1001
|
+
const payments = order.payments.map((payment) => ({
|
|
1002
|
+
id: newId(),
|
|
1003
|
+
method: payment.method,
|
|
1004
|
+
amountMinor: payment.amountMinor,
|
|
1005
|
+
...payment.reference !== void 0 ? { reference: payment.reference } : {}
|
|
1006
|
+
}));
|
|
1007
|
+
for (let i = payments.length - 1; i >= 0; i--) {
|
|
1008
|
+
const payment = payments[i];
|
|
1009
|
+
if (payment.method !== "cash") continue;
|
|
1010
|
+
payment.tenderedMinor = payment.amountMinor;
|
|
1011
|
+
payment.changeMinor = Math.min(change, payment.tenderedMinor);
|
|
1012
|
+
payment.amountMinor -= payment.changeMinor;
|
|
1013
|
+
change -= payment.changeMinor;
|
|
1014
|
+
}
|
|
1015
|
+
if (payments.reduce((sum, p) => sum + p.amountMinor, 0) !== order.totalMinor) {
|
|
1016
|
+
throw new Error("finalize: payments do not reconcile");
|
|
1017
|
+
}
|
|
1018
|
+
const now = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1019
|
+
return {
|
|
1020
|
+
id: id2,
|
|
1021
|
+
createdAt: now,
|
|
1022
|
+
updatedAt: now,
|
|
1023
|
+
commandId: newId(),
|
|
1024
|
+
syncStatus: "pending",
|
|
1025
|
+
currency: order.currency,
|
|
1026
|
+
pricesIncludeTax: order.pricesIncludeTax,
|
|
1027
|
+
lines,
|
|
1028
|
+
payments,
|
|
1029
|
+
subtotalMinor: order.subtotalMinor,
|
|
1030
|
+
discountMinor: order.discountMinor,
|
|
1031
|
+
taxMinor: order.taxMinor,
|
|
1032
|
+
totalMinor: order.totalMinor,
|
|
1033
|
+
customer: order.customer ? {
|
|
1034
|
+
id: order.customer.id,
|
|
1035
|
+
name: order.customer.name,
|
|
1036
|
+
...order.customer.email !== void 0 ? { email: order.customer.email } : {}
|
|
1037
|
+
} : null,
|
|
1038
|
+
...order.note ? { note: order.note } : {},
|
|
1039
|
+
...options.registerId !== void 0 ? { registerId: options.registerId } : {},
|
|
1040
|
+
...options.cashierRef !== void 0 ? { cashierRef: options.cashierRef } : {}
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// src/pos-order/command.ts
|
|
1045
|
+
function toOrderCreateEnvelope(order, deviceId, attempt2 = 1) {
|
|
1046
|
+
const discountMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
|
|
1047
|
+
return {
|
|
1048
|
+
id: order.commandId,
|
|
1049
|
+
type: "order.create",
|
|
1050
|
+
version: discountMinor > 0 ? 2 : 1,
|
|
1051
|
+
createdAt: order.createdAt,
|
|
1052
|
+
deviceId,
|
|
1053
|
+
attempt: attempt2,
|
|
1054
|
+
payload: {
|
|
1055
|
+
clientOrderId: order.id,
|
|
1056
|
+
createdAt: order.createdAt,
|
|
1057
|
+
currency: order.currency,
|
|
1058
|
+
pricesIncludeTax: order.pricesIncludeTax,
|
|
1059
|
+
lines: order.lines.map((line) => ({
|
|
1060
|
+
clientLineId: line.id,
|
|
1061
|
+
variantId: line.variantId ?? line.productId,
|
|
1062
|
+
title: line.name,
|
|
1063
|
+
quantity: line.quantity,
|
|
1064
|
+
unitPriceMinor: line.unitPriceMinor,
|
|
1065
|
+
...line.taxInclusive !== void 0 ? { taxInclusive: line.taxInclusive } : {},
|
|
1066
|
+
...line.discountMinor > 0 ? { discountMinor: line.discountMinor } : {}
|
|
1067
|
+
})),
|
|
1068
|
+
payments: order.payments.map((payment) => ({
|
|
1069
|
+
clientPaymentId: payment.id,
|
|
1070
|
+
method: payment.method,
|
|
1071
|
+
amountMinor: payment.amountMinor,
|
|
1072
|
+
...payment.tenderedMinor !== void 0 ? { tenderedMinor: payment.tenderedMinor } : {},
|
|
1073
|
+
...payment.changeMinor !== void 0 ? { changeMinor: payment.changeMinor } : {},
|
|
1074
|
+
...payment.reference !== void 0 ? { reference: payment.reference } : {}
|
|
1075
|
+
})),
|
|
1076
|
+
subtotalMinor: order.subtotalMinor,
|
|
1077
|
+
...discountMinor > 0 ? { discountMinor } : {},
|
|
1078
|
+
taxMinor: order.taxMinor,
|
|
1079
|
+
totalMinor: order.totalMinor,
|
|
1080
|
+
customer: order.customer?.email ? { email: order.customer.email } : null,
|
|
1081
|
+
...order.registerId !== void 0 ? { registerId: order.registerId } : {},
|
|
1082
|
+
...order.cashierRef !== void 0 ? { cashierRef: order.cashierRef } : {}
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/pos-order/schema.ts
|
|
1088
|
+
import { addRxPlugin } from "rxdb";
|
|
1089
|
+
import { RxDBMigrationSchemaPlugin } from "rxdb/plugins/migration-schema";
|
|
1090
|
+
var posOrderSchema = {
|
|
1091
|
+
version: 2,
|
|
1092
|
+
primaryKey: "id",
|
|
1093
|
+
type: "object",
|
|
1094
|
+
additionalProperties: false,
|
|
1095
|
+
properties: {
|
|
1096
|
+
id: { type: "string", maxLength: 36 },
|
|
1097
|
+
commandId: { type: "string", maxLength: 36 },
|
|
1098
|
+
createdAt: { type: "string", maxLength: 40 },
|
|
1099
|
+
updatedAt: { type: "string" },
|
|
1100
|
+
currency: { type: "string" },
|
|
1101
|
+
pricesIncludeTax: { type: "boolean" },
|
|
1102
|
+
subtotalMinor: { type: "integer" },
|
|
1103
|
+
discountMinor: { type: "integer" },
|
|
1104
|
+
taxMinor: { type: "integer" },
|
|
1105
|
+
totalMinor: { type: "integer" },
|
|
1106
|
+
syncStatus: { type: "string", enum: ["pending", "applied", "rejected"], maxLength: 10 },
|
|
1107
|
+
note: { type: "string" },
|
|
1108
|
+
registerId: { type: "string" },
|
|
1109
|
+
sessionId: { type: "string" },
|
|
1110
|
+
cashierRef: { type: "string" },
|
|
1111
|
+
lines: { type: "array", items: {
|
|
1112
|
+
type: "object",
|
|
1113
|
+
properties: {
|
|
1114
|
+
id: { type: "string", maxLength: 36 },
|
|
1115
|
+
productId: { type: "string" },
|
|
1116
|
+
variantId: { type: "string" },
|
|
1117
|
+
name: { type: "string" },
|
|
1118
|
+
sku: { type: "string" },
|
|
1119
|
+
quantity: { type: "integer" },
|
|
1120
|
+
unitPriceMinor: { type: "integer" },
|
|
1121
|
+
discountMinor: { type: "integer" },
|
|
1122
|
+
netMinor: { type: "integer" },
|
|
1123
|
+
taxLines: { type: "array", items: {
|
|
1124
|
+
type: "object",
|
|
1125
|
+
properties: { code: { type: "string" }, ratePpm: { type: "integer" }, taxMicros: { type: "string" } },
|
|
1126
|
+
required: ["ratePpm", "taxMicros"]
|
|
1127
|
+
} }
|
|
1128
|
+
},
|
|
1129
|
+
required: ["id", "productId", "name", "sku", "quantity", "unitPriceMinor", "discountMinor", "netMinor", "taxLines"]
|
|
1130
|
+
} },
|
|
1131
|
+
payments: { type: "array", items: {
|
|
1132
|
+
type: "object",
|
|
1133
|
+
properties: {
|
|
1134
|
+
id: { type: "string", maxLength: 36 },
|
|
1135
|
+
method: { type: "string", enum: ["cash", "external"] },
|
|
1136
|
+
amountMinor: { type: "integer" },
|
|
1137
|
+
tenderedMinor: { type: "integer" },
|
|
1138
|
+
changeMinor: { type: "integer" },
|
|
1139
|
+
reference: { type: "string" }
|
|
1140
|
+
},
|
|
1141
|
+
required: ["id", "method", "amountMinor"]
|
|
1142
|
+
} },
|
|
1143
|
+
customer: { type: ["object", "null"], properties: { id: { type: "string" }, name: { type: "string" }, email: { type: "string" } } },
|
|
1144
|
+
serverRefs: { type: "object", properties: {
|
|
1145
|
+
orderId: { type: "string" },
|
|
1146
|
+
displayId: { type: "string" },
|
|
1147
|
+
totalMinor: { type: "integer" }
|
|
1148
|
+
}, required: ["orderId", "totalMinor"] },
|
|
1149
|
+
warnings: { type: "array", items: {
|
|
1150
|
+
type: "object",
|
|
1151
|
+
properties: { code: { type: "string", maxLength: 64 } },
|
|
1152
|
+
required: ["code"],
|
|
1153
|
+
additionalProperties: true
|
|
1154
|
+
} },
|
|
1155
|
+
error: { type: "object", properties: { code: { type: "string" }, message: { type: "string" } }, required: ["code", "message"] },
|
|
1156
|
+
lateSessionId: { type: "string" },
|
|
1157
|
+
// The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
|
|
1158
|
+
display: { type: "object", additionalProperties: false, properties: {
|
|
1159
|
+
currency: { type: "string" },
|
|
1160
|
+
exponent: { type: "integer" },
|
|
1161
|
+
taxInclusive: { type: "boolean" },
|
|
1162
|
+
subtotalMinor: { type: "integer" },
|
|
1163
|
+
discountMinor: { type: "integer" },
|
|
1164
|
+
taxMinor: { type: "integer" },
|
|
1165
|
+
totalMinor: { type: "integer" },
|
|
1166
|
+
orderDiscountMinor: { type: "integer" },
|
|
1167
|
+
lines: { type: "array", items: {
|
|
1168
|
+
type: "object",
|
|
1169
|
+
additionalProperties: false,
|
|
1170
|
+
properties: {
|
|
1171
|
+
lineId: { type: "string" },
|
|
1172
|
+
amountMinor: { type: "integer" },
|
|
1173
|
+
discounts: { type: "array", items: {
|
|
1174
|
+
type: "object",
|
|
1175
|
+
additionalProperties: false,
|
|
1176
|
+
properties: { discountId: { type: "string" }, label: { type: "string" }, amountMinor: { type: "integer" } },
|
|
1177
|
+
required: ["discountId", "amountMinor"]
|
|
1178
|
+
} }
|
|
1179
|
+
},
|
|
1180
|
+
required: ["lineId", "amountMinor", "discounts"]
|
|
1181
|
+
} }
|
|
1182
|
+
}, required: ["currency", "exponent", "taxInclusive", "subtotalMinor", "discountMinor", "taxMinor", "totalMinor", "orderDiscountMinor", "lines"] },
|
|
1183
|
+
taxByRate: { type: "array", items: {
|
|
1184
|
+
type: "object",
|
|
1185
|
+
additionalProperties: false,
|
|
1186
|
+
properties: {
|
|
1187
|
+
ratePpm: { type: "integer" },
|
|
1188
|
+
code: { type: "string" },
|
|
1189
|
+
label: { type: "string" },
|
|
1190
|
+
netMinor: { type: "integer" },
|
|
1191
|
+
amountMinor: { type: "integer" },
|
|
1192
|
+
grossMinor: { type: "integer" }
|
|
1193
|
+
},
|
|
1194
|
+
required: ["ratePpm", "netMinor", "amountMinor", "grossMinor"]
|
|
1195
|
+
} }
|
|
1196
|
+
},
|
|
1197
|
+
required: [
|
|
1198
|
+
"id",
|
|
1199
|
+
"createdAt",
|
|
1200
|
+
"currency",
|
|
1201
|
+
"pricesIncludeTax",
|
|
1202
|
+
"lines",
|
|
1203
|
+
"subtotalMinor",
|
|
1204
|
+
"discountMinor",
|
|
1205
|
+
"taxMinor",
|
|
1206
|
+
"totalMinor",
|
|
1207
|
+
"payments",
|
|
1208
|
+
"customer",
|
|
1209
|
+
"syncStatus",
|
|
1210
|
+
"commandId",
|
|
1211
|
+
"updatedAt"
|
|
1212
|
+
],
|
|
1213
|
+
indexes: ["createdAt", "syncStatus", ["syncStatus", "createdAt"]]
|
|
1214
|
+
};
|
|
1215
|
+
function posOrderCollection() {
|
|
1216
|
+
addRxPlugin(RxDBMigrationSchemaPlugin);
|
|
1217
|
+
const identity2 = (doc) => doc;
|
|
1218
|
+
return { schema: posOrderSchema, migrationStrategies: { 1: identity2, 2: identity2 } };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// src/pos-order/open.ts
|
|
1222
|
+
import {
|
|
1223
|
+
deepEqual,
|
|
1224
|
+
defaultConflictHandler,
|
|
1225
|
+
getChangedDocumentsSince,
|
|
1226
|
+
getRxReplicationMetaInstanceSchema,
|
|
1227
|
+
getSingleDocument,
|
|
1228
|
+
hasEncryption,
|
|
1229
|
+
newRxError,
|
|
1230
|
+
overwritable,
|
|
1231
|
+
rxStorageInstanceToReplicationHandler
|
|
1232
|
+
} from "rxdb";
|
|
1233
|
+
import { getOldCollectionMeta, migrateDocumentData } from "rxdb/plugins/migration-schema";
|
|
1234
|
+
import { Subject, takeUntil } from "rxjs";
|
|
1235
|
+
var posOrdersLogger = createLogger("pos-orders");
|
|
1236
|
+
var POS_ORDER_MIGRATION_CLOSE_WAIT_MS = 1e4;
|
|
1237
|
+
var PosOrderOpenClosedError = class extends Error {
|
|
1238
|
+
constructor(databaseName) {
|
|
1239
|
+
super(`addPosOrderCollection: database ${databaseName} closed during the open; reopen it and open pos_orders again`);
|
|
1240
|
+
this.databaseName = databaseName;
|
|
1241
|
+
this.name = "PosOrderOpenClosedError";
|
|
1242
|
+
}
|
|
1243
|
+
code = "POS_ORDER_OPEN_CLOSED";
|
|
1244
|
+
};
|
|
1245
|
+
var latestOpens = /* @__PURE__ */ new WeakMap();
|
|
1246
|
+
var closedUnderOpen = /* @__PURE__ */ new WeakSet();
|
|
1247
|
+
function waitWithTimeout(promise, ms) {
|
|
1248
|
+
return new Promise((resolve) => {
|
|
1249
|
+
const timer = setTimeout(() => resolve(false), ms);
|
|
1250
|
+
promise.finally(() => {
|
|
1251
|
+
clearTimeout(timer);
|
|
1252
|
+
resolve(true);
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
async function writeOverStaleCopies(collection, from, to) {
|
|
1257
|
+
const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
|
|
1258
|
+
for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0; page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
|
|
1259
|
+
const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc.id), false)).map((copy) => [copy.id, copy]));
|
|
1260
|
+
const rows = await Promise.all(page.documents.filter((doc) => copies.has(doc.id) && !doc._deleted).map(async (doc) => ({ assumedMasterState: copies.get(doc.id), newDocumentState: await migrateDocumentData(collection, from.schema.version, doc) })));
|
|
1261
|
+
const stale = rows.filter((row) => row.newDocumentState && !deepEqual(row.assumedMasterState, row.newDocumentState));
|
|
1262
|
+
if (stale.length > 0) await handler.masterWrite(stale);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
async function addPosOrderCollection(db, closeWaitMs = POS_ORDER_MIGRATION_CLOSE_WAIT_MS) {
|
|
1266
|
+
if (db.multiInstance) throw new Error("addPosOrderCollection: multiInstance databases are not supported (ADR-061)");
|
|
1267
|
+
if (db.closed || db.closePromise) throw new PosOrderOpenClosedError(db.name);
|
|
1268
|
+
const previous = latestOpens.get(db);
|
|
1269
|
+
if (!previous) {
|
|
1270
|
+
db.onClose.push(() => waitWithTimeout(latestOpens.get(db), closeWaitMs).then((settled) => {
|
|
1271
|
+
if (!settled) closedUnderOpen.add(db);
|
|
1272
|
+
}));
|
|
1273
|
+
}
|
|
1274
|
+
const opening = openPosOrders(db, previous);
|
|
1275
|
+
latestOpens.set(db, Promise.all([previous, opening.catch(() => void 0)]));
|
|
1276
|
+
return opening;
|
|
1277
|
+
}
|
|
1278
|
+
async function openPosOrders(db, previous) {
|
|
1279
|
+
const closing = () => closedUnderOpen.has(db) || db.closed;
|
|
1280
|
+
const stopIfClosing = () => {
|
|
1281
|
+
if (closing()) throw new PosOrderOpenClosedError(db.name);
|
|
1282
|
+
};
|
|
1283
|
+
const { pos_orders: collection } = await db.addCollections({ pos_orders: { ...posOrderCollection(), autoMigrate: false } });
|
|
1284
|
+
const state = collection.getMigrationState();
|
|
1285
|
+
try {
|
|
1286
|
+
stopIfClosing();
|
|
1287
|
+
const mustMigrate = await state.mustMigrate;
|
|
1288
|
+
stopIfClosing();
|
|
1289
|
+
if (!mustMigrate) return collection;
|
|
1290
|
+
await previous;
|
|
1291
|
+
stopIfClosing();
|
|
1292
|
+
await state.updateStatus((status2) => {
|
|
1293
|
+
delete status2.error;
|
|
1294
|
+
status2.status = "RUNNING";
|
|
1295
|
+
status2.count = { total: 0, handled: 0, percent: 0 };
|
|
1296
|
+
return status2;
|
|
1297
|
+
});
|
|
1298
|
+
const old = (await state.oldCollectionMeta).data.schema;
|
|
1299
|
+
stopIfClosing();
|
|
1300
|
+
const checkpoint = await db.storage.createStorageInstance({
|
|
1301
|
+
databaseName: db.name,
|
|
1302
|
+
collectionName: `rx-migration-state-meta-pos_orders-${old.version}`,
|
|
1303
|
+
databaseInstanceToken: db.token,
|
|
1304
|
+
multiInstance: db.multiInstance,
|
|
1305
|
+
options: {},
|
|
1306
|
+
password: db.password,
|
|
1307
|
+
schema: getRxReplicationMetaInstanceSchema(old, hasEncryption(old)),
|
|
1308
|
+
devMode: overwritable.isDevMode()
|
|
1309
|
+
});
|
|
1310
|
+
await (closing() ? checkpoint.close() : checkpoint.remove());
|
|
1311
|
+
stopIfClosing();
|
|
1312
|
+
const settled = new Subject();
|
|
1313
|
+
const gate = (target, closed) => new Proxy(target, { get: (t, key) => {
|
|
1314
|
+
const value = Reflect.get(t, key);
|
|
1315
|
+
return typeof value !== "function" ? value : (...args) => closing() && (key === "bulkWrite" || key === "findDocumentsById") ? closed(key, args[0]) : value.apply(t, args);
|
|
1316
|
+
} });
|
|
1317
|
+
const internalStore = gate(db.internalStore, async (key, first) => {
|
|
1318
|
+
const ids = key === "bulkWrite" ? first.map((row) => row.document.id) : first;
|
|
1319
|
+
posOrdersLogger.warn(
|
|
1320
|
+
`The database closed during the migration: dropped a ${key === "bulkWrite" ? "status write" : "status read"}`,
|
|
1321
|
+
{ database: db.name, ids }
|
|
1322
|
+
);
|
|
1323
|
+
return key === "bulkWrite" ? { error: [] } : [];
|
|
1324
|
+
});
|
|
1325
|
+
state.database = new Proxy(db, { get: (target, key) => key === "internalStore" ? internalStore : Reflect.get(target, key) });
|
|
1326
|
+
const migrateStorage = state.migrateStorage.bind(state);
|
|
1327
|
+
state.migrateStorage = async (from, current, batchSize) => {
|
|
1328
|
+
const to = gate(current, () => Promise.reject(new PosOrderOpenClosedError(db.name)));
|
|
1329
|
+
if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to);
|
|
1330
|
+
return migrateStorage(new Proxy(from, { get: (target, key) => {
|
|
1331
|
+
const value = key === "changeStream" ? () => target.changeStream().pipe(takeUntil(settled)) : Reflect.get(target, key);
|
|
1332
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
1333
|
+
} }), to, batchSize);
|
|
1334
|
+
};
|
|
1335
|
+
await state.startMigration().finally(() => settled.next());
|
|
1336
|
+
stopIfClosing();
|
|
1337
|
+
const status = (await getSingleDocument(db.internalStore, state.statusDocId))?.data;
|
|
1338
|
+
stopIfClosing();
|
|
1339
|
+
const oldMeta = status?.status === "DONE" ? await getOldCollectionMeta(state) : void 0;
|
|
1340
|
+
stopIfClosing();
|
|
1341
|
+
if (status?.status === "DONE" && !oldMeta) return collection;
|
|
1342
|
+
throw newRxError("DM4", { collection: collection.name, error: status?.error });
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
await collection.close();
|
|
1345
|
+
throw closing() ? new PosOrderOpenClosedError(db.name) : error;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// src/pos-order/device-id.ts
|
|
1350
|
+
var processId;
|
|
1351
|
+
function getDeviceId(storage, key) {
|
|
1352
|
+
try {
|
|
1353
|
+
if (storage) {
|
|
1354
|
+
const existing = storage.getItem(key);
|
|
1355
|
+
if (existing) return existing;
|
|
1356
|
+
const id2 = uuidv7();
|
|
1357
|
+
storage.setItem(key, id2);
|
|
1358
|
+
return id2;
|
|
1359
|
+
}
|
|
1360
|
+
} catch {
|
|
1361
|
+
}
|
|
1362
|
+
return processId ??= uuidv7();
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// src/pos-order/needs-attention.ts
|
|
1366
|
+
function needsAttention(orders) {
|
|
1367
|
+
return orders.filter((order) => order.syncStatus === "rejected" || order.syncStatus === "applied" && !!order.warnings?.length || order.lateSessionId !== void 0).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// src/pos-order/same-sale.ts
|
|
1371
|
+
function sameSale(stored, retried) {
|
|
1372
|
+
return stored.totalMinor === retried.totalMinor && stored.currency === retried.currency && stored.lines.length === retried.lines.length && stored.lines.every((line, i) => {
|
|
1373
|
+
const other = retried.lines[i];
|
|
1374
|
+
return line.id === other.id && line.quantity === other.quantity && line.netMinor === other.netMinor;
|
|
1375
|
+
}) && stored.payments.length === retried.payments.length && stored.payments.every((payment, i) => payment.method === retried.payments[i].method && payment.amountMinor === retried.payments[i].amountMinor);
|
|
1376
|
+
}
|
|
1377
|
+
var OrderContentMismatchError = class extends Error {
|
|
1378
|
+
constructor(orderId) {
|
|
1379
|
+
super(`Order ${orderId} is already stored with different content`);
|
|
1380
|
+
this.orderId = orderId;
|
|
1381
|
+
this.name = "OrderContentMismatchError";
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
// src/register/movement-input.ts
|
|
1386
|
+
var SERVER_DECIMAL = /^\d+(?:\.\d+)?$/;
|
|
1387
|
+
var MAX_INTEGER_DIGITS = 15;
|
|
1388
|
+
var MAX_REASON_LENGTH = 500;
|
|
1389
|
+
var KEYPAD_DECIMAL_COMMA = /^(\d+),(\d{1,2})$/;
|
|
1390
|
+
function normalizeAmount(raw) {
|
|
1391
|
+
const trimmed = raw.trim();
|
|
1392
|
+
const dotted = trimmed.replace(KEYPAD_DECIMAL_COMMA, "$1.$2");
|
|
1393
|
+
return dotted.replace(/^\./, "0.").replace(/\.$/, "");
|
|
1394
|
+
}
|
|
1395
|
+
function isServerDecimal(value) {
|
|
1396
|
+
if (!SERVER_DECIMAL.test(value)) return false;
|
|
1397
|
+
return value.split(".")[0].replace(/^0+/, "").length <= MAX_INTEGER_DIGITS;
|
|
1398
|
+
}
|
|
1399
|
+
function movementFieldError(input) {
|
|
1400
|
+
if (input.type !== "no_sale") {
|
|
1401
|
+
const amount = normalizeAmount(input.amount);
|
|
1402
|
+
if (!isServerDecimal(amount) || Number(amount) <= 0) return "amount";
|
|
1403
|
+
}
|
|
1404
|
+
const reason = input.reason.trim();
|
|
1405
|
+
if (!reason || reason.length > MAX_REASON_LENGTH) return "reason";
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// src/register/expected.ts
|
|
1410
|
+
function deriveExpected({
|
|
1411
|
+
session,
|
|
1412
|
+
movements,
|
|
1413
|
+
ledgerRowsBySession
|
|
1414
|
+
}) {
|
|
1415
|
+
const totals = { cash: session.countedFloatMinor };
|
|
1416
|
+
for (const row of ledgerRowsBySession) {
|
|
1417
|
+
if (row.session_id !== session.id || row.status !== "captured") continue;
|
|
1418
|
+
const method = row.kind === "cash" ? "cash" : row.method_id;
|
|
1419
|
+
totals[method] = (totals[method] ?? 0) + row.amountMinor;
|
|
1420
|
+
}
|
|
1421
|
+
const voids = new Set(
|
|
1422
|
+
movements.filter((row) => row.session_id === session.id && row.type === "void").map((row) => row.voids)
|
|
1423
|
+
);
|
|
1424
|
+
for (const row of movements) {
|
|
1425
|
+
if (row.session_id !== session.id || row.voided_by || voids.has(row.id)) continue;
|
|
1426
|
+
if (row.type === "paid_in") totals.cash += row.amountMinor;
|
|
1427
|
+
if (row.type === "paid_out") totals.cash -= row.amountMinor;
|
|
1428
|
+
}
|
|
1429
|
+
return totals;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// src/register/register-count.helpers.ts
|
|
1433
|
+
function parseMinor(text2, exponent) {
|
|
1434
|
+
const [whole, fraction = ""] = text2.trim().split(".");
|
|
1435
|
+
if (fraction.length > exponent) return NaN;
|
|
1436
|
+
return Number(whole || "0") * 10 ** exponent + Number(fraction.padEnd(exponent, "0") || "0");
|
|
1437
|
+
}
|
|
1438
|
+
var validAmount = (value, exponent) => /^(?:\d+\.?\d*|\.\d+)$/.test(value) && Number.isFinite(Number(value)) && Number.isSafeInteger(parseMinor(value, exponent));
|
|
1439
|
+
var countVariance = (countedMinor, expectedMinor) => countedMinor - expectedMinor;
|
|
1440
|
+
var overThreshold = (variance, thresholdMinor) => thresholdMinor != null && Math.abs(variance) > thresholdMinor;
|
|
1441
|
+
var closeNeedsApproval = (countedCashMinor, expectedCashMinor, thresholdMinor) => overThreshold(countVariance(countedCashMinor, expectedCashMinor), thresholdMinor);
|
|
1442
|
+
var denominationTotal = (pieces) => Object.entries(pieces).reduce((sum, [value, count]) => sum + Number(value) * count, 0);
|
|
1443
|
+
function varianceText(variance, exponent, format2, t) {
|
|
1444
|
+
if (!variance) return t("register.exact");
|
|
1445
|
+
return `${format2(Math.abs(variance) / 10 ** exponent)} ${t(variance < 0 ? "register.short" : "register.over")}`;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// src/register/register-count.denominations.ts
|
|
1449
|
+
var coinsMinor = [200, 100, 50, 20, 10, 5, 2, 1];
|
|
1450
|
+
var denominations = {
|
|
1451
|
+
GBP: [5e3, 2e3, 1e3, 500, ...coinsMinor],
|
|
1452
|
+
EUR: [5e4, 2e4, 1e4, 5e3, 2e3, 1e3, 500, ...coinsMinor],
|
|
1453
|
+
USD: [1e4, 5e3, 2e3, 1e3, 500, 100, 25, 10, 5, 1],
|
|
1454
|
+
default: [1e4, 5e3, 2e3, 1e3, 500, 200, 100, 50, 25, 10, 5, 1]
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
// src/register/schemas.ts
|
|
1458
|
+
import { addRxPlugin as addRxPlugin2 } from "rxdb";
|
|
1459
|
+
import { RxDBLocalDocumentsPlugin } from "rxdb/plugins/local-documents";
|
|
1460
|
+
var id = { type: "string", maxLength: 36 };
|
|
1461
|
+
var text = { type: "string" };
|
|
1462
|
+
var nullableText = { type: ["string", "null"] };
|
|
1463
|
+
var minor = { type: "integer" };
|
|
1464
|
+
var nullableMinor = { type: ["integer", "null"] };
|
|
1465
|
+
var tenders = { type: "object", additionalProperties: minor };
|
|
1466
|
+
var registerSessionSchema = {
|
|
1467
|
+
title: "Register sessions",
|
|
1468
|
+
version: 0,
|
|
1469
|
+
primaryKey: "id",
|
|
1470
|
+
type: "object",
|
|
1471
|
+
additionalProperties: false,
|
|
1472
|
+
properties: {
|
|
1473
|
+
id,
|
|
1474
|
+
register_id: id,
|
|
1475
|
+
store_key: nullableText,
|
|
1476
|
+
status: { type: "string", enum: ["open", "counting", "closed"], maxLength: 8 },
|
|
1477
|
+
business_day: { type: "string", maxLength: 10 },
|
|
1478
|
+
opened_at_gmt: text,
|
|
1479
|
+
opened_by: nullableText,
|
|
1480
|
+
expected_float_minor: nullableMinor,
|
|
1481
|
+
counted_float_minor: minor,
|
|
1482
|
+
opening_variance_minor: nullableMinor,
|
|
1483
|
+
counting_started_at_gmt: nullableText,
|
|
1484
|
+
closed_at_gmt: nullableText,
|
|
1485
|
+
closed_by: nullableText,
|
|
1486
|
+
approval_required: { type: "boolean", default: false },
|
|
1487
|
+
approved_by: nullableText,
|
|
1488
|
+
counted: { type: ["object", "null"], additionalProperties: minor },
|
|
1489
|
+
closure_id: nullableText,
|
|
1490
|
+
pending_status: { ...nullableText, default: null },
|
|
1491
|
+
server_status: { ...nullableText, default: null },
|
|
1492
|
+
status_at: { ...nullableText, default: null },
|
|
1493
|
+
approver_token: { ...nullableText, default: null },
|
|
1494
|
+
server_expected: { type: ["object", "null"], additionalProperties: minor, default: null },
|
|
1495
|
+
server_sales_count: { type: ["integer", "null"], default: null }
|
|
1496
|
+
},
|
|
1497
|
+
required: ["id", "register_id", "status", "opened_at_gmt", "counted_float_minor"],
|
|
1498
|
+
indexes: [["register_id", "status"]]
|
|
1499
|
+
};
|
|
1500
|
+
function registerSessionCollection() {
|
|
1501
|
+
addRxPlugin2(RxDBLocalDocumentsPlugin);
|
|
1502
|
+
return { schema: registerSessionSchema, localDocuments: true };
|
|
1503
|
+
}
|
|
1504
|
+
var cashMovementSchema = {
|
|
1505
|
+
title: "Cash movements",
|
|
1506
|
+
version: 0,
|
|
1507
|
+
primaryKey: "id",
|
|
1508
|
+
type: "object",
|
|
1509
|
+
additionalProperties: false,
|
|
1510
|
+
properties: {
|
|
1511
|
+
id,
|
|
1512
|
+
session_id: id,
|
|
1513
|
+
type: { type: "string", enum: ["paid_in", "paid_out", "no_sale", "void"] },
|
|
1514
|
+
amountMinor: minor,
|
|
1515
|
+
reason: text,
|
|
1516
|
+
created_at_gmt: text,
|
|
1517
|
+
created_by: nullableText,
|
|
1518
|
+
voids: nullableText,
|
|
1519
|
+
voided_by: nullableText
|
|
1520
|
+
},
|
|
1521
|
+
required: ["id", "session_id", "type", "amountMinor", "reason", "created_at_gmt"],
|
|
1522
|
+
indexes: [["session_id"]]
|
|
1523
|
+
};
|
|
1524
|
+
var closureSchema = {
|
|
1525
|
+
title: "Register closures",
|
|
1526
|
+
version: 0,
|
|
1527
|
+
primaryKey: "id",
|
|
1528
|
+
type: "object",
|
|
1529
|
+
additionalProperties: false,
|
|
1530
|
+
properties: {
|
|
1531
|
+
id,
|
|
1532
|
+
session_id: id,
|
|
1533
|
+
register_id: id,
|
|
1534
|
+
store_key: nullableText,
|
|
1535
|
+
business_day: { type: "string", maxLength: 10 },
|
|
1536
|
+
closed_by: nullableText,
|
|
1537
|
+
corrections_count: { type: "integer" },
|
|
1538
|
+
number: { type: "integer" },
|
|
1539
|
+
opened_at: text,
|
|
1540
|
+
closed_at: text,
|
|
1541
|
+
till_expected: tenders,
|
|
1542
|
+
expected: tenders,
|
|
1543
|
+
counted: tenders,
|
|
1544
|
+
variance: tenders,
|
|
1545
|
+
period_sales_total_minor: minor,
|
|
1546
|
+
period_refunds_total_minor: minor,
|
|
1547
|
+
perpetual_sales_total_minor: minor,
|
|
1548
|
+
perpetual_refunds_total_minor: minor,
|
|
1549
|
+
unsynced_count: { type: "integer" },
|
|
1550
|
+
unsynced_total_minor: minor,
|
|
1551
|
+
software_version: text,
|
|
1552
|
+
breakdowns: { type: "object", additionalProperties: true },
|
|
1553
|
+
order_ids: { type: "array", items: text },
|
|
1554
|
+
movement_ids: { type: "array", items: text },
|
|
1555
|
+
server_closure_id: { ...nullableText, default: null },
|
|
1556
|
+
printed_at: { ...nullableText, default: null },
|
|
1557
|
+
print_count: { type: "integer", default: 0 },
|
|
1558
|
+
number_retried: { type: "boolean", default: false }
|
|
1559
|
+
},
|
|
1560
|
+
required: [
|
|
1561
|
+
"id",
|
|
1562
|
+
"session_id",
|
|
1563
|
+
"register_id",
|
|
1564
|
+
"number",
|
|
1565
|
+
"opened_at",
|
|
1566
|
+
"closed_at",
|
|
1567
|
+
"till_expected",
|
|
1568
|
+
"expected",
|
|
1569
|
+
"counted",
|
|
1570
|
+
"variance",
|
|
1571
|
+
"period_sales_total_minor",
|
|
1572
|
+
"period_refunds_total_minor",
|
|
1573
|
+
"perpetual_sales_total_minor",
|
|
1574
|
+
"perpetual_refunds_total_minor",
|
|
1575
|
+
"unsynced_count",
|
|
1576
|
+
"unsynced_total_minor",
|
|
1577
|
+
"software_version",
|
|
1578
|
+
"breakdowns",
|
|
1579
|
+
"order_ids",
|
|
1580
|
+
"movement_ids",
|
|
1581
|
+
"print_count"
|
|
1582
|
+
],
|
|
1583
|
+
indexes: [["register_id"], ["session_id"]]
|
|
1584
|
+
};
|
|
1585
|
+
|
|
1586
|
+
// src/register/register-document.ts
|
|
1587
|
+
import { map as map4 } from "rxjs";
|
|
1588
|
+
var REGISTER = "register";
|
|
1589
|
+
function mintUuid() {
|
|
1590
|
+
const id2 = globalThis.crypto?.randomUUID?.();
|
|
1591
|
+
if (id2) return id2;
|
|
1592
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
1593
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
1594
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
1595
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1596
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
1597
|
+
}
|
|
1598
|
+
async function readRegister(host) {
|
|
1599
|
+
return (await host.getLocal(REGISTER))?.toJSON(true).data ?? null;
|
|
1600
|
+
}
|
|
1601
|
+
async function ensureRegister(host, platform) {
|
|
1602
|
+
const existing = await readRegister(host);
|
|
1603
|
+
if (existing) return existing;
|
|
1604
|
+
const id2 = mintUuid().toLowerCase();
|
|
1605
|
+
const data = {
|
|
1606
|
+
id: id2,
|
|
1607
|
+
name: `Register ${id2.slice(-4).toUpperCase()}`,
|
|
1608
|
+
platform,
|
|
1609
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1610
|
+
stores: {}
|
|
1611
|
+
};
|
|
1612
|
+
try {
|
|
1613
|
+
await host.insertLocal(REGISTER, data);
|
|
1614
|
+
return data;
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
const winner = await readRegister(host);
|
|
1617
|
+
if (winner) return winner;
|
|
1618
|
+
throw error;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
function observeRegister$(host) {
|
|
1622
|
+
return host.getLocal$(REGISTER).pipe(map4((doc) => doc?.toJSON(true).data ?? null));
|
|
1623
|
+
}
|
|
1624
|
+
async function modifyStore(host, storeKey, modify) {
|
|
1625
|
+
const doc = await host.getLocal(REGISTER);
|
|
1626
|
+
if (!doc) throw new Error("Register is not initialized");
|
|
1627
|
+
await doc.incrementalModify((data) => {
|
|
1628
|
+
const store = modify(data.stores[storeKey]);
|
|
1629
|
+
return store ? { ...data, stores: { ...data.stores, [storeKey]: store } } : data;
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
function getBoundRegisterId(register, storeKey) {
|
|
1633
|
+
return register?.stores?.[storeKey]?.register_id ?? null;
|
|
1634
|
+
}
|
|
1635
|
+
async function readBoundRegister(host, storeKey) {
|
|
1636
|
+
const register = await readRegister(host);
|
|
1637
|
+
const id2 = getBoundRegisterId(register, storeKey);
|
|
1638
|
+
return id2 ? { id: id2, name: register?.stores[storeKey]?.register_name ?? "" } : null;
|
|
1639
|
+
}
|
|
1640
|
+
function bindRegister(host, storeKey, register) {
|
|
1641
|
+
return modifyStore(host, storeKey, (store) => ({
|
|
1642
|
+
...store ?? { sale_counter: 0 },
|
|
1643
|
+
register_id: register.id,
|
|
1644
|
+
register_name: register.name
|
|
1645
|
+
}));
|
|
1646
|
+
}
|
|
1647
|
+
function unbindRegister(host, storeKey) {
|
|
1648
|
+
return modifyStore(host, storeKey, (store) => store && { ...store, register_id: null, register_name: null });
|
|
1649
|
+
}
|
|
1650
|
+
async function nextSaleCounter(host, storeKey) {
|
|
1651
|
+
let counter = 0;
|
|
1652
|
+
await modifyStore(host, storeKey, (store) => {
|
|
1653
|
+
counter = (store?.sale_counter ?? 0) + 1;
|
|
1654
|
+
return { ...store ?? {}, sale_counter: counter };
|
|
1655
|
+
});
|
|
1656
|
+
return counter;
|
|
1657
|
+
}
|
|
1658
|
+
function withRegister(store, registerId, bucket) {
|
|
1659
|
+
const base = store ?? { sale_counter: 0 };
|
|
1660
|
+
return { ...base, registers: { ...base.registers, [registerId]: bucket } };
|
|
1661
|
+
}
|
|
1662
|
+
async function mintClosureNumber(host, storeKey, registerId, closure) {
|
|
1663
|
+
let number = 0;
|
|
1664
|
+
await modifyStore(host, storeKey, (store) => {
|
|
1665
|
+
const register = store?.registers?.[registerId] ?? {};
|
|
1666
|
+
const reservation = register.closure_reservation;
|
|
1667
|
+
if (closure && reservation?.row.id === closure.id && !!reservation.row.number_retried === !!closure.number_retried) {
|
|
1668
|
+
number = reservation.row.number;
|
|
1669
|
+
return void 0;
|
|
1670
|
+
}
|
|
1671
|
+
if (closure && reservation && reservation.row.id !== closure.id && !reservation.applied) {
|
|
1672
|
+
throw new Error("closure_write_incomplete");
|
|
1673
|
+
}
|
|
1674
|
+
number = (register.last_closure_number ?? 0) + 1;
|
|
1675
|
+
const applied = closure && reservation?.row.id === closure.id && reservation.applied ? reservation.row : null;
|
|
1676
|
+
const sales = closure ? (register.perpetual_sales_total_minor ?? 0) - (applied?.period_sales_total_minor ?? 0) + closure.period_sales_total_minor : 0;
|
|
1677
|
+
const refunds = closure ? (register.perpetual_refunds_total_minor ?? 0) - (applied?.period_refunds_total_minor ?? 0) + closure.period_refunds_total_minor : 0;
|
|
1678
|
+
const next = closure ? {
|
|
1679
|
+
row: { ...closure, number, perpetual_sales_total_minor: sales, perpetual_refunds_total_minor: refunds },
|
|
1680
|
+
applied: !!applied || !!closure.number_retried
|
|
1681
|
+
} : reservation;
|
|
1682
|
+
return withRegister(store, registerId, {
|
|
1683
|
+
...register,
|
|
1684
|
+
last_closure_number: number,
|
|
1685
|
+
...next ? { closure_reservation: next } : {},
|
|
1686
|
+
// An applied period follows the closure row it came from.
|
|
1687
|
+
...applied ? { perpetual_sales_total_minor: sales, perpetual_refunds_total_minor: refunds } : {}
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
return number;
|
|
1691
|
+
}
|
|
1692
|
+
function advancePerpetual(host, storeKey, registerId, period) {
|
|
1693
|
+
return modifyStore(host, storeKey, (store) => {
|
|
1694
|
+
const register = store?.registers?.[registerId] ?? {};
|
|
1695
|
+
const reservation = register.closure_reservation;
|
|
1696
|
+
if (period.closureId && (!reservation || reservation.row.id !== period.closureId || reservation.applied)) {
|
|
1697
|
+
return void 0;
|
|
1698
|
+
}
|
|
1699
|
+
const sales = register.perpetual_sales_total_minor ?? 0;
|
|
1700
|
+
const refunds = register.perpetual_refunds_total_minor ?? 0;
|
|
1701
|
+
return withRegister(store, registerId, {
|
|
1702
|
+
...register,
|
|
1703
|
+
...period.closureId && reservation ? { closure_reservation: { ...reservation, applied: true } } : {},
|
|
1704
|
+
perpetual_sales_total_minor: period.closureId && reservation ? Math.max(sales, reservation.row.perpetual_sales_total_minor) : sales + period.salesMinor,
|
|
1705
|
+
perpetual_refunds_total_minor: period.closureId && reservation ? Math.max(refunds, reservation.row.perpetual_refunds_total_minor) : refunds + period.refundsMinor,
|
|
1706
|
+
counters_started_at: register.counters_started_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
1707
|
+
});
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
function markClosuresSwept(host, storeKey, registerId, closureIds) {
|
|
1711
|
+
return modifyStore(host, storeKey, (store) => {
|
|
1712
|
+
const register = store?.registers?.[registerId] ?? {};
|
|
1713
|
+
const swept = new Set(register.swept_closure_ids);
|
|
1714
|
+
closureIds.forEach((closureId) => swept.add(closureId));
|
|
1715
|
+
return withRegister(store, registerId, { ...register, swept_closure_ids: [...swept] });
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// src/register/facts.ts
|
|
1720
|
+
var registerFactsLogger = createLogger("register-session");
|
|
1721
|
+
var attempt = () => ({ operationId: mintUuid().replace(/-/g, "") });
|
|
1722
|
+
function identity(fact) {
|
|
1723
|
+
switch (fact.kind) {
|
|
1724
|
+
case "session-opened":
|
|
1725
|
+
return { operationId: fact.sessionId?.replace(/-/g, "") ?? "" };
|
|
1726
|
+
case "session-closed":
|
|
1727
|
+
return { operationId: fact.closureId.replace(/-/g, "") };
|
|
1728
|
+
case "movement-recorded":
|
|
1729
|
+
case "movement-voided":
|
|
1730
|
+
return { operationId: fact.movementId.replace(/-/g, "") };
|
|
1731
|
+
case "late-sale":
|
|
1732
|
+
return { operationId: fact.orderId.replace(/-/g, "") };
|
|
1733
|
+
default:
|
|
1734
|
+
return attempt();
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
function recordRegisterFact(fact) {
|
|
1738
|
+
const terminal = identity(fact);
|
|
1739
|
+
const pair = { sessionId: fact.sessionId, registerId: fact.registerId };
|
|
1740
|
+
const emit = (message, type, context = {}, level = "info") => registerFactsLogger[level](message, { actor: fact.actor, terminal, context: { type, ...pair, ...context } });
|
|
1741
|
+
switch (fact.kind) {
|
|
1742
|
+
case "session-opened":
|
|
1743
|
+
return emit("Register session opened", "register.session-opened", { amount: fact.amount, variance: fact.variance });
|
|
1744
|
+
case "counting-started":
|
|
1745
|
+
return emit("Register session counting started", "register.counting-started");
|
|
1746
|
+
case "counting-abandoned":
|
|
1747
|
+
return emit("Register session counting abandoned", "register.counting-abandoned");
|
|
1748
|
+
case "session-closed":
|
|
1749
|
+
return emit("Register session closed", "register.session-closed", {
|
|
1750
|
+
closureId: fact.closureId,
|
|
1751
|
+
number: fact.number,
|
|
1752
|
+
counted: fact.counted,
|
|
1753
|
+
variance: fact.variance
|
|
1754
|
+
});
|
|
1755
|
+
case "movement-recorded":
|
|
1756
|
+
if (fact.movementType === "no_sale")
|
|
1757
|
+
return emit("Register no-sale recorded", "register.no-sale-recorded", { movementId: fact.movementId });
|
|
1758
|
+
return emit("Register cash movement recorded", "register.movement-recorded", {
|
|
1759
|
+
movementId: fact.movementId,
|
|
1760
|
+
movementType: fact.movementType,
|
|
1761
|
+
amount: fact.amount
|
|
1762
|
+
});
|
|
1763
|
+
case "movement-voided":
|
|
1764
|
+
return emit("Register cash movement voided", "register.movement-voided", {
|
|
1765
|
+
movementId: fact.movementId,
|
|
1766
|
+
movementType: fact.movementType,
|
|
1767
|
+
amount: fact.amount,
|
|
1768
|
+
voids: fact.voids
|
|
1769
|
+
});
|
|
1770
|
+
case "approval-granted":
|
|
1771
|
+
return emit("Register session approval granted", "register.approval-granted", { approvedBy: fact.approvedBy });
|
|
1772
|
+
case "approval-refused":
|
|
1773
|
+
return emit("Register session approval refused", "register.approval-refused", {}, "warn");
|
|
1774
|
+
case "variance-over-threshold":
|
|
1775
|
+
return emit(
|
|
1776
|
+
"Register count exceeds variance threshold",
|
|
1777
|
+
"register.variance-over-threshold",
|
|
1778
|
+
{ variance: fact.variance, threshold: fact.threshold },
|
|
1779
|
+
"warn"
|
|
1780
|
+
);
|
|
1781
|
+
// These prove dispatch success, not that paper printed or a physical drawer opened.
|
|
1782
|
+
case "x-report-dispatched":
|
|
1783
|
+
return emit("Register X-report print dispatched", "register.x-report-printed");
|
|
1784
|
+
case "drawer-dispatched":
|
|
1785
|
+
return emit("Register drawer kick dispatched", "register.drawer-opened");
|
|
1786
|
+
// The money was taken, so the sale is kept and queued, outside every closure.
|
|
1787
|
+
case "late-sale":
|
|
1788
|
+
return emit("Register late sale kept outside its session", "register.late-sale", { orderId: fact.orderId }, "warn");
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// src/register/session-store.ts
|
|
1793
|
+
var RegisterSessionRequiredError = class extends Error {
|
|
1794
|
+
constructor() {
|
|
1795
|
+
super("register_session_not_open");
|
|
1796
|
+
this.name = "RegisterSessionRequiredError";
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
var RegisterSessionClosedError = class extends Error {
|
|
1800
|
+
constructor() {
|
|
1801
|
+
super("register_session_closed");
|
|
1802
|
+
this.name = "RegisterSessionClosedError";
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
var RegisterMovementStrandedError = class extends Error {
|
|
1806
|
+
id;
|
|
1807
|
+
session_id;
|
|
1808
|
+
constructor(movement) {
|
|
1809
|
+
super("Recorded, but the session closed while saving. It may not be on this session's Z. Do not enter it again.");
|
|
1810
|
+
this.name = "RegisterMovementStrandedError";
|
|
1811
|
+
this.id = movement.id;
|
|
1812
|
+
this.session_id = movement.session_id;
|
|
1813
|
+
}
|
|
1814
|
+
};
|
|
1815
|
+
var TRANSITIONS = {
|
|
1816
|
+
open: ["counting", "closed"],
|
|
1817
|
+
counting: ["open", "closed"],
|
|
1818
|
+
closed: []
|
|
1819
|
+
};
|
|
1820
|
+
async function readSession(sessions, id2) {
|
|
1821
|
+
const [stored] = await sessions.storageInstance.findDocumentsById([id2], false);
|
|
1822
|
+
return stored;
|
|
1823
|
+
}
|
|
1824
|
+
async function readClosure(closures, id2) {
|
|
1825
|
+
const [stored] = await closures?.storageInstance.findDocumentsById([id2], false) ?? [];
|
|
1826
|
+
return stored;
|
|
1827
|
+
}
|
|
1828
|
+
async function requireLiveSession(sessions, id2) {
|
|
1829
|
+
const session = await readSession(sessions, id2);
|
|
1830
|
+
if (!session) throw new RegisterSessionRequiredError();
|
|
1831
|
+
if (session.status === "closed") throw new RegisterSessionClosedError();
|
|
1832
|
+
return session;
|
|
1833
|
+
}
|
|
1834
|
+
var openSessionSelector = { status: "open" };
|
|
1835
|
+
async function requireOpenSession(sessions, registerId, enabled) {
|
|
1836
|
+
if (!enabled) return null;
|
|
1837
|
+
const session = await sessions?.findOne({ selector: { register_id: registerId ?? "", ...openSessionSelector } }).exec();
|
|
1838
|
+
if (!session) throw new RegisterSessionRequiredError();
|
|
1839
|
+
await session.incrementalPatch({ server_expected: null, server_sales_count: null });
|
|
1840
|
+
return session.id;
|
|
1841
|
+
}
|
|
1842
|
+
async function stampSession(order, sessionId, sessions) {
|
|
1843
|
+
if (order.sessionId !== void 0 && order.sessionId !== sessionId) throw new Error("session_already_stamped");
|
|
1844
|
+
const session = await requireLiveSession(sessions, sessionId);
|
|
1845
|
+
return { ...order, sessionId: session.id };
|
|
1846
|
+
}
|
|
1847
|
+
var SWEEP_GRACE_MS = 10 * 6e4;
|
|
1848
|
+
async function sweepOrphanStamps({ sessions, closures, orders, registerId, register, storeKey, full = false }) {
|
|
1849
|
+
const closed = new Set((await readFresh(sessions, { selector: { register_id: registerId, status: "closed" } })).map(({ id: id2 }) => id2));
|
|
1850
|
+
const swept = new Set((await readRegister(register))?.stores[storeKey]?.registers?.[registerId]?.swept_closure_ids);
|
|
1851
|
+
const unswept = (await readFresh(closures, { selector: { register_id: registerId } })).filter((closure) => closed.has(closure.session_id) && (full || !swept.has(closure.id)));
|
|
1852
|
+
const frozen = new Map(unswept.map((closure) => [closure.session_id, new Set(closure.order_ids)]));
|
|
1853
|
+
const patched = [];
|
|
1854
|
+
if (!frozen.size) return patched;
|
|
1855
|
+
for (const { id: id2, sessionId, cashierRef } of await readFresh(orders, { selector: { sessionId: { $in: [...frozen.keys()] } } })) {
|
|
1856
|
+
if (!sessionId || frozen.get(sessionId)?.has(id2) !== false) continue;
|
|
1857
|
+
let late = false;
|
|
1858
|
+
await (await orders.findOne(id2).exec())?.incrementalModify((doc) => {
|
|
1859
|
+
late = doc.sessionId === sessionId && doc.lateSessionId === void 0;
|
|
1860
|
+
if (!late) return doc;
|
|
1861
|
+
delete doc.sessionId;
|
|
1862
|
+
return { ...doc, lateSessionId: sessionId };
|
|
1863
|
+
});
|
|
1864
|
+
if (!late) continue;
|
|
1865
|
+
patched.push(id2);
|
|
1866
|
+
try {
|
|
1867
|
+
recordRegisterFact({ kind: "late-sale", orderId: id2, sessionId, registerId, actor: { id: cashierRef ?? "", name: "" } });
|
|
1868
|
+
} catch {
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
const now = Date.now();
|
|
1872
|
+
const markSwept = unswept.filter((closure) => now - new Date(closure.closed_at).getTime() >= SWEEP_GRACE_MS).map(({ id: id2 }) => id2);
|
|
1873
|
+
if (markSwept.length) await markClosuresSwept(register, storeKey, registerId, markSwept);
|
|
1874
|
+
return patched;
|
|
1875
|
+
}
|
|
1876
|
+
function businessDayOf(atGmt, timezone) {
|
|
1877
|
+
const at2 = new Date(atGmt.endsWith("Z") ? atGmt : `${atGmt}Z`);
|
|
1878
|
+
const format2 = new Intl.DateTimeFormat("en-US", {
|
|
1879
|
+
timeZone: timezone === "device" ? void 0 : timezone,
|
|
1880
|
+
year: "numeric",
|
|
1881
|
+
month: "2-digit",
|
|
1882
|
+
day: "2-digit"
|
|
1883
|
+
});
|
|
1884
|
+
const part = Object.fromEntries(format2.formatToParts(at2).map(({ type, value }) => [type, value]));
|
|
1885
|
+
return `${part.year}-${part.month}-${part.day}`;
|
|
1886
|
+
}
|
|
1887
|
+
function openSession(sessions, input) {
|
|
1888
|
+
const { year, month, day } = input.businessDay;
|
|
1889
|
+
return sessions.insert({
|
|
1890
|
+
id: mintUuid(),
|
|
1891
|
+
register_id: input.registerId,
|
|
1892
|
+
store_key: input.storeKey ?? null,
|
|
1893
|
+
status: "open",
|
|
1894
|
+
opened_at_gmt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1895
|
+
opened_by: input.openedBy,
|
|
1896
|
+
business_day: `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
|
|
1897
|
+
expected_float_minor: input.expectedFloatMinor,
|
|
1898
|
+
counted_float_minor: input.countedFloatMinor,
|
|
1899
|
+
opening_variance_minor: input.expectedFloatMinor === null ? null : countVariance(input.countedFloatMinor, input.expectedFloatMinor)
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
async function transition(sessions, id2, status, extra = {}) {
|
|
1903
|
+
const row = await sessions.findOne(id2).exec();
|
|
1904
|
+
if (!row) throw new RegisterSessionRequiredError();
|
|
1905
|
+
if (row.status === "closed" && status === "closed") return row;
|
|
1906
|
+
const at2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
1907
|
+
return row.incrementalModify((doc) => {
|
|
1908
|
+
if (doc.status === "closed" && status === "closed") return doc;
|
|
1909
|
+
if (doc.status === "closed") throw new RegisterSessionClosedError();
|
|
1910
|
+
if (!TRANSITIONS[doc.status].includes(status)) throw new Error(`invalid_session_transition:${doc.status}->${status}`);
|
|
1911
|
+
return {
|
|
1912
|
+
...doc,
|
|
1913
|
+
...extra,
|
|
1914
|
+
status,
|
|
1915
|
+
pending_status: status,
|
|
1916
|
+
status_at: at2,
|
|
1917
|
+
...status === "counting" ? { counting_started_at_gmt: at2 } : {},
|
|
1918
|
+
...status === "closed" ? { closed_at_gmt: at2 } : {}
|
|
1919
|
+
};
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
var startCounting = (sessions, id2) => transition(sessions, id2, "counting");
|
|
1923
|
+
var backToSelling = (sessions, id2) => transition(sessions, id2, "open");
|
|
1924
|
+
async function closeSession(sessions, id2, input) {
|
|
1925
|
+
const session = await sessions.findOne(id2).exec();
|
|
1926
|
+
if (!session) throw new RegisterSessionRequiredError();
|
|
1927
|
+
return transition(sessions, id2, "closed", {
|
|
1928
|
+
business_day: session.business_day || businessDayOf(session.opened_at_gmt, input.timezone ?? "device"),
|
|
1929
|
+
counted: input.counted,
|
|
1930
|
+
closed_by: input.closedBy ?? null,
|
|
1931
|
+
...input.approvedBy ? { approved_by: input.approvedBy } : {},
|
|
1932
|
+
closure_id: id2
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
async function recordMovement(sessions, movements, closures, input) {
|
|
1936
|
+
await requireLiveSession(sessions, input.sessionId);
|
|
1937
|
+
const row = await movements.insert({
|
|
1938
|
+
id: mintUuid(),
|
|
1939
|
+
session_id: input.sessionId,
|
|
1940
|
+
type: input.type,
|
|
1941
|
+
amountMinor: input.amountMinor,
|
|
1942
|
+
reason: input.reason,
|
|
1943
|
+
created_by: input.actor,
|
|
1944
|
+
created_at_gmt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1945
|
+
});
|
|
1946
|
+
let counted;
|
|
1947
|
+
try {
|
|
1948
|
+
const after = await readSession(sessions, input.sessionId);
|
|
1949
|
+
if (after?.status !== "closed") return row;
|
|
1950
|
+
counted = (await closures.findOne(input.sessionId).exec())?.movement_ids;
|
|
1951
|
+
} catch {
|
|
1952
|
+
return row;
|
|
1953
|
+
}
|
|
1954
|
+
if (!counted) throw new RegisterMovementStrandedError(row);
|
|
1955
|
+
if (counted.includes(row.id)) return row;
|
|
1956
|
+
try {
|
|
1957
|
+
await row.remove();
|
|
1958
|
+
} catch {
|
|
1959
|
+
throw new RegisterMovementStrandedError(row);
|
|
1960
|
+
}
|
|
1961
|
+
throw new RegisterSessionClosedError();
|
|
1962
|
+
}
|
|
1963
|
+
async function voidMovement(sessions, movements, movementId, actor, closures) {
|
|
1964
|
+
const row = await movements.findOne(movementId).exec();
|
|
1965
|
+
if (!row || row.type === "void") throw new Error("invalid_void_target");
|
|
1966
|
+
await requireLiveSession(sessions, row.session_id);
|
|
1967
|
+
const id2 = mintUuid();
|
|
1968
|
+
const claimed = await row.incrementalModify((doc) => {
|
|
1969
|
+
doc.voided_by ??= id2;
|
|
1970
|
+
return doc;
|
|
1971
|
+
});
|
|
1972
|
+
const reversalId = claimed.voided_by;
|
|
1973
|
+
const existing = await movements.findOne(reversalId).exec();
|
|
1974
|
+
const reversal = existing ?? await movements.incrementalUpsert({
|
|
1975
|
+
id: reversalId,
|
|
1976
|
+
session_id: row.session_id,
|
|
1977
|
+
type: "void",
|
|
1978
|
+
amountMinor: row.amountMinor,
|
|
1979
|
+
reason: row.reason,
|
|
1980
|
+
created_by: actor,
|
|
1981
|
+
created_at_gmt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1982
|
+
voids: row.id
|
|
1983
|
+
});
|
|
1984
|
+
let counted;
|
|
1985
|
+
try {
|
|
1986
|
+
const after = await readSession(sessions, row.session_id);
|
|
1987
|
+
if (after?.status !== "closed") return reversal;
|
|
1988
|
+
counted = (await readClosure(closures, row.session_id))?.movement_ids;
|
|
1989
|
+
} catch {
|
|
1990
|
+
return reversal;
|
|
1991
|
+
}
|
|
1992
|
+
if (counted?.includes(reversal.id)) return reversal;
|
|
1993
|
+
throw new RegisterMovementStrandedError(reversal);
|
|
1994
|
+
}
|
|
1995
|
+
async function writeClosure({
|
|
1996
|
+
closures,
|
|
1997
|
+
register,
|
|
1998
|
+
storeKey,
|
|
1999
|
+
session,
|
|
2000
|
+
counted,
|
|
2001
|
+
otherTenders,
|
|
2002
|
+
movements,
|
|
2003
|
+
orders,
|
|
2004
|
+
tillExpected,
|
|
2005
|
+
labels,
|
|
2006
|
+
resolveCashierName,
|
|
2007
|
+
softwareVersion,
|
|
2008
|
+
timezone = "device"
|
|
2009
|
+
}) {
|
|
2010
|
+
if (session.status !== "closed" || !session.closed_at_gmt) throw new Error("register_session_not_closed");
|
|
2011
|
+
const existing = await closures.findOne(session.id).exec();
|
|
2012
|
+
if (existing) {
|
|
2013
|
+
await advancePerpetual(register, storeKey, session.register_id, {
|
|
2014
|
+
salesMinor: existing.period_sales_total_minor,
|
|
2015
|
+
refundsMinor: existing.period_refunds_total_minor,
|
|
2016
|
+
closureId: existing.id
|
|
2017
|
+
});
|
|
2018
|
+
return existing;
|
|
2019
|
+
}
|
|
2020
|
+
const bound = orders.filter((order) => order.sessionId === session.id);
|
|
2021
|
+
const rows = bound.flatMap(
|
|
2022
|
+
(order) => order.payments.map((payment) => ({
|
|
2023
|
+
session_id: order.sessionId,
|
|
2024
|
+
kind: payment.method === "cash" ? "cash" : "other",
|
|
2025
|
+
method_id: payment.method,
|
|
2026
|
+
status: "captured",
|
|
2027
|
+
amountMinor: payment.amountMinor
|
|
2028
|
+
}))
|
|
2029
|
+
);
|
|
2030
|
+
const entries = movements.filter((row2) => row2.session_id === session.id);
|
|
2031
|
+
const till_expected = tillExpected ?? deriveExpected({
|
|
2032
|
+
session: { id: session.id, countedFloatMinor: session.counted_float_minor },
|
|
2033
|
+
movements: entries,
|
|
2034
|
+
ledgerRowsBySession: rows
|
|
2035
|
+
});
|
|
2036
|
+
const counts = { cash: counted, ...otherTenders };
|
|
2037
|
+
const sum = (values) => values.reduce((total, value) => total + value, 0);
|
|
2038
|
+
const payment_methods = {};
|
|
2039
|
+
for (const row2 of rows) {
|
|
2040
|
+
const method = row2.kind === "cash" ? "cash" : row2.method_id;
|
|
2041
|
+
payment_methods[method] = { sales_minor: (payment_methods[method]?.sales_minor ?? 0) + row2.amountMinor, refunds_minor: 0 };
|
|
2042
|
+
}
|
|
2043
|
+
const taxRates = /* @__PURE__ */ new Map();
|
|
2044
|
+
for (const order of bound) {
|
|
2045
|
+
const lines = order.lines.map((line) => ({ ...line, taxInclusive: line.taxInclusive ?? order.pricesIncludeTax }));
|
|
2046
|
+
for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor)) {
|
|
2047
|
+
const existing2 = taxRates.get(String(ratePpm));
|
|
2048
|
+
taxRates.set(String(ratePpm), {
|
|
2049
|
+
ratePpm,
|
|
2050
|
+
net_minor: (existing2?.net_minor ?? 0) + netMinor,
|
|
2051
|
+
tax_minor: (existing2?.tax_minor ?? 0) + amountMinor
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
const unsynced = bound.filter((order) => order.syncStatus === "pending");
|
|
2056
|
+
const draft = {
|
|
2057
|
+
id: session.id,
|
|
2058
|
+
session_id: session.id,
|
|
2059
|
+
register_id: session.register_id,
|
|
2060
|
+
store_key: session.store_key ?? null,
|
|
2061
|
+
number: 0,
|
|
2062
|
+
opened_at: session.opened_at_gmt,
|
|
2063
|
+
business_day: session.business_day || businessDayOf(session.opened_at_gmt, timezone),
|
|
2064
|
+
closed_by: session.closed_by ?? null,
|
|
2065
|
+
closed_at: session.closed_at_gmt,
|
|
2066
|
+
till_expected,
|
|
2067
|
+
expected: till_expected,
|
|
2068
|
+
counted: counts,
|
|
2069
|
+
variance: Object.fromEntries(
|
|
2070
|
+
Object.entries(counts).map(([method, value]) => [method, countVariance(value, till_expected[method] ?? 0)])
|
|
2071
|
+
),
|
|
2072
|
+
period_sales_total_minor: sum(rows.map((row2) => row2.amountMinor)),
|
|
2073
|
+
period_refunds_total_minor: 0,
|
|
2074
|
+
perpetual_sales_total_minor: 0,
|
|
2075
|
+
perpetual_refunds_total_minor: 0,
|
|
2076
|
+
unsynced_count: unsynced.length,
|
|
2077
|
+
unsynced_total_minor: sum(unsynced.flatMap((order) => order.payments.map((payment) => payment.amountMinor))),
|
|
2078
|
+
software_version: softwareVersion,
|
|
2079
|
+
printed_at: null,
|
|
2080
|
+
print_count: 0,
|
|
2081
|
+
breakdowns: {
|
|
2082
|
+
...labels,
|
|
2083
|
+
opened_by: session.opened_by ?? null,
|
|
2084
|
+
approved_by: session.approved_by ?? null,
|
|
2085
|
+
payment_methods,
|
|
2086
|
+
tax_rates: Object.fromEntries(
|
|
2087
|
+
[...taxRates.values()].map(({ ratePpm, net_minor, tax_minor }) => [
|
|
2088
|
+
ratePpm,
|
|
2089
|
+
{ name: `Tax ${ratePpm / 1e4}%`, net_minor, tax_minor, gross_minor: net_minor + tax_minor }
|
|
2090
|
+
])
|
|
2091
|
+
),
|
|
2092
|
+
opening_float: {
|
|
2093
|
+
expected_minor: session.expected_float_minor ?? null,
|
|
2094
|
+
counted_minor: session.counted_float_minor,
|
|
2095
|
+
variance_minor: session.opening_variance_minor ?? null
|
|
2096
|
+
},
|
|
2097
|
+
movements: entries.map(({ id: id2, type, amountMinor, reason, voids, voided_by, created_at_gmt, created_by }) => ({
|
|
2098
|
+
id: id2,
|
|
2099
|
+
type,
|
|
2100
|
+
amountMinor,
|
|
2101
|
+
reason,
|
|
2102
|
+
voids: voids ?? null,
|
|
2103
|
+
created_at_gmt,
|
|
2104
|
+
created_by: created_by ?? null,
|
|
2105
|
+
voided_by: voided_by ?? null
|
|
2106
|
+
})),
|
|
2107
|
+
transaction_count: bound.length,
|
|
2108
|
+
refund_count: 0,
|
|
2109
|
+
cashiers: [...new Set(bound.map((order) => order.cashierRef ?? "").filter(Boolean))].map((id2) => ({
|
|
2110
|
+
id: id2,
|
|
2111
|
+
name: resolveCashierName?.(id2) || id2
|
|
2112
|
+
}))
|
|
2113
|
+
},
|
|
2114
|
+
order_ids: bound.map((order) => order.id),
|
|
2115
|
+
movement_ids: entries.map((row2) => row2.id)
|
|
2116
|
+
};
|
|
2117
|
+
await mintClosureNumber(register, storeKey, session.register_id, draft);
|
|
2118
|
+
const reserved = (await readRegister(register)).stores[storeKey].registers[session.register_id].closure_reservation.row;
|
|
2119
|
+
let row;
|
|
2120
|
+
try {
|
|
2121
|
+
row = await closures.insert({ ...reserved, order_ids: [...reserved.order_ids], movement_ids: [...reserved.movement_ids] });
|
|
2122
|
+
} catch (error) {
|
|
2123
|
+
const winner = await closures.findOne(reserved.id).exec();
|
|
2124
|
+
if (!winner) throw error;
|
|
2125
|
+
row = winner;
|
|
2126
|
+
}
|
|
2127
|
+
await advancePerpetual(register, storeKey, session.register_id, {
|
|
2128
|
+
salesMinor: row.period_sales_total_minor,
|
|
2129
|
+
refundsMinor: row.period_refunds_total_minor,
|
|
2130
|
+
closureId: row.id
|
|
2131
|
+
});
|
|
2132
|
+
return row;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// src/register/settled-figures.ts
|
|
2136
|
+
function deriveSettled(recorded, corrections) {
|
|
2137
|
+
const settled = {
|
|
2138
|
+
...recorded,
|
|
2139
|
+
expected: { ...recorded.expected },
|
|
2140
|
+
counted: { ...recorded.counted },
|
|
2141
|
+
variance: { ...recorded.variance }
|
|
2142
|
+
};
|
|
2143
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2144
|
+
for (const correction of [...corrections].sort(
|
|
2145
|
+
(a, b) => a.created_at.localeCompare(b.created_at) || a.id - b.id
|
|
2146
|
+
)) {
|
|
2147
|
+
if (seen.has(correction.id)) continue;
|
|
2148
|
+
seen.add(correction.id);
|
|
2149
|
+
const f = correction.figures;
|
|
2150
|
+
if (correction.type === "recount" && f.counted) Object.assign(settled.counted, f.counted);
|
|
2151
|
+
const deltas = correction.type === "late_movement" ? { cash: f.cash_delta ?? 0 } : correction.type === "late_sale" ? f.expected_delta ?? {} : {};
|
|
2152
|
+
for (const [tender, delta] of Object.entries(deltas))
|
|
2153
|
+
settled.expected[tender] = (settled.expected[tender] ?? 0) + delta;
|
|
2154
|
+
if (correction.type === "late_sale") {
|
|
2155
|
+
settled.period_sales_total_minor += f.sales_delta ?? 0;
|
|
2156
|
+
settled.perpetual_sales_total_minor += f.sales_delta ?? 0;
|
|
2157
|
+
settled.period_refunds_total_minor += f.refunds_delta ?? 0;
|
|
2158
|
+
settled.perpetual_refunds_total_minor += f.refunds_delta ?? 0;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
if (corrections.length)
|
|
2162
|
+
for (const tender of /* @__PURE__ */ new Set([...Object.keys(settled.expected), ...Object.keys(settled.counted)]))
|
|
2163
|
+
settled.variance[tender] = (settled.counted[tender] ?? 0) - (settled.expected[tender] ?? 0);
|
|
2164
|
+
const touched = /* @__PURE__ */ new Set();
|
|
2165
|
+
for (const field of [
|
|
2166
|
+
"expected",
|
|
2167
|
+
"counted",
|
|
2168
|
+
"variance",
|
|
2169
|
+
"period_sales_total_minor",
|
|
2170
|
+
"period_refunds_total_minor",
|
|
2171
|
+
"perpetual_sales_total_minor",
|
|
2172
|
+
"perpetual_refunds_total_minor"
|
|
2173
|
+
]) {
|
|
2174
|
+
const value = settled[field];
|
|
2175
|
+
if (typeof value === "number") {
|
|
2176
|
+
if (value !== recorded[field]) touched.add(field);
|
|
2177
|
+
} else
|
|
2178
|
+
for (const [tender, amount] of Object.entries(value))
|
|
2179
|
+
if (amount !== (recorded[field][tender] ?? 0))
|
|
2180
|
+
touched.add(`${field}.${tender}`);
|
|
2181
|
+
}
|
|
2182
|
+
return { settled, touched };
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// src/register/money.ts
|
|
2186
|
+
function minorToDecimal(minor2, exponent) {
|
|
2187
|
+
const sign = minor2 < 0 ? "-" : "";
|
|
2188
|
+
const abs = Math.abs(minor2);
|
|
2189
|
+
const scale = 10 ** exponent;
|
|
2190
|
+
const whole = Math.trunc(abs / scale);
|
|
2191
|
+
const fraction = abs % scale;
|
|
2192
|
+
return exponent === 0 ? `${sign}${whole}` : `${sign}${whole}.${String(fraction).padStart(exponent, "0")}`;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// src/register/export-csv.ts
|
|
2196
|
+
var at = (map6, key) => map6[key];
|
|
2197
|
+
function exportCsv(rows, exponent, t, names = {}, storeName = "") {
|
|
2198
|
+
const tenders2 = [
|
|
2199
|
+
...new Set(
|
|
2200
|
+
rows.flatMap((row) => [
|
|
2201
|
+
...Object.keys(row.expected),
|
|
2202
|
+
...Object.keys(row.counted),
|
|
2203
|
+
...Object.keys(row.variance)
|
|
2204
|
+
])
|
|
2205
|
+
)
|
|
2206
|
+
];
|
|
2207
|
+
const cell = (value) => {
|
|
2208
|
+
const text2 = String(value ?? "");
|
|
2209
|
+
return `"${(/^(?:[\t\r\n]|[\s\x00-\x1f\x7f-\x9f]*[=+\-@])/.test(text2) ? "'" + text2 : text2).replace(/"/g, '""')}"`;
|
|
2210
|
+
};
|
|
2211
|
+
const header = ["business_day", "closure", "register", "store", "opened", "closed", "closer"].map(
|
|
2212
|
+
(key) => t(`reports.csv_${key}`)
|
|
2213
|
+
);
|
|
2214
|
+
header.push(
|
|
2215
|
+
...tenders2.flatMap((method) => [
|
|
2216
|
+
`${t("reports.document.expected")} (${method})`,
|
|
2217
|
+
`${t("register.counted")} (${method})`,
|
|
2218
|
+
`${t("register.variance")} (${method})`
|
|
2219
|
+
]),
|
|
2220
|
+
t("reports.csv_status")
|
|
2221
|
+
);
|
|
2222
|
+
return [
|
|
2223
|
+
header,
|
|
2224
|
+
...rows.map((row) => [
|
|
2225
|
+
row.business_day,
|
|
2226
|
+
row.number,
|
|
2227
|
+
row.breakdowns.register_name || names[row.register_id] || row.register_id.slice(0, 8),
|
|
2228
|
+
row.breakdowns.store_name || storeName || row.store_key,
|
|
2229
|
+
row.opened_at,
|
|
2230
|
+
row.closed_at,
|
|
2231
|
+
row.breakdowns.closed_by_name || t("register.unknown_cashier"),
|
|
2232
|
+
...tenders2.flatMap((method) => {
|
|
2233
|
+
const expected = at(row.expected, method);
|
|
2234
|
+
const counted = at(row.counted, method) ?? (expected === void 0 ? void 0 : 0);
|
|
2235
|
+
const value = at(row.variance, method) ?? (expected === void 0 ? void 0 : (counted ?? 0) - expected);
|
|
2236
|
+
return [
|
|
2237
|
+
expected === void 0 ? "" : minorToDecimal(expected, exponent),
|
|
2238
|
+
counted === void 0 ? "" : minorToDecimal(counted, exponent),
|
|
2239
|
+
value === void 0 ? "" : `${minorToDecimal(Math.abs(value), exponent)} ${t(value < 0 ? "register.short" : value > 0 ? "register.over" : "reports.exact")}`
|
|
2240
|
+
];
|
|
2241
|
+
}),
|
|
2242
|
+
row.unsynced_count > 0 ? t("register.unsynced") : row.corrections_count ? t("reports.corrected") : ""
|
|
2243
|
+
])
|
|
2244
|
+
].map((row) => row.map(cell).join(",")).join("\r\n");
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// src/register/document-labels.ts
|
|
2248
|
+
var labelKeys = {
|
|
2249
|
+
x_report: "register.x_report",
|
|
2250
|
+
closure: "reports.document.closure",
|
|
2251
|
+
opened: "reports.document.opened",
|
|
2252
|
+
closed: "register.closed",
|
|
2253
|
+
approver: "reports.document.approver",
|
|
2254
|
+
opening_float: "reports.document.opening_float",
|
|
2255
|
+
expected: "reports.document.expected",
|
|
2256
|
+
counted: "register.counted",
|
|
2257
|
+
variance: "register.variance",
|
|
2258
|
+
tenders: "reports.document.tenders",
|
|
2259
|
+
tender: "reports.document.tender",
|
|
2260
|
+
cash_movements: "reports.document.cash_movements",
|
|
2261
|
+
time: "common.time",
|
|
2262
|
+
type: "common.type",
|
|
2263
|
+
amount: "register.amount",
|
|
2264
|
+
reason: "register.reason",
|
|
2265
|
+
sales: "reports.sales",
|
|
2266
|
+
period_sales: "register.period_sales",
|
|
2267
|
+
period_refunds: "register.period_refunds",
|
|
2268
|
+
transactions: "reports.document.transactions",
|
|
2269
|
+
refunds: "common.refunds",
|
|
2270
|
+
payment_method: "reports.document.payment_method",
|
|
2271
|
+
cashiers: "reports.document.cashiers",
|
|
2272
|
+
tax_rates: "common.tax_rates",
|
|
2273
|
+
tax_rate: "reports.document.tax_rate",
|
|
2274
|
+
net: "reports.document.net",
|
|
2275
|
+
tax: "common.tax",
|
|
2276
|
+
gross: "reports.document.gross",
|
|
2277
|
+
perpetual_totals: "reports.document.perpetual_totals",
|
|
2278
|
+
perpetual_sales: "register.perpetual_sales",
|
|
2279
|
+
perpetual_refunds: "register.perpetual_refunds",
|
|
2280
|
+
unsynced_sales: "reports.document.unsynced_sales",
|
|
2281
|
+
short: "reports.document.short",
|
|
2282
|
+
over: "reports.document.over",
|
|
2283
|
+
exact: "reports.exact",
|
|
2284
|
+
voided: "pos_checkout.status_voided",
|
|
2285
|
+
paid_in: "register.paid_in",
|
|
2286
|
+
paid_out: "register.paid_out",
|
|
2287
|
+
no_sale: "register.no_sale",
|
|
2288
|
+
void: "register.void",
|
|
2289
|
+
copy: "reports.document.copy"
|
|
2290
|
+
};
|
|
2291
|
+
|
|
2292
|
+
// src/register/closure-rows.ts
|
|
2293
|
+
var DEFAULT_HISTORY_DAYS = 92;
|
|
2294
|
+
function daysBefore(day, days) {
|
|
2295
|
+
const at2 = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
|
|
2296
|
+
at2.setUTCDate(at2.getUTCDate() - days);
|
|
2297
|
+
return at2.toISOString().slice(0, 10);
|
|
2298
|
+
}
|
|
2299
|
+
function clampClosureScope(scope, today, historyDays = DEFAULT_HISTORY_DAYS) {
|
|
2300
|
+
const min = daysBefore(today, historyDays);
|
|
2301
|
+
const clamp = (day) => day < min ? min : day > today ? today : day;
|
|
2302
|
+
return { ...scope, from: clamp(scope.from), to: clamp(scope.to) };
|
|
2303
|
+
}
|
|
2304
|
+
function selectClosureRows(rows, scope, timezone) {
|
|
2305
|
+
return rows.map((row) => ({
|
|
2306
|
+
...row,
|
|
2307
|
+
business_day: row.business_day || businessDayOf(row.opened_at, timezone)
|
|
2308
|
+
})).filter(
|
|
2309
|
+
(row) => row.business_day >= scope.from && row.business_day <= scope.to && (!scope.registerId || row.register_id === scope.registerId) && (row.store_key ?? null) === (scope.storeKey ?? null) && (scope.cashier === void 0 || row.closed_by === scope.cashier)
|
|
2310
|
+
).sort((a, b) => b.business_day.localeCompare(a.business_day) || b.closed_at.localeCompare(a.closed_at));
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// src/register/closure-document.ts
|
|
2314
|
+
var decimalMap = (minor2, exponent) => Object.fromEntries(Object.entries(minor2).map(([key, value]) => [key, minorToDecimal(value, exponent)]));
|
|
2315
|
+
function decimalizeBreakdowns(breakdowns, exponent) {
|
|
2316
|
+
const dec = (m) => typeof m === "number" ? minorToDecimal(m, exponent) : m;
|
|
2317
|
+
const group = (key, pairs) => {
|
|
2318
|
+
const source = breakdowns[key];
|
|
2319
|
+
return source && Object.fromEntries(
|
|
2320
|
+
Object.entries(source).map(([id2, entry]) => [
|
|
2321
|
+
id2,
|
|
2322
|
+
{ ...entry, ...Object.fromEntries(pairs.map(([from, to]) => [to, dec(entry[from])])) }
|
|
2323
|
+
])
|
|
2324
|
+
);
|
|
2325
|
+
};
|
|
2326
|
+
const openingFloat = breakdowns.opening_float;
|
|
2327
|
+
return {
|
|
2328
|
+
...breakdowns,
|
|
2329
|
+
...breakdowns.payment_methods ? { payment_methods: group("payment_methods", [["sales_minor", "sales"], ["refunds_minor", "refunds"]]) } : {},
|
|
2330
|
+
...breakdowns.tax_rates ? { tax_rates: group("tax_rates", [["net_minor", "net"], ["tax_minor", "tax"], ["gross_minor", "gross"]]) } : {},
|
|
2331
|
+
...openingFloat ? {
|
|
2332
|
+
opening_float: {
|
|
2333
|
+
...openingFloat,
|
|
2334
|
+
expected: dec(openingFloat.expected_minor),
|
|
2335
|
+
counted: dec(openingFloat.counted_minor),
|
|
2336
|
+
variance: dec(openingFloat.variance_minor)
|
|
2337
|
+
}
|
|
2338
|
+
} : {},
|
|
2339
|
+
...breakdowns.movements ? { movements: breakdowns.movements.map((m) => ({ ...m, amount: dec(m.amountMinor) })) } : {}
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
function formatClosureDate(value, context) {
|
|
2343
|
+
const date = value ? new Date(/[Zz]|[+-]\d\d:\d\d$/.test(value) ? value : `${value.replace(" ", "T")}Z`) : null;
|
|
2344
|
+
const timeZone = context.timezone === "device" ? void 0 : context.timezone;
|
|
2345
|
+
const display = (settings) => date ? new Intl.DateTimeFormat(context.locale, { timeZone, ...settings, hour12: false }).format(date) : "";
|
|
2346
|
+
const result = {
|
|
2347
|
+
datetime: display({ dateStyle: "medium", timeStyle: "short" }),
|
|
2348
|
+
date: display({ dateStyle: "medium" }),
|
|
2349
|
+
time: display({ timeStyle: "short" })
|
|
2350
|
+
};
|
|
2351
|
+
for (const style of ["short", "long", "full"]) {
|
|
2352
|
+
result[`datetime_${style}`] = display({ dateStyle: style, timeStyle: style === "short" ? "short" : style });
|
|
2353
|
+
result[`date_${style}`] = display({ dateStyle: style });
|
|
2354
|
+
}
|
|
2355
|
+
const parts = date ? Object.fromEntries(
|
|
2356
|
+
new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(date).map(({ type, value: v }) => [type, v])
|
|
2357
|
+
) : {};
|
|
2358
|
+
result.date_ymd = date ? `${parts.year}-${parts.month}-${parts.day}` : "";
|
|
2359
|
+
result.date_dmy = date ? `${parts.day}/${parts.month}/${parts.year}` : "";
|
|
2360
|
+
result.date_mdy = date ? `${parts.month}/${parts.day}/${parts.year}` : "";
|
|
2361
|
+
result.day = date ? parts.day : "";
|
|
2362
|
+
result.month = date ? parts.month : "";
|
|
2363
|
+
result.year = date ? parts.year : "";
|
|
2364
|
+
for (const style of ["short", "long"]) {
|
|
2365
|
+
result[`weekday_${style}`] = display({ weekday: style });
|
|
2366
|
+
result[`month_${style}`] = display({ month: style });
|
|
2367
|
+
}
|
|
2368
|
+
return result;
|
|
2369
|
+
}
|
|
2370
|
+
function envelope(row, context, xreport = false) {
|
|
2371
|
+
const money = (values, fields) => Object.assign(
|
|
2372
|
+
{},
|
|
2373
|
+
values,
|
|
2374
|
+
Object.fromEntries(fields.map((key) => [`${key}_display`, context.formatMoney(String(values[key] ?? ""))]))
|
|
2375
|
+
);
|
|
2376
|
+
const breakdowns = row.breakdowns ?? {};
|
|
2377
|
+
const labels = {
|
|
2378
|
+
opened_by_name: "",
|
|
2379
|
+
closed_by_name: "",
|
|
2380
|
+
approved_by_name: "",
|
|
2381
|
+
...Object.fromEntries(Object.entries(breakdowns).filter(([key]) => key.endsWith("_name"))),
|
|
2382
|
+
...breakdowns.labels
|
|
2383
|
+
};
|
|
2384
|
+
const rows = (key, fields) => Object.entries(breakdowns[key] ?? {}).map(
|
|
2385
|
+
([entryKey, value]) => money({ ...value, name: value.name ?? value.method ?? value.rate ?? entryKey }, fields)
|
|
2386
|
+
);
|
|
2387
|
+
const methods = breakdowns.payment_methods ?? {};
|
|
2388
|
+
const counted = row.counted ?? {};
|
|
2389
|
+
const expected = row.expected ?? {};
|
|
2390
|
+
const variances = row.variance ?? {};
|
|
2391
|
+
const tenders2 = [.../* @__PURE__ */ new Set([...Object.keys(counted), ...Object.keys(expected)])].map((name) => {
|
|
2392
|
+
const variance = variances[name] ?? "";
|
|
2393
|
+
const method = Object.values(methods).find((m) => m.method === name) ?? methods[name];
|
|
2394
|
+
return money(
|
|
2395
|
+
{
|
|
2396
|
+
name,
|
|
2397
|
+
label: method?.name || name.charAt(0).toUpperCase() + name.slice(1),
|
|
2398
|
+
expected: expected[name] ?? "",
|
|
2399
|
+
counted: counted[name] ?? "",
|
|
2400
|
+
variance,
|
|
2401
|
+
has_variance: Number(variance || 0) !== 0,
|
|
2402
|
+
variance_label: variance === "" ? "" : context.i18n[Number(variance) > 0 ? "over" : Number(variance) < 0 ? "short" : "exact"],
|
|
2403
|
+
variance_absolute_display: context.formatMoney(variance.replace("-", ""))
|
|
2404
|
+
},
|
|
2405
|
+
["expected", "counted", "variance"]
|
|
2406
|
+
);
|
|
2407
|
+
});
|
|
2408
|
+
return {
|
|
2409
|
+
closure: {
|
|
2410
|
+
has_sales: row.period_sales_total != null || row.period_refunds_total != null || Number(breakdowns.transaction_count) > 0,
|
|
2411
|
+
has_perpetual: row.perpetual_sales_total != null || row.perpetual_refunds_total != null,
|
|
2412
|
+
...Object.fromEntries(
|
|
2413
|
+
["payment_methods", "tax_rates", "movements"].map((key) => [`has_${key}`, Object.keys(breakdowns[key] ?? {}).length > 0])
|
|
2414
|
+
),
|
|
2415
|
+
...money(row, ["period_sales_total", "period_refunds_total", "perpetual_sales_total", "perpetual_refunds_total", "unsynced_total"]),
|
|
2416
|
+
opened_at_gmt: row.opened_at,
|
|
2417
|
+
opened_by: breakdowns.opened_by ?? null,
|
|
2418
|
+
closed_by: row.closed_by ?? null,
|
|
2419
|
+
approved_by: breakdowns.approved_by ?? null,
|
|
2420
|
+
closed_at_gmt: row.closed_at,
|
|
2421
|
+
opened_at: formatClosureDate(row.opened_at, context),
|
|
2422
|
+
closed_at: formatClosureDate(row.closed_at, context),
|
|
2423
|
+
tenders: tenders2,
|
|
2424
|
+
breakdowns: {
|
|
2425
|
+
...breakdowns,
|
|
2426
|
+
labels,
|
|
2427
|
+
payment_methods: rows("payment_methods", ["sales", "refunds"]),
|
|
2428
|
+
tax_rates: rows("tax_rates", ["net", "tax", "gross"]),
|
|
2429
|
+
opening_float: money(breakdowns.opening_float ?? {}, ["expected", "counted", "variance"]),
|
|
2430
|
+
movements: (breakdowns.movements ?? []).map((m) => ({
|
|
2431
|
+
...money(m, ["amount"]),
|
|
2432
|
+
created_at: formatClosureDate(m.created_at_gmt ?? m.created_at, context),
|
|
2433
|
+
type_label: context.i18n[String(m.type)] ?? m.type,
|
|
2434
|
+
voided: !!m.voided_by
|
|
2435
|
+
})),
|
|
2436
|
+
cashiers: (breakdowns.cashiers ?? []).map((c) => typeof c === "string" ? { id: c, name: c } : c)
|
|
2437
|
+
}
|
|
2438
|
+
},
|
|
2439
|
+
store: context.store,
|
|
2440
|
+
register: { id: row.register_id, name: labels.register_name ?? "" },
|
|
2441
|
+
software: { name: "WCPOS", plugin_version: row.software_version ?? "" },
|
|
2442
|
+
order: { currency: context.currency, printed: formatClosureDate(context.printedAt, context) },
|
|
2443
|
+
fiscal: {
|
|
2444
|
+
immutable_id: "",
|
|
2445
|
+
hash: "",
|
|
2446
|
+
qr_payload: "",
|
|
2447
|
+
tax_agency_code: "",
|
|
2448
|
+
signature_excerpt: "",
|
|
2449
|
+
document_label: "",
|
|
2450
|
+
sequence: null,
|
|
2451
|
+
signed_at: null,
|
|
2452
|
+
extra_fields: [],
|
|
2453
|
+
document_type: xreport ? "xreport" : "closure",
|
|
2454
|
+
receipt_number: xreport ? "" : String(row.number),
|
|
2455
|
+
is_sale_document: false,
|
|
2456
|
+
is_refund_document: false,
|
|
2457
|
+
is_closure_document: true,
|
|
2458
|
+
is_x_report: xreport,
|
|
2459
|
+
is_reprint: !xreport && !!row.print_count,
|
|
2460
|
+
reprint_count: xreport ? 0 : row.print_count ?? 0
|
|
2461
|
+
},
|
|
2462
|
+
i18n: context.i18n
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
function buildClosureDocument(row, context) {
|
|
2466
|
+
const { exponent } = context;
|
|
2467
|
+
const dec = (m) => minorToDecimal(m, exponent);
|
|
2468
|
+
const {
|
|
2469
|
+
store_key,
|
|
2470
|
+
till_expected,
|
|
2471
|
+
expected,
|
|
2472
|
+
counted,
|
|
2473
|
+
variance,
|
|
2474
|
+
period_sales_total_minor,
|
|
2475
|
+
period_refunds_total_minor,
|
|
2476
|
+
perpetual_sales_total_minor,
|
|
2477
|
+
perpetual_refunds_total_minor,
|
|
2478
|
+
unsynced_total_minor,
|
|
2479
|
+
...rest
|
|
2480
|
+
} = row;
|
|
2481
|
+
return envelope(
|
|
2482
|
+
{
|
|
2483
|
+
...rest,
|
|
2484
|
+
breakdowns: decimalizeBreakdowns(rest.breakdowns, exponent),
|
|
2485
|
+
store_id: store_key,
|
|
2486
|
+
till_expected: decimalMap(till_expected, exponent),
|
|
2487
|
+
expected: decimalMap(expected, exponent),
|
|
2488
|
+
counted: decimalMap(counted, exponent),
|
|
2489
|
+
variance: decimalMap(variance, exponent),
|
|
2490
|
+
period_sales_total: dec(period_sales_total_minor),
|
|
2491
|
+
period_refunds_total: dec(period_refunds_total_minor),
|
|
2492
|
+
perpetual_sales_total: dec(perpetual_sales_total_minor),
|
|
2493
|
+
perpetual_refunds_total: dec(perpetual_refunds_total_minor),
|
|
2494
|
+
unsynced_total: dec(unsynced_total_minor)
|
|
2495
|
+
},
|
|
2496
|
+
context
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
function buildXReportDocument(session, context) {
|
|
2500
|
+
const { exponent } = context;
|
|
2501
|
+
const dec = (m) => m == null ? void 0 : minorToDecimal(m, exponent);
|
|
2502
|
+
const expectedMinor = context.expected ?? session.server_expected ?? {};
|
|
2503
|
+
const countedMinor = session.counted ?? {};
|
|
2504
|
+
const varianceMinor = Object.fromEntries(
|
|
2505
|
+
Object.entries(countedMinor).map(([key, value]) => [key, value - (expectedMinor[key] ?? 0)])
|
|
2506
|
+
);
|
|
2507
|
+
return envelope(
|
|
2508
|
+
{
|
|
2509
|
+
id: session.id,
|
|
2510
|
+
register_id: session.register_id,
|
|
2511
|
+
store_id: session.store_key,
|
|
2512
|
+
opened_at: session.opened_at_gmt,
|
|
2513
|
+
expected: decimalMap(expectedMinor, exponent),
|
|
2514
|
+
counted: decimalMap(countedMinor, exponent),
|
|
2515
|
+
variance: decimalMap(varianceMinor, exponent),
|
|
2516
|
+
breakdowns: {
|
|
2517
|
+
...context.breakdowns,
|
|
2518
|
+
opening_float: {
|
|
2519
|
+
expected: dec(session.expected_float_minor),
|
|
2520
|
+
counted: dec(session.counted_float_minor),
|
|
2521
|
+
variance: dec(session.opening_variance_minor)
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
},
|
|
2525
|
+
context,
|
|
2526
|
+
true
|
|
2527
|
+
);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// src/register/use-register-session.ts
|
|
2531
|
+
import { useEffect as useEffect2, useMemo as useMemo3, useRef as useRef2, useState as useState2, useSyncExternalStore } from "react";
|
|
2532
|
+
import { combineLatest, map as map5, of, switchMap } from "rxjs";
|
|
2533
|
+
var RegisterTenderInProgressError = class extends Error {
|
|
2534
|
+
constructor() {
|
|
2535
|
+
super("Finish or cancel the sale in progress first.");
|
|
2536
|
+
this.name = "RegisterTenderInProgressError";
|
|
2537
|
+
}
|
|
2538
|
+
};
|
|
2539
|
+
var RegisterSessionAlreadyOpenError = class extends Error {
|
|
2540
|
+
constructor() {
|
|
2541
|
+
super("This register already has an open session.");
|
|
2542
|
+
this.name = "RegisterSessionAlreadyOpenError";
|
|
2543
|
+
}
|
|
2544
|
+
};
|
|
2545
|
+
var RegisterCloseIncompleteError = class extends Error {
|
|
2546
|
+
constructor() {
|
|
2547
|
+
super("Finish closing the previous session first.");
|
|
2548
|
+
this.name = "RegisterCloseIncompleteError";
|
|
2549
|
+
}
|
|
2550
|
+
};
|
|
2551
|
+
var RegisterApprovalRequiredError = class extends Error {
|
|
2552
|
+
constructor() {
|
|
2553
|
+
super("Manager approval needed. Ask a manager to approve, or count again.");
|
|
2554
|
+
this.name = "RegisterApprovalRequiredError";
|
|
2555
|
+
}
|
|
2556
|
+
};
|
|
2557
|
+
var openingByRegister = /* @__PURE__ */ new Map();
|
|
2558
|
+
var closingByRegister = /* @__PURE__ */ new Map();
|
|
2559
|
+
var closingListeners = /* @__PURE__ */ new Set();
|
|
2560
|
+
var notifyClosing = () => closingListeners.forEach((listener) => listener());
|
|
2561
|
+
var subscribeClosing = (listener) => {
|
|
2562
|
+
closingListeners.add(listener);
|
|
2563
|
+
return () => {
|
|
2564
|
+
closingListeners.delete(listener);
|
|
2565
|
+
};
|
|
2566
|
+
};
|
|
2567
|
+
var closedFacts = /* @__PURE__ */ new Set();
|
|
2568
|
+
var reservationOf = (register, storeKey, registerId) => register?.stores[storeKey]?.registers?.[registerId]?.closure_reservation;
|
|
2569
|
+
var unwritten = (row, closureRows) => row.status === "closed" && row.closure_id === row.id && !closureRows.some((closure) => closure.id === row.id);
|
|
2570
|
+
function currentSession(rows, closureRows, reservation) {
|
|
2571
|
+
return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : void 0) ?? rows.find((row) => row.status !== "closed") ?? rows.find((row) => unwritten(row, closureRows)) ?? null;
|
|
2572
|
+
}
|
|
2573
|
+
function ledgerRows(orders) {
|
|
2574
|
+
return orders.flatMap((order) => order.payments.map((payment) => ({
|
|
2575
|
+
session_id: order.sessionId,
|
|
2576
|
+
kind: payment.method === "cash" ? "cash" : "other",
|
|
2577
|
+
method_id: payment.method,
|
|
2578
|
+
status: "captured",
|
|
2579
|
+
amountMinor: payment.amountMinor
|
|
2580
|
+
})));
|
|
2581
|
+
}
|
|
2582
|
+
function useRegisterSession(options) {
|
|
2583
|
+
const { sessions, movements, closures, orders, register, storeKey, registerId, enabled, actor, timezone, tenderInProgress, expectedCloseTime, labels } = options;
|
|
2584
|
+
const source = useMemo3(() => {
|
|
2585
|
+
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
|
|
2586
|
+
return combineLatest([
|
|
2587
|
+
watchFresh(sessions, { selector: { register_id: registerId } }),
|
|
2588
|
+
watchFresh(closures, { selector: { register_id: registerId } }),
|
|
2589
|
+
observeRegister$(register).pipe(map5((document) => reservationOf(document, storeKey, registerId)))
|
|
2590
|
+
]).pipe(switchMap(([rows, closureRows, reservation]) => {
|
|
2591
|
+
const current2 = currentSession(rows, closureRows, reservation);
|
|
2592
|
+
if (!current2) return of({ rows, closureRows, reservation, entries: [], sales: [] });
|
|
2593
|
+
return combineLatest([
|
|
2594
|
+
watchFresh(movements, { selector: { session_id: current2.id } }),
|
|
2595
|
+
watchFresh(orders, { selector: { sessionId: current2.id } })
|
|
2596
|
+
]).pipe(map5(([entries2, sales2]) => ({ rows, closureRows, reservation, entries: entries2, sales: sales2 })));
|
|
2597
|
+
}));
|
|
2598
|
+
}, [enabled, sessions, movements, closures, orders, register, storeKey, registerId]);
|
|
2599
|
+
const tender = useRef2(tenderInProgress);
|
|
2600
|
+
tender.current = tenderInProgress;
|
|
2601
|
+
const closing = useSyncExternalStore(subscribeClosing, () => closingByRegister.has(registerId ?? ""));
|
|
2602
|
+
const [observed, setObserved] = useState2(null);
|
|
2603
|
+
useEffect2(() => {
|
|
2604
|
+
if (!source) return;
|
|
2605
|
+
const subscription = source.subscribe((snapshot) => setObserved({ source, snapshot }));
|
|
2606
|
+
return () => subscription.unsubscribe();
|
|
2607
|
+
}, [source]);
|
|
2608
|
+
const [, setMinute] = useState2(0);
|
|
2609
|
+
useEffect2(() => {
|
|
2610
|
+
if (!enabled || !expectedCloseTime) return;
|
|
2611
|
+
const timer = setInterval(() => setMinute((value) => value + 1), 6e4);
|
|
2612
|
+
return () => clearInterval(timer);
|
|
2613
|
+
}, [enabled, expectedCloseTime]);
|
|
2614
|
+
const data = source && observed?.source === source ? observed.snapshot : null;
|
|
2615
|
+
const sweeper = useRef2({});
|
|
2616
|
+
sweeper.current.target = enabled && sessions && closures && orders && registerId && register ? { sessions, closures, orders, registerId, register, storeKey } : void 0;
|
|
2617
|
+
const sweep = (full = false) => {
|
|
2618
|
+
const state = sweeper.current;
|
|
2619
|
+
if (state.running) {
|
|
2620
|
+
state.queuedFull = state.queuedFull || full;
|
|
2621
|
+
return state.queued ??= state.running.then(() => {
|
|
2622
|
+
const runFull = !!state.queuedFull;
|
|
2623
|
+
state.queued = void 0;
|
|
2624
|
+
state.queuedFull = void 0;
|
|
2625
|
+
return sweep(runFull);
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
const target = state.target;
|
|
2629
|
+
if (!target) return Promise.resolve();
|
|
2630
|
+
const run = sweepOrphanStamps({ ...target, full }).then(() => void 0, (error) => {
|
|
2631
|
+
try {
|
|
2632
|
+
registerFactsLogger.error("Register orphan-stamp sweep failed", { context: { type: "register.sweep-failed", registerId: target.registerId, error: String(error) } });
|
|
2633
|
+
} catch {
|
|
2634
|
+
}
|
|
2635
|
+
}).finally(() => {
|
|
2636
|
+
state.running = void 0;
|
|
2637
|
+
});
|
|
2638
|
+
return state.running = run;
|
|
2639
|
+
};
|
|
2640
|
+
const seen = useRef2(null);
|
|
2641
|
+
useEffect2(() => {
|
|
2642
|
+
if (!data) return;
|
|
2643
|
+
const ids = data.closureRows.map((row) => row.id);
|
|
2644
|
+
if (seen.current?.source !== source) {
|
|
2645
|
+
seen.current = { source, ids: new Set(ids) };
|
|
2646
|
+
void sweep(true);
|
|
2647
|
+
} else if (ids.some((id2) => !seen.current.ids.has(id2))) {
|
|
2648
|
+
ids.forEach((id2) => seen.current.ids.add(id2));
|
|
2649
|
+
void sweep();
|
|
2650
|
+
}
|
|
2651
|
+
}, [data, source]);
|
|
2652
|
+
const session = data ? currentSession(data.rows, data.closureRows, data.reservation) : null;
|
|
2653
|
+
const entries = data?.entries ?? [];
|
|
2654
|
+
const sales = data?.sales ?? [];
|
|
2655
|
+
const expected = session ? deriveExpected({ session: { id: session.id, countedFloatMinor: session.counted_float_minor }, movements: entries, ledgerRowsBySession: ledgerRows(sales) }) : {};
|
|
2656
|
+
const now = /* @__PURE__ */ new Date();
|
|
2657
|
+
const [hour, minute] = String(expectedCloseTime ?? "").split(":").map(Number);
|
|
2658
|
+
const overdue = session?.status === "open" && !!expectedCloseTime && now.getTime() > new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute).getTime();
|
|
2659
|
+
const requireOpen = () => requireOpenSession(sessions ?? void 0, registerId, enabled);
|
|
2660
|
+
const requireSaleSession = async () => {
|
|
2661
|
+
const id2 = await requireOpen();
|
|
2662
|
+
return id2 === null ? null : { id: id2, sessions };
|
|
2663
|
+
};
|
|
2664
|
+
const live = () => {
|
|
2665
|
+
if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) throw new RegisterSessionRequiredError();
|
|
2666
|
+
return { sessions, movements, closures, orders, registerId, register };
|
|
2667
|
+
};
|
|
2668
|
+
const current = () => {
|
|
2669
|
+
if (!session) throw new RegisterSessionRequiredError();
|
|
2670
|
+
return session;
|
|
2671
|
+
};
|
|
2672
|
+
const refuseDuringTender = () => {
|
|
2673
|
+
if (tender.current) throw new RegisterTenderInProgressError();
|
|
2674
|
+
};
|
|
2675
|
+
const nameOf = (id2) => !id2 ? "" : id2 === actor.id ? actor.name : labels?.resolveCashierName?.(id2) ?? "";
|
|
2676
|
+
return {
|
|
2677
|
+
session,
|
|
2678
|
+
movements: entries,
|
|
2679
|
+
expected,
|
|
2680
|
+
salesCount: sales.length,
|
|
2681
|
+
overdue,
|
|
2682
|
+
/** A `closeSession` for this register is in flight, in any hook instance: the session can already be stored `closed` while its closure isn't written yet. */
|
|
2683
|
+
closing,
|
|
2684
|
+
lastClosure: [...data?.closureRows ?? []].sort((a, b) => b.closed_at.localeCompare(a.closed_at))[0] ?? null,
|
|
2685
|
+
lastClosed: (data?.rows ?? []).filter((row) => row.status === "closed").sort((a, b) => (b.closed_at_gmt ?? "").localeCompare(a.closed_at_gmt ?? ""))[0] ?? null,
|
|
2686
|
+
varianceThreshold: options.varianceThreshold,
|
|
2687
|
+
enabled,
|
|
2688
|
+
blind: options.blind ?? false,
|
|
2689
|
+
/** Pass straight to useSale's `session` option: `complete()` then stamps through `stampSession`. */
|
|
2690
|
+
saleSession: session && sessions && session.status !== "closed" ? { id: session.id, sessions } : void 0,
|
|
2691
|
+
/** The open session's id, `null` when sessions are off, else `RegisterSessionRequiredError`. Call it when tender starts and before a card terminal captures. */
|
|
2692
|
+
requireOpen,
|
|
2693
|
+
/** `requireOpen()`, returning `{ id, sessions }`: pass it to useSale's `startTender`, which pins it (the rendered `saleSession` can lag). */
|
|
2694
|
+
requireSaleSession,
|
|
2695
|
+
actions: {
|
|
2696
|
+
/**
|
|
2697
|
+
* Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
|
|
2698
|
+
* another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
|
|
2699
|
+
* while an earlier close hasn't finished: a new session would lock the till behind it.
|
|
2700
|
+
*/
|
|
2701
|
+
openSession: async (input) => {
|
|
2702
|
+
const key = registerId ?? "";
|
|
2703
|
+
if (openingByRegister.has(key)) throw new RegisterSessionAlreadyOpenError();
|
|
2704
|
+
const run = (async () => {
|
|
2705
|
+
const { sessions: sessions2, closures: closures2, registerId: registerId2, register: register2 } = live();
|
|
2706
|
+
const rows = await readFresh(sessions2, { selector: { register_id: registerId2 } });
|
|
2707
|
+
if (rows.some((row2) => row2.status !== "closed")) throw new RegisterSessionAlreadyOpenError();
|
|
2708
|
+
const closureRows = await readFresh(closures2, { selector: { register_id: registerId2 } });
|
|
2709
|
+
const reservation = reservationOf(await readRegister(register2), storeKey, registerId2);
|
|
2710
|
+
if (reservation && !reservation.applied || rows.some((row2) => unwritten(row2, closureRows))) {
|
|
2711
|
+
throw new RegisterCloseIncompleteError();
|
|
2712
|
+
}
|
|
2713
|
+
const [year, month, day] = businessDayOf((/* @__PURE__ */ new Date()).toISOString(), timezone).split("-").map(Number);
|
|
2714
|
+
const row = await openSession(sessions2, {
|
|
2715
|
+
...input,
|
|
2716
|
+
registerId: registerId2,
|
|
2717
|
+
openedBy: actor.id,
|
|
2718
|
+
businessDay: { year, month, day },
|
|
2719
|
+
storeKey: options.storeKey
|
|
2720
|
+
});
|
|
2721
|
+
recordRegisterFact({
|
|
2722
|
+
kind: "session-opened",
|
|
2723
|
+
actor,
|
|
2724
|
+
sessionId: row.id,
|
|
2725
|
+
registerId: row.register_id,
|
|
2726
|
+
amount: row.counted_float_minor,
|
|
2727
|
+
variance: row.opening_variance_minor
|
|
2728
|
+
});
|
|
2729
|
+
return row;
|
|
2730
|
+
})();
|
|
2731
|
+
openingByRegister.set(key, run);
|
|
2732
|
+
try {
|
|
2733
|
+
return await run;
|
|
2734
|
+
} finally {
|
|
2735
|
+
openingByRegister.delete(key);
|
|
2736
|
+
}
|
|
2737
|
+
},
|
|
2738
|
+
startCounting: async () => {
|
|
2739
|
+
refuseDuringTender();
|
|
2740
|
+
const row = await startCounting(live().sessions, current().id);
|
|
2741
|
+
recordRegisterFact({ kind: "counting-started", actor, sessionId: row.id, registerId: row.register_id });
|
|
2742
|
+
return row;
|
|
2743
|
+
},
|
|
2744
|
+
backToSelling: async () => {
|
|
2745
|
+
const row = await backToSelling(live().sessions, current().id);
|
|
2746
|
+
recordRegisterFact({ kind: "counting-abandoned", actor, sessionId: row.id, registerId: row.register_id });
|
|
2747
|
+
return row;
|
|
2748
|
+
},
|
|
2749
|
+
/**
|
|
2750
|
+
* `counted` maps each tender to its counted minor units. An interrupted close resumes with
|
|
2751
|
+
* the count persisted on the session, not the one passed to the retry (WCPOS uses the retry's).
|
|
2752
|
+
* Over `varianceThreshold` (`closeNeedsApproval`, as `RegisterCount`), a close without
|
|
2753
|
+
* `approvedBy` throws `RegisterApprovalRequiredError` before any write, blind or not; a
|
|
2754
|
+
* resumed close (the stored session already closed) isn't gated again. `approvedBy` reaches the Z
|
|
2755
|
+
* (`breakdowns.approved_by`), and `approvedByName` its `approved_by_name`.
|
|
2756
|
+
* One close per register runs at a time, in any hook instance: a call while one is in flight joins
|
|
2757
|
+
* it, getting its closure or error, and its own `counted`, `approvedBy` and `approvedByName` are ignored.
|
|
2758
|
+
*/
|
|
2759
|
+
closeSession: async (input) => {
|
|
2760
|
+
refuseDuringTender();
|
|
2761
|
+
const key = registerId ?? "";
|
|
2762
|
+
if (closingByRegister.has(key)) return closingByRegister.get(key);
|
|
2763
|
+
const run = (async () => {
|
|
2764
|
+
const { sessions: sessions2, movements: movements2, closures: closures2, orders: orders2, register: register2 } = live();
|
|
2765
|
+
const open = current();
|
|
2766
|
+
const stored = await readSession(sessions2, open.id);
|
|
2767
|
+
if (!stored) throw new RegisterSessionRequiredError();
|
|
2768
|
+
if (stored.status !== "closed" && !input.approvedBy) {
|
|
2769
|
+
const fresh = deriveExpected({
|
|
2770
|
+
session: { id: stored.id, countedFloatMinor: stored.counted_float_minor },
|
|
2771
|
+
movements: await readFresh(movements2, { selector: { session_id: stored.id } }),
|
|
2772
|
+
ledgerRowsBySession: ledgerRows(await readFresh(orders2, { selector: { sessionId: stored.id } }))
|
|
2773
|
+
});
|
|
2774
|
+
if (closeNeedsApproval(input.counted.cash ?? 0, fresh.cash ?? 0, options.varianceThreshold)) throw new RegisterApprovalRequiredError();
|
|
2775
|
+
}
|
|
2776
|
+
const closed = await closeSession(sessions2, open.id, { counted: input.counted, closedBy: actor.id, approvedBy: input.approvedBy, timezone });
|
|
2777
|
+
const approvedByName = input.approvedBy && closed.approved_by === input.approvedBy && input.approvedByName || nameOf(closed.approved_by);
|
|
2778
|
+
const { cash = 0, ...otherTenders } = closed.counted ?? input.counted;
|
|
2779
|
+
const closure = await writeClosure({
|
|
2780
|
+
closures: closures2,
|
|
2781
|
+
register: register2,
|
|
2782
|
+
storeKey,
|
|
2783
|
+
session: closed,
|
|
2784
|
+
counted: cash,
|
|
2785
|
+
otherTenders,
|
|
2786
|
+
timezone,
|
|
2787
|
+
softwareVersion: options.softwareVersion,
|
|
2788
|
+
movements: await readFresh(movements2, { selector: { session_id: closed.id } }),
|
|
2789
|
+
orders: await readFresh(orders2, { selector: { sessionId: closed.id } }),
|
|
2790
|
+
resolveCashierName: labels?.resolveCashierName,
|
|
2791
|
+
labels: {
|
|
2792
|
+
register_name: labels?.registerName ?? "",
|
|
2793
|
+
closed_by_name: nameOf(closed.closed_by),
|
|
2794
|
+
opened_by_name: nameOf(closed.opened_by),
|
|
2795
|
+
approved_by_name: approvedByName
|
|
2796
|
+
}
|
|
2797
|
+
});
|
|
2798
|
+
await sweep();
|
|
2799
|
+
if (!closedFacts.has(closure.id)) {
|
|
2800
|
+
closedFacts.add(closure.id);
|
|
2801
|
+
if (closed.approved_by) {
|
|
2802
|
+
recordRegisterFact({ kind: "approval-granted", actor, sessionId: closed.id, registerId: closed.register_id, approvedBy: closed.approved_by });
|
|
2803
|
+
}
|
|
2804
|
+
recordRegisterFact({
|
|
2805
|
+
kind: "session-closed",
|
|
2806
|
+
actor,
|
|
2807
|
+
sessionId: closed.id,
|
|
2808
|
+
registerId: closed.register_id,
|
|
2809
|
+
closureId: closure.id,
|
|
2810
|
+
number: closure.number,
|
|
2811
|
+
counted: closure.counted,
|
|
2812
|
+
variance: closure.variance
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2815
|
+
return closure;
|
|
2816
|
+
})();
|
|
2817
|
+
closingByRegister.set(key, run);
|
|
2818
|
+
notifyClosing();
|
|
2819
|
+
try {
|
|
2820
|
+
return await run;
|
|
2821
|
+
} finally {
|
|
2822
|
+
closingByRegister.delete(key);
|
|
2823
|
+
notifyClosing();
|
|
2824
|
+
}
|
|
2825
|
+
},
|
|
2826
|
+
recordMovement: async (input) => {
|
|
2827
|
+
const { sessions: sessions2, movements: movements2, closures: closures2, registerId: registerId2 } = live();
|
|
2828
|
+
const sessionId = await requireOpen();
|
|
2829
|
+
const row = await recordMovement(sessions2, movements2, closures2, { ...input, sessionId, actor: actor.id });
|
|
2830
|
+
recordRegisterFact({
|
|
2831
|
+
kind: "movement-recorded",
|
|
2832
|
+
actor,
|
|
2833
|
+
sessionId: row.session_id,
|
|
2834
|
+
registerId: registerId2,
|
|
2835
|
+
movementId: row.id,
|
|
2836
|
+
movementType: row.type,
|
|
2837
|
+
amount: row.amountMinor
|
|
2838
|
+
});
|
|
2839
|
+
return row;
|
|
2840
|
+
},
|
|
2841
|
+
voidMovement: async (id2) => {
|
|
2842
|
+
const { sessions: sessions2, movements: movements2, closures: closures2, registerId: registerId2 } = live();
|
|
2843
|
+
await requireOpen();
|
|
2844
|
+
const row = await voidMovement(sessions2, movements2, id2, actor.id, closures2);
|
|
2845
|
+
recordRegisterFact({
|
|
2846
|
+
kind: "movement-voided",
|
|
2847
|
+
actor,
|
|
2848
|
+
sessionId: row.session_id,
|
|
2849
|
+
registerId: registerId2,
|
|
2850
|
+
movementId: row.id,
|
|
2851
|
+
movementType: row.type,
|
|
2852
|
+
amount: row.amountMinor,
|
|
2853
|
+
voids: id2
|
|
2854
|
+
});
|
|
2855
|
+
return row;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
// src/sale/cart.ts
|
|
2862
|
+
import { resolvePrice as resolvePrice2 } from "@tallyui/core";
|
|
2863
|
+
var CartError = class extends Error {
|
|
2864
|
+
};
|
|
2865
|
+
function addEntryToCart(builder, entry, traits, currency) {
|
|
2866
|
+
const { product, variant } = entry;
|
|
2867
|
+
const existing = builder.getSnapshot().lineItems.find((line) => line.variantId === variant.id);
|
|
2868
|
+
if (existing) {
|
|
2869
|
+
builder.updateQuantity(existing.id, existing.quantity + 1);
|
|
2870
|
+
return existing.id;
|
|
2871
|
+
}
|
|
2872
|
+
const name = traits.getName(product) + (traits.getVariantCount(product) > 1 ? ` \xB7 ${variant.title}` : "");
|
|
2873
|
+
const unitPrice = resolvePrice2(variant.prices, currency)?.current;
|
|
2874
|
+
if (!unitPrice) throw new CartError(`No ${currency} price for ${name}`);
|
|
2875
|
+
return builder.addLine({
|
|
2876
|
+
productId: traits.getId(product),
|
|
2877
|
+
variantId: variant.id,
|
|
2878
|
+
name,
|
|
2879
|
+
sku: variant.sku ?? "",
|
|
2880
|
+
imageUrl: traits.getImageUrl?.(product),
|
|
2881
|
+
unitPrice
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
// src/sale/use-sale.ts
|
|
2886
|
+
var saleLogger = createLogger("sale");
|
|
2887
|
+
var DISCOUNTS_UNSUPPORTED = "finalize: discounts are not supported by the server yet (order.create v2)";
|
|
2888
|
+
var SALE_SAVING = "This sale is being saved. Retry to finish it.";
|
|
2889
|
+
var HUNG_SAVE_CHECK_MS = 5e3;
|
|
2890
|
+
function useSale(settings, opts) {
|
|
2891
|
+
const taxContext = useTax();
|
|
2892
|
+
const madeWith = useRef3({ taxContext, currency: settings.currency });
|
|
2893
|
+
const [builder, setBuilder] = useState3(() => createOrderBuilder({ currency: settings.currency, taxContext }));
|
|
2894
|
+
const [order, setOrder] = useState3(() => builder.getSnapshot());
|
|
2895
|
+
const [stage, setStage] = useState3({ kind: "cart" });
|
|
2896
|
+
const [error, setError] = useState3(null);
|
|
2897
|
+
const pending = useRef3(null);
|
|
2898
|
+
const [saving, setSaving] = useState3(false);
|
|
2899
|
+
const tenderSession = useRef3(null);
|
|
2900
|
+
const confirmed = useRef3(null);
|
|
2901
|
+
const [canContinue, setCanContinue] = useState3(false);
|
|
2902
|
+
const attempts = useRef3(0);
|
|
2903
|
+
const hungSaveTimer = useRef3(null);
|
|
2904
|
+
const checking = useRef3(null);
|
|
2905
|
+
function clearHungSaveTimer() {
|
|
2906
|
+
if (hungSaveTimer.current !== null) {
|
|
2907
|
+
clearInterval(hungSaveTimer.current);
|
|
2908
|
+
hungSaveTimer.current = null;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
function confirm(completion) {
|
|
2912
|
+
confirmed.current = completion;
|
|
2913
|
+
setCanContinue(!!completion);
|
|
2914
|
+
clearHungSaveTimer();
|
|
2915
|
+
}
|
|
2916
|
+
const inFlight = useRef3(null);
|
|
2917
|
+
const stageNow = useRef3(stage);
|
|
2918
|
+
stageNow.current = stage;
|
|
2919
|
+
function locked() {
|
|
2920
|
+
const active = !!(inFlight.current || pending.current);
|
|
2921
|
+
if (active) setError(SALE_SAVING);
|
|
2922
|
+
return active;
|
|
2923
|
+
}
|
|
2924
|
+
useEffect3(() => {
|
|
2925
|
+
const subscription = builder.order$.subscribe(setOrder);
|
|
2926
|
+
return () => subscription.unsubscribe();
|
|
2927
|
+
}, [builder]);
|
|
2928
|
+
const idle2 = stage.kind === "cart" && !order.lineItems.length;
|
|
2929
|
+
useEffect3(() => {
|
|
2930
|
+
if (idle2 && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) result.newSale();
|
|
2931
|
+
});
|
|
2932
|
+
useEffect3(() => () => {
|
|
2933
|
+
clearHungSaveTimer();
|
|
2934
|
+
if (pending.current || inFlight.current) {
|
|
2935
|
+
saleLogger.error(
|
|
2936
|
+
"useSale unmounted with a save pending or in flight",
|
|
2937
|
+
{ orderId: pending.current?.posOrder.id, stage: stageNow.current.kind }
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
}, []);
|
|
2941
|
+
function setTender(tender) {
|
|
2942
|
+
if (locked()) return;
|
|
2943
|
+
const previous = builder.getSnapshot().payments[0];
|
|
2944
|
+
if (previous) builder.removePayment(previous.id);
|
|
2945
|
+
if (tender) builder.addPayment(tender);
|
|
2946
|
+
setError(null);
|
|
2947
|
+
}
|
|
2948
|
+
function checkStored(completion) {
|
|
2949
|
+
const attempt2 = attempts.current;
|
|
2950
|
+
const isStored = opts.isStored;
|
|
2951
|
+
if (!isStored) return;
|
|
2952
|
+
checking.current = completion;
|
|
2953
|
+
void Promise.resolve(completion.posOrder).then(isStored).catch(() => false).then((stored) => {
|
|
2954
|
+
if (stored === true && attempts.current === attempt2 && pending.current === completion) confirm(completion);
|
|
2955
|
+
}).finally(() => {
|
|
2956
|
+
if (checking.current === completion) checking.current = null;
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
async function deliver(completion) {
|
|
2960
|
+
clearHungSaveTimer();
|
|
2961
|
+
const timer = opts.isStored ? setInterval(() => {
|
|
2962
|
+
if (checking.current === completion) return;
|
|
2963
|
+
if (pending.current === completion && inFlight.current && confirmed.current !== completion) checkStored(completion);
|
|
2964
|
+
else if (hungSaveTimer.current === timer) clearHungSaveTimer();
|
|
2965
|
+
}, opts.hungSaveCheckMs ?? HUNG_SAVE_CHECK_MS) : null;
|
|
2966
|
+
hungSaveTimer.current = timer;
|
|
2967
|
+
try {
|
|
2968
|
+
await opts.onSaleCompleted?.(completion.posOrder);
|
|
2969
|
+
} catch (error2) {
|
|
2970
|
+
if (hungSaveTimer.current === timer) clearHungSaveTimer();
|
|
2971
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2972
|
+
if (confirmed.current !== completion) {
|
|
2973
|
+
saleLogger.error("onSaleCompleted failed", { orderId: completion.posOrder.id, error: message });
|
|
2974
|
+
}
|
|
2975
|
+
if (pending.current === completion) {
|
|
2976
|
+
setError(`The sale could not be saved: ${message}`);
|
|
2977
|
+
checkStored(completion);
|
|
2978
|
+
}
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
if (hungSaveTimer.current === timer) clearHungSaveTimer();
|
|
2982
|
+
if (pending.current !== completion) return;
|
|
2983
|
+
pending.current = null;
|
|
2984
|
+
confirm(null);
|
|
2985
|
+
setSaving(false);
|
|
2986
|
+
setError(null);
|
|
2987
|
+
stageNow.current = { kind: "receipt", order: completion.order, posOrder: completion.posOrder };
|
|
2988
|
+
setStage(stageNow.current);
|
|
2989
|
+
}
|
|
2990
|
+
function once(attempt2) {
|
|
2991
|
+
if (!inFlight.current && stageNow.current.kind !== "receipt") {
|
|
2992
|
+
const mine = attempt2().finally(() => {
|
|
2993
|
+
if (inFlight.current === mine) inFlight.current = null;
|
|
2994
|
+
});
|
|
2995
|
+
inFlight.current = mine;
|
|
2996
|
+
}
|
|
2997
|
+
return inFlight.current ?? Promise.resolve();
|
|
2998
|
+
}
|
|
2999
|
+
const result = {
|
|
3000
|
+
order,
|
|
3001
|
+
stage,
|
|
3002
|
+
error,
|
|
3003
|
+
idle: idle2,
|
|
3004
|
+
/** A completion is pending (see `complete()`): the sale is locked, and the UI offers Retry. */
|
|
3005
|
+
saving,
|
|
3006
|
+
/** The failed save's order is confirmed stored (see `isStored`): the UI also offers Continue (`continueSale()`). */
|
|
3007
|
+
canContinue,
|
|
3008
|
+
add(entry, traits) {
|
|
3009
|
+
if (locked()) return;
|
|
3010
|
+
try {
|
|
3011
|
+
addEntryToCart(builder, entry, traits, madeWith.current.currency);
|
|
3012
|
+
setError(null);
|
|
3013
|
+
} catch (error2) {
|
|
3014
|
+
if (!(error2 instanceof CartError)) throw error2;
|
|
3015
|
+
setError(error2.message);
|
|
3016
|
+
}
|
|
3017
|
+
},
|
|
3018
|
+
setQuantity(lineId, quantity) {
|
|
3019
|
+
if (!locked()) builder.updateQuantity(lineId, quantity);
|
|
3020
|
+
},
|
|
3021
|
+
remove(lineId) {
|
|
3022
|
+
if (!locked()) builder.removeItem(lineId);
|
|
3023
|
+
},
|
|
3024
|
+
/** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
|
|
3025
|
+
applyDiscount(lineId, discount) {
|
|
3026
|
+
if (locked()) return SALE_SAVING;
|
|
3027
|
+
if ((opts.capabilities?.orderCreate ?? 1) < 2) return DISCOUNTS_UNSUPPORTED;
|
|
3028
|
+
const applied = (snapshot) => lineId === null ? snapshot.discounts : snapshot.lineItems.find((line) => line.id === lineId)?.discounts ?? [];
|
|
3029
|
+
const before = new Set(applied(builder.getSnapshot()).map((entry) => entry.id));
|
|
3030
|
+
if (lineId === null) builder.applyOrderDiscount(discount);
|
|
3031
|
+
else builder.applyLineDiscount(lineId, discount);
|
|
3032
|
+
const added = applied(builder.getSnapshot()).find((entry) => !before.has(entry.id));
|
|
3033
|
+
if (added?.type === "fixed" && added.amountMinor < added.value) {
|
|
3034
|
+
builder.removeDiscount(added.id);
|
|
3035
|
+
return `The discount is more than the ${lineId === null ? "order" : "line"}`;
|
|
3036
|
+
}
|
|
3037
|
+
return null;
|
|
3038
|
+
},
|
|
3039
|
+
removeDiscount(id2) {
|
|
3040
|
+
if (!locked()) builder.removeDiscount(id2);
|
|
3041
|
+
},
|
|
3042
|
+
/**
|
|
3043
|
+
* Pins `options.session` for this tender when given, else the rendered `session` option. Pass the
|
|
3044
|
+
* session `useRegisterSession`'s `requireSaleSession()` returned: the rendered one can lag a session
|
|
3045
|
+
* opened just before (#170 race). A repeat call mid-tender keeps the first pin.
|
|
3046
|
+
*/
|
|
3047
|
+
startTender(method, options) {
|
|
3048
|
+
if (locked()) return;
|
|
3049
|
+
const current = builder.getSnapshot();
|
|
3050
|
+
if (!current.lineItems.length) return;
|
|
3051
|
+
tenderSession.current ??= { session: options?.session ?? opts.session };
|
|
3052
|
+
setTender(method === "external" ? { method, amountMinor: current.totalMinor } : null);
|
|
3053
|
+
setStage({ kind: "tender", method });
|
|
3054
|
+
},
|
|
3055
|
+
setTender,
|
|
3056
|
+
cancelTender() {
|
|
3057
|
+
if (locked()) return;
|
|
3058
|
+
setTender(null);
|
|
3059
|
+
tenderSession.current = null;
|
|
3060
|
+
setStage({ kind: "cart" });
|
|
3061
|
+
},
|
|
3062
|
+
/**
|
|
3063
|
+
* Finalizes the tender, stamps the session (see `session`), hands the order to `onSaleCompleted`,
|
|
3064
|
+
* then shows the receipt. `saving` turns true and the sale locks (every change sets SALE_SAVING)
|
|
3065
|
+
* from this call's entry, not only once the order is built; a refused finalize unlocks it again,
|
|
3066
|
+
* with the refusal's error. Idempotent for one tender attempt (DECISIONS, ADR-052): once the order
|
|
3067
|
+
* is built, that order is the sale, kept as the pending completion, because `onSaleCompleted` may
|
|
3068
|
+
* already have stored it before failing. If `onSaleCompleted` throws, the error is set and the tender stays;
|
|
3069
|
+
* calling `complete()` again reuses the pending completion exactly (the same `id`, `commandId`
|
|
3070
|
+
* and `createdAt`, no new stamp and no second late-sale fact) and hands it to `onSaleCompleted`
|
|
3071
|
+
* again, so that must accept an order it already stored (as `useOrderOutbox.record` does). The
|
|
3072
|
+
* pending completion is cleared once `onSaleCompleted` resolves, or by `newSale()`. A call while
|
|
3073
|
+
* another is in flight returns that call's promise, and a call on the receipt does nothing.
|
|
3074
|
+
*/
|
|
3075
|
+
complete: () => once(async () => {
|
|
3076
|
+
attempts.current++;
|
|
3077
|
+
confirm(null);
|
|
3078
|
+
if (pending.current) return deliver(pending.current);
|
|
3079
|
+
setSaving(true);
|
|
3080
|
+
const current = builder.getSnapshot();
|
|
3081
|
+
let posOrder;
|
|
3082
|
+
try {
|
|
3083
|
+
posOrder = finalizeOrder(current, { registerId: opts.registerId, cashierRef: opts.cashierRef, capabilities: opts.capabilities });
|
|
3084
|
+
} catch (error2) {
|
|
3085
|
+
setSaving(false);
|
|
3086
|
+
setError(error2.message);
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
const session = tenderSession.current?.session ?? opts.session;
|
|
3090
|
+
if (tenderSession.current && !tenderSession.current.session && session) {
|
|
3091
|
+
try {
|
|
3092
|
+
saleLogger.warn(
|
|
3093
|
+
"the tender started before its session rendered; pass the confirmed session to startTender",
|
|
3094
|
+
{ orderId: posOrder.id, sessionId: session.id }
|
|
3095
|
+
);
|
|
3096
|
+
} catch {
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
if (session) {
|
|
3100
|
+
try {
|
|
3101
|
+
posOrder = await stampSession(posOrder, session.id, session.sessions);
|
|
3102
|
+
} catch {
|
|
3103
|
+
const { sessionId: _unstamped, ...unstamped } = posOrder;
|
|
3104
|
+
posOrder = { ...unstamped, lateSessionId: session.id };
|
|
3105
|
+
try {
|
|
3106
|
+
recordRegisterFact({
|
|
3107
|
+
kind: "late-sale",
|
|
3108
|
+
orderId: posOrder.id,
|
|
3109
|
+
sessionId: session.id,
|
|
3110
|
+
registerId: opts.registerId,
|
|
3111
|
+
actor: { id: opts.cashierRef, name: "" }
|
|
3112
|
+
});
|
|
3113
|
+
} catch {
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
pending.current = { order: current, posOrder };
|
|
3118
|
+
return deliver(pending.current);
|
|
3119
|
+
}),
|
|
3120
|
+
/**
|
|
3121
|
+
* Starts a new, empty sale. Refused with SALE_SAVING, changing nothing, while a save is still
|
|
3122
|
+
* building or stamping (nothing yet to ask `isStored` about), and while a pending completion
|
|
3123
|
+
* exists and isn't confirmed stored — the ways out are Retry (`complete()`) or Continue, once
|
|
3124
|
+
* `canContinue` (the Front desk, 2026-09-27; #149 review). So an app that doesn't pass `isStored`
|
|
3125
|
+
* gets Retry only after a failed save. A refusal while a save is still delivering its order asks
|
|
3126
|
+
* `isStored` afresh, so a hung save whose order is stored can still offer Continue. Past those, it
|
|
3127
|
+
* abandons a confirmed pending completion (never handed to `onSaleCompleted` again: it's stored; a
|
|
3128
|
+
* save still in flight carries on in the background, a throw logged at error) and starts the new
|
|
3129
|
+
* sale outright — from the receipt, or an idle cart, there's nothing pending to abandon. Abandoning
|
|
3130
|
+
* clears the screen, never the record (the Front desk, 2026-09-25). `newSale()` never deletes,
|
|
3131
|
+
* updates or requeues `pos_orders` itself.
|
|
3132
|
+
*/
|
|
3133
|
+
newSale() {
|
|
3134
|
+
if (inFlight.current && !pending.current) return setError(SALE_SAVING);
|
|
3135
|
+
if (pending.current && confirmed.current !== pending.current) {
|
|
3136
|
+
if (inFlight.current) checkStored(pending.current);
|
|
3137
|
+
return setError(SALE_SAVING);
|
|
3138
|
+
}
|
|
3139
|
+
confirm(null);
|
|
3140
|
+
pending.current = null;
|
|
3141
|
+
inFlight.current = null;
|
|
3142
|
+
tenderSession.current = null;
|
|
3143
|
+
setSaving(false);
|
|
3144
|
+
madeWith.current = { taxContext, currency: settings.currency };
|
|
3145
|
+
const next = createOrderBuilder({ currency: settings.currency, taxContext });
|
|
3146
|
+
setBuilder(next);
|
|
3147
|
+
setOrder(next.getSnapshot());
|
|
3148
|
+
setStage({ kind: "cart" });
|
|
3149
|
+
setError(null);
|
|
3150
|
+
},
|
|
3151
|
+
/**
|
|
3152
|
+
* Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
|
|
3153
|
+
* The order stays pending in the outbox, which will send it; it isn't handed to `onSaleCompleted` again, and
|
|
3154
|
+
* its receipt is not shown. Otherwise does nothing.
|
|
3155
|
+
*/
|
|
3156
|
+
continueSale() {
|
|
3157
|
+
if (pending.current && confirmed.current === pending.current) result.newSale();
|
|
3158
|
+
}
|
|
3159
|
+
};
|
|
3160
|
+
return result;
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
// src/sale/catalogue.ts
|
|
3164
|
+
import { findVariantByCode, formatMoney as formatMoney2, resolvePrice as resolvePrice3 } from "@tallyui/core";
|
|
3165
|
+
function catalogueEntries(products, traits) {
|
|
3166
|
+
return products.flatMap((product) => traits.getVariants(product).map((variant) => ({ product, variant })));
|
|
3167
|
+
}
|
|
3168
|
+
function findEntryByCode(entries, code) {
|
|
3169
|
+
const variant = findVariantByCode(entries.map((entry) => entry.variant), code);
|
|
3170
|
+
return variant ? entries.find((entry) => entry.variant === variant) : void 0;
|
|
3171
|
+
}
|
|
3172
|
+
function variantPriceLabel(variant, currency, locale) {
|
|
3173
|
+
const price = resolvePrice3(variant.prices, currency);
|
|
3174
|
+
return price ? formatMoney2(price.current, locale) : void 0;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
// src/outbox/http-transport.ts
|
|
3178
|
+
import { COMMANDS_PATH, PROTOCOL_HEADER, PROTOCOL_VERSION, isCommandBatchResponse } from "@tallyui/core";
|
|
3179
|
+
var PERMANENT_STATUSES = /* @__PURE__ */ new Set([400, 403, 413, 415, 422]);
|
|
3180
|
+
function createHttpCommandTransport(options) {
|
|
3181
|
+
const fetch = options.fetch ?? globalThis.fetch;
|
|
3182
|
+
let end = options.baseUrl.length;
|
|
3183
|
+
while (end > 0 && options.baseUrl[end - 1] === "/") {
|
|
3184
|
+
end--;
|
|
3185
|
+
}
|
|
3186
|
+
const baseUrl = options.baseUrl.slice(0, end);
|
|
3187
|
+
return {
|
|
3188
|
+
async send(batch) {
|
|
3189
|
+
const controller = new AbortController();
|
|
3190
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 3e4);
|
|
3191
|
+
try {
|
|
3192
|
+
const response = await fetch(`${baseUrl}${COMMANDS_PATH}`, {
|
|
3193
|
+
method: "POST",
|
|
3194
|
+
headers: {
|
|
3195
|
+
"Content-Type": "application/json",
|
|
3196
|
+
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
3197
|
+
...await options.getHeaders()
|
|
3198
|
+
},
|
|
3199
|
+
body: JSON.stringify({ commands: batch }),
|
|
3200
|
+
signal: controller.signal
|
|
3201
|
+
});
|
|
3202
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
3203
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
3204
|
+
const retry = (reason) => ({
|
|
3205
|
+
kind: "retry",
|
|
3206
|
+
reason,
|
|
3207
|
+
...Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1e3 } : {}
|
|
3208
|
+
});
|
|
3209
|
+
if (response.status === 401) return { kind: "unauthorized" };
|
|
3210
|
+
if (PERMANENT_STATUSES.has(response.status)) {
|
|
3211
|
+
let reason = `status_${response.status}`;
|
|
3212
|
+
try {
|
|
3213
|
+
const body2 = await response.json();
|
|
3214
|
+
if (typeof body2?.message === "string") reason = body2.message;
|
|
3215
|
+
else if (typeof body2?.code === "string") reason = body2.code;
|
|
3216
|
+
} catch {
|
|
3217
|
+
}
|
|
3218
|
+
return { kind: "refused", status: response.status, reason };
|
|
3219
|
+
}
|
|
3220
|
+
if (response.status !== 200) return retry(`status_${response.status}`);
|
|
3221
|
+
let body;
|
|
3222
|
+
try {
|
|
3223
|
+
body = await response.json();
|
|
3224
|
+
} catch {
|
|
3225
|
+
return retry(controller.signal.aborted ? "network" : "bad_body");
|
|
3226
|
+
}
|
|
3227
|
+
return isCommandBatchResponse(body) ? { kind: "results", results: body.results } : retry("bad_body");
|
|
3228
|
+
} catch {
|
|
3229
|
+
return { kind: "retry", reason: "network" };
|
|
3230
|
+
} finally {
|
|
3231
|
+
clearTimeout(timeout);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
// src/outbox/order-outbox.ts
|
|
3238
|
+
import { BehaviorSubject as BehaviorSubject3 } from "rxjs";
|
|
3239
|
+
var AUTH_FAILURES_BEFORE_PROMPT = 3;
|
|
3240
|
+
var NOT_REQUEUEABLE = /* @__PURE__ */ new Set(["idempotency_mismatch"]);
|
|
3241
|
+
function createOrderOutbox(options) {
|
|
3242
|
+
const { collection, transport, deviceId } = options;
|
|
3243
|
+
const batchSize = Math.min(options.batchSize ?? 10, 10);
|
|
3244
|
+
const initialBackoff = options.initialBackoffMs ?? 1e3;
|
|
3245
|
+
const maxBackoff = options.maxBackoffMs ?? 6e4;
|
|
3246
|
+
const random = options.random ?? Math.random;
|
|
3247
|
+
const now = options.now ?? Date.now;
|
|
3248
|
+
const state$ = new BehaviorSubject3({ pending: 0, sending: false });
|
|
3249
|
+
const attempts = /* @__PURE__ */ new Map();
|
|
3250
|
+
let backoff = initialBackoff;
|
|
3251
|
+
let unauthorizedSinceAccepted = 0;
|
|
3252
|
+
let running;
|
|
3253
|
+
let timer;
|
|
3254
|
+
let subscription;
|
|
3255
|
+
let stopped = false;
|
|
3256
|
+
let insertedDuringRun = false;
|
|
3257
|
+
let stoppedDuringRun = false;
|
|
3258
|
+
async function updateState(patch = {}) {
|
|
3259
|
+
const pending = await countFresh(collection, { syncStatus: "pending" });
|
|
3260
|
+
state$.next({ ...state$.value, ...patch, pending });
|
|
3261
|
+
}
|
|
3262
|
+
function scheduleRetry(reason, retryAfterMs = 0) {
|
|
3263
|
+
state$.next({ ...state$.value, lastRetryReason: reason });
|
|
3264
|
+
if (stopped) {
|
|
3265
|
+
stoppedDuringRun = true;
|
|
3266
|
+
return;
|
|
3267
|
+
}
|
|
3268
|
+
const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
3269
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
3270
|
+
timer = setTimeout(() => {
|
|
3271
|
+
timer = void 0;
|
|
3272
|
+
flush().catch(() => {
|
|
3273
|
+
});
|
|
3274
|
+
}, delay);
|
|
3275
|
+
state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
|
|
3276
|
+
}
|
|
3277
|
+
async function run() {
|
|
3278
|
+
stoppedDuringRun = false;
|
|
3279
|
+
while (!stopped) try {
|
|
3280
|
+
await updateState({ sending: true, nextAttemptAt: void 0 });
|
|
3281
|
+
insertedDuringRun = false;
|
|
3282
|
+
const orders = await readFresh(collection, {
|
|
3283
|
+
selector: { syncStatus: "pending" },
|
|
3284
|
+
sort: [{ createdAt: "asc" }],
|
|
3285
|
+
limit: batchSize
|
|
3286
|
+
});
|
|
3287
|
+
if (!orders.length || stopped) {
|
|
3288
|
+
stoppedDuringRun = stopped;
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
const batch = orders.map((order) => {
|
|
3292
|
+
const attempt2 = (attempts.get(order.commandId) ?? 0) + 1;
|
|
3293
|
+
attempts.set(order.commandId, attempt2);
|
|
3294
|
+
return toOrderCreateEnvelope(order, deviceId, attempt2);
|
|
3295
|
+
});
|
|
3296
|
+
const outcome = await transport.send(batch);
|
|
3297
|
+
if (outcome.kind === "retry") {
|
|
3298
|
+
return scheduleRetry(outcome.reason, outcome.retryAfterMs);
|
|
3299
|
+
}
|
|
3300
|
+
if (outcome.kind === "unauthorized") {
|
|
3301
|
+
unauthorizedSinceAccepted++;
|
|
3302
|
+
if (unauthorizedSinceAccepted < AUTH_FAILURES_BEFORE_PROMPT) return scheduleRetry("unauthorized");
|
|
3303
|
+
state$.next({
|
|
3304
|
+
...state$.value,
|
|
3305
|
+
authRequired: true,
|
|
3306
|
+
lastRetryReason: "unauthorized",
|
|
3307
|
+
sending: false,
|
|
3308
|
+
nextAttemptAt: void 0
|
|
3309
|
+
});
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
if (outcome.kind === "refused") {
|
|
3313
|
+
unauthorizedSinceAccepted = 0;
|
|
3314
|
+
state$.next({
|
|
3315
|
+
...state$.value,
|
|
3316
|
+
refused: { status: outcome.status, reason: outcome.reason },
|
|
3317
|
+
authRequired: false,
|
|
3318
|
+
lastRetryReason: "refused",
|
|
3319
|
+
sending: false,
|
|
3320
|
+
nextAttemptAt: void 0
|
|
3321
|
+
});
|
|
3322
|
+
return;
|
|
3323
|
+
}
|
|
3324
|
+
unauthorizedSinceAccepted = 0;
|
|
3325
|
+
state$.next({ ...state$.value, authRequired: false, refused: void 0 });
|
|
3326
|
+
let progressed = false;
|
|
3327
|
+
for (const order of orders) {
|
|
3328
|
+
const result = outcome.results.find((entry) => entry.id === order.commandId);
|
|
3329
|
+
if (!result) continue;
|
|
3330
|
+
const [stored] = await collection.storageInstance.findDocumentsById([order.id], false);
|
|
3331
|
+
if (stored?.syncStatus !== "pending" || stored.commandId !== order.commandId) continue;
|
|
3332
|
+
const current = await collection.findOne(order.id).exec();
|
|
3333
|
+
if (!current) continue;
|
|
3334
|
+
const updatedAt = new Date(now()).toISOString();
|
|
3335
|
+
await current.incrementalPatch(result.status === "rejected" ? { syncStatus: "rejected", error: result.error, updatedAt } : {
|
|
3336
|
+
syncStatus: "applied",
|
|
3337
|
+
serverRefs: result.serverRefs,
|
|
3338
|
+
...result.warnings ? { warnings: result.warnings } : {},
|
|
3339
|
+
updatedAt
|
|
3340
|
+
});
|
|
3341
|
+
attempts.delete(order.commandId);
|
|
3342
|
+
progressed = true;
|
|
3343
|
+
await updateState();
|
|
3344
|
+
}
|
|
3345
|
+
if (!progressed) return scheduleRetry("no_progress");
|
|
3346
|
+
backoff = initialBackoff;
|
|
3347
|
+
state$.next({ ...state$.value, lastRetryReason: void 0 });
|
|
3348
|
+
} catch (error) {
|
|
3349
|
+
scheduleRetry("error: " + (error instanceof Error ? error.message : String(error)));
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3352
|
+
stoppedDuringRun = true;
|
|
3353
|
+
}
|
|
3354
|
+
function flush() {
|
|
3355
|
+
if (running) return running;
|
|
3356
|
+
stopped = false;
|
|
3357
|
+
clearTimeout(timer);
|
|
3358
|
+
timer = void 0;
|
|
3359
|
+
running = Promise.resolve().then(run).finally(async () => {
|
|
3360
|
+
try {
|
|
3361
|
+
await updateState({ sending: false });
|
|
3362
|
+
} catch {
|
|
3363
|
+
}
|
|
3364
|
+
running = void 0;
|
|
3365
|
+
if ((insertedDuringRun || stoppedDuringRun) && !stopped && timer === void 0) flush().catch(() => {
|
|
3366
|
+
});
|
|
3367
|
+
});
|
|
3368
|
+
return running;
|
|
3369
|
+
}
|
|
3370
|
+
updateState().catch(() => {
|
|
3371
|
+
});
|
|
3372
|
+
return {
|
|
3373
|
+
state$,
|
|
3374
|
+
flush,
|
|
3375
|
+
async requeue(orderIds) {
|
|
3376
|
+
const orders = (await readFresh(collection, { selector: {
|
|
3377
|
+
syncStatus: "rejected",
|
|
3378
|
+
...orderIds ? { id: { $in: orderIds } } : {}
|
|
3379
|
+
} })).filter((order) => !NOT_REQUEUEABLE.has(order.error?.code ?? ""));
|
|
3380
|
+
let count = 0;
|
|
3381
|
+
for (const order of orders) {
|
|
3382
|
+
const oldCommandId = order.commandId;
|
|
3383
|
+
let changed = false;
|
|
3384
|
+
await (await collection.findOne(order.id).exec())?.incrementalModify((data) => {
|
|
3385
|
+
changed = data.syncStatus === "rejected" && !NOT_REQUEUEABLE.has(data.error?.code ?? "");
|
|
3386
|
+
if (!changed) return data;
|
|
3387
|
+
data.syncStatus = "pending";
|
|
3388
|
+
delete data.error;
|
|
3389
|
+
data.commandId = uuidv7();
|
|
3390
|
+
data.updatedAt = new Date(now()).toISOString();
|
|
3391
|
+
return data;
|
|
3392
|
+
});
|
|
3393
|
+
if (changed) {
|
|
3394
|
+
count++;
|
|
3395
|
+
attempts.delete(oldCommandId);
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
await updateState();
|
|
3399
|
+
if (count && !stopped) flush().catch(() => {
|
|
3400
|
+
});
|
|
3401
|
+
return count;
|
|
3402
|
+
},
|
|
3403
|
+
start() {
|
|
3404
|
+
stopped = false;
|
|
3405
|
+
if (!subscription) subscription = collection.$.subscribe((event) => {
|
|
3406
|
+
if (event.documentData?.syncStatus === "pending" && (event.operation === "INSERT" || event.operation === "UPDATE")) {
|
|
3407
|
+
insertedDuringRun = true;
|
|
3408
|
+
flush().catch(() => {
|
|
3409
|
+
});
|
|
3410
|
+
}
|
|
3411
|
+
});
|
|
3412
|
+
if (!stopped) flush().catch(() => {
|
|
3413
|
+
});
|
|
3414
|
+
},
|
|
3415
|
+
stop() {
|
|
3416
|
+
stopped = true;
|
|
3417
|
+
clearTimeout(timer);
|
|
3418
|
+
timer = void 0;
|
|
3419
|
+
subscription?.unsubscribe();
|
|
3420
|
+
subscription = void 0;
|
|
3421
|
+
state$.next({ ...state$.value, nextAttemptAt: void 0 });
|
|
3422
|
+
}
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
// src/outbox/use-order-outbox.ts
|
|
3427
|
+
import { useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
|
|
3428
|
+
var idle = { pending: 0, sending: false };
|
|
3429
|
+
var outboxLogger = createLogger("outbox");
|
|
3430
|
+
function useOrderOutbox(options) {
|
|
3431
|
+
const { storeKey, deviceId } = options;
|
|
3432
|
+
const latest = useRef4(options);
|
|
3433
|
+
latest.current = options;
|
|
3434
|
+
const current = useRef4(null);
|
|
3435
|
+
const openingError = useRef4(null);
|
|
3436
|
+
const loggedMismatches = useRef4(/* @__PURE__ */ new Set());
|
|
3437
|
+
const [orders, setOrders] = useState4(null);
|
|
3438
|
+
const [state, setState] = useState4(idle);
|
|
3439
|
+
const [recent, setRecent] = useState4([]);
|
|
3440
|
+
const savesInFlight = useRef4(0);
|
|
3441
|
+
const [savesInFlightCount, setSavesInFlightCount] = useState4(0);
|
|
3442
|
+
useEffect4(() => {
|
|
3443
|
+
let active = true;
|
|
3444
|
+
let dispose;
|
|
3445
|
+
openingError.current = null;
|
|
3446
|
+
setOrders(null);
|
|
3447
|
+
setState(idle);
|
|
3448
|
+
setRecent([]);
|
|
3449
|
+
if (!storeKey) return;
|
|
3450
|
+
void latest.current.open(storeKey).then(async (store) => {
|
|
3451
|
+
if (!active) {
|
|
3452
|
+
await store.close();
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
const outbox = createOrderOutbox({ collection: store.orders, deviceId, transport: latest.current.transport(storeKey) });
|
|
3456
|
+
current.current = { storeKey, orders: store.orders, outbox };
|
|
3457
|
+
const status = outbox.state$.subscribe(setState);
|
|
3458
|
+
const history = watchFresh(store.orders, { sort: [{ createdAt: "desc" }], limit: 50 }).subscribe(setRecent);
|
|
3459
|
+
dispose = () => {
|
|
3460
|
+
outbox.stop();
|
|
3461
|
+
status.unsubscribe();
|
|
3462
|
+
history.unsubscribe();
|
|
3463
|
+
void store.close();
|
|
3464
|
+
};
|
|
3465
|
+
setOrders(store.orders);
|
|
3466
|
+
outbox.start();
|
|
3467
|
+
}).catch((error) => {
|
|
3468
|
+
if (active) openingError.current = error;
|
|
3469
|
+
latest.current.onOpenError?.(error);
|
|
3470
|
+
});
|
|
3471
|
+
return () => {
|
|
3472
|
+
active = false;
|
|
3473
|
+
current.current = null;
|
|
3474
|
+
dispose?.();
|
|
3475
|
+
};
|
|
3476
|
+
}, [storeKey, deviceId]);
|
|
3477
|
+
useEffect4(() => {
|
|
3478
|
+
latest.current.onBusy?.(state.sending);
|
|
3479
|
+
return () => latest.current.onBusy?.(false);
|
|
3480
|
+
}, [state.sending]);
|
|
3481
|
+
const ready = current.current?.storeKey === storeKey && !!storeKey;
|
|
3482
|
+
return {
|
|
3483
|
+
orders: ready ? orders : null,
|
|
3484
|
+
state: ready ? state : idle,
|
|
3485
|
+
recent: ready ? recent : [],
|
|
3486
|
+
async flush() {
|
|
3487
|
+
const opened = current.current;
|
|
3488
|
+
if (opened && opened.storeKey === storeKey) await opened.outbox.flush();
|
|
3489
|
+
},
|
|
3490
|
+
async requeue(orderIds) {
|
|
3491
|
+
const opened = current.current;
|
|
3492
|
+
return opened && opened.storeKey === storeKey ? opened.outbox.requeue(orderIds) : 0;
|
|
3493
|
+
},
|
|
3494
|
+
savesInFlight: savesInFlightCount,
|
|
3495
|
+
async record(posOrder) {
|
|
3496
|
+
savesInFlight.current++;
|
|
3497
|
+
setSavesInFlightCount(savesInFlight.current);
|
|
3498
|
+
try {
|
|
3499
|
+
const opened = current.current;
|
|
3500
|
+
if (!opened || opened.storeKey !== storeKey) throw openingError.current ?? new Error("Orders are not ready.");
|
|
3501
|
+
try {
|
|
3502
|
+
await opened.orders.insert(posOrder);
|
|
3503
|
+
} catch (error) {
|
|
3504
|
+
const stored = error?.code === "CONFLICT" ? error.parameters.writeError : void 0;
|
|
3505
|
+
const inDb = stored?.status === 409 ? stored.documentInDb : void 0;
|
|
3506
|
+
if (!inDb || inDb._deleted) throw error;
|
|
3507
|
+
if (!sameSale(inDb, posOrder)) throw new OrderContentMismatchError(posOrder.id);
|
|
3508
|
+
if (inDb.commandId !== posOrder.commandId) {
|
|
3509
|
+
outboxLogger.warn("Recorded an order stored under another commandId", {
|
|
3510
|
+
orderId: posOrder.id,
|
|
3511
|
+
storedCommandId: inDb.commandId,
|
|
3512
|
+
recordedCommandId: posOrder.commandId
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
if (current.current === opened) void opened.outbox.flush();
|
|
3517
|
+
} finally {
|
|
3518
|
+
savesInFlight.current--;
|
|
3519
|
+
setSavesInFlightCount(savesInFlight.current);
|
|
3520
|
+
}
|
|
3521
|
+
},
|
|
3522
|
+
async isStored(order) {
|
|
3523
|
+
const opened = current.current;
|
|
3524
|
+
if (!opened || opened.storeKey !== storeKey) return false;
|
|
3525
|
+
const [stored] = await opened.orders.storageInstance.findDocumentsById([order.id], false);
|
|
3526
|
+
if (!stored || stored._deleted) return false;
|
|
3527
|
+
if (sameSale(stored, order)) return true;
|
|
3528
|
+
if (!loggedMismatches.current.has(order.id)) {
|
|
3529
|
+
loggedMismatches.current.add(order.id);
|
|
3530
|
+
outboxLogger.error("A stored order has this id with different content", { orderId: order.id });
|
|
3531
|
+
}
|
|
3532
|
+
return false;
|
|
3533
|
+
}
|
|
3534
|
+
};
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
// src/tender/tender-state.ts
|
|
3538
|
+
var MAX_TENDER_MINOR = 999999999;
|
|
3539
|
+
var initialTenderState = {
|
|
3540
|
+
view: "select",
|
|
3541
|
+
methodId: null,
|
|
3542
|
+
readerId: null,
|
|
3543
|
+
transport: null,
|
|
3544
|
+
entryMinor: 0,
|
|
3545
|
+
entryDirty: false,
|
|
3546
|
+
plan: null,
|
|
3547
|
+
splitView: false,
|
|
3548
|
+
splitTab: "even",
|
|
3549
|
+
pickedLineIds: [],
|
|
3550
|
+
linesPaidBy: {}
|
|
3551
|
+
};
|
|
3552
|
+
function initTenderState({
|
|
3553
|
+
methodId,
|
|
3554
|
+
balanceMinor
|
|
3555
|
+
}) {
|
|
3556
|
+
if (!methodId) return initialTenderState;
|
|
3557
|
+
return { ...initialTenderState, view: "amount", methodId, entryMinor: balanceMinor };
|
|
3558
|
+
}
|
|
3559
|
+
function tenderReducer(state, action) {
|
|
3560
|
+
switch (action.type) {
|
|
3561
|
+
case "pick-method":
|
|
3562
|
+
return {
|
|
3563
|
+
...state,
|
|
3564
|
+
view: "amount",
|
|
3565
|
+
methodId: action.methodId,
|
|
3566
|
+
readerId: action.readerId,
|
|
3567
|
+
transport: action.transport ?? null,
|
|
3568
|
+
entryMinor: state.entryDirty ? state.entryMinor : action.prefillMinor,
|
|
3569
|
+
entryDirty: state.entryDirty
|
|
3570
|
+
};
|
|
3571
|
+
case "pick-transport":
|
|
3572
|
+
return { ...state, transport: action.transport };
|
|
3573
|
+
case "pick-reader":
|
|
3574
|
+
return { ...state, readerId: action.readerId };
|
|
3575
|
+
case "key": {
|
|
3576
|
+
if (state.view !== "amount") {
|
|
3577
|
+
return state;
|
|
3578
|
+
}
|
|
3579
|
+
if (action.key === "backspace") {
|
|
3580
|
+
return { ...state, entryMinor: Math.floor(state.entryMinor / 10), entryDirty: true };
|
|
3581
|
+
}
|
|
3582
|
+
if (action.key === "clear") {
|
|
3583
|
+
return { ...state, entryMinor: 0, entryDirty: true };
|
|
3584
|
+
}
|
|
3585
|
+
const digit = Number(action.key);
|
|
3586
|
+
if (!state.entryDirty) {
|
|
3587
|
+
return { ...state, entryMinor: digit, entryDirty: true };
|
|
3588
|
+
}
|
|
3589
|
+
const entryMinor = state.entryMinor * 10 + digit;
|
|
3590
|
+
return entryMinor > MAX_TENDER_MINOR ? state : { ...state, entryMinor };
|
|
3591
|
+
}
|
|
3592
|
+
case "set-entry":
|
|
3593
|
+
return {
|
|
3594
|
+
...state,
|
|
3595
|
+
entryMinor: Math.max(0, Math.min(action.minor, MAX_TENDER_MINOR)),
|
|
3596
|
+
entryDirty: true
|
|
3597
|
+
};
|
|
3598
|
+
case "tender-started":
|
|
3599
|
+
return { ...state, entryMinor: 0, entryDirty: false, splitView: false };
|
|
3600
|
+
case "back":
|
|
3601
|
+
return {
|
|
3602
|
+
...state,
|
|
3603
|
+
view: "select",
|
|
3604
|
+
methodId: null,
|
|
3605
|
+
readerId: null,
|
|
3606
|
+
transport: null,
|
|
3607
|
+
entryMinor: 0,
|
|
3608
|
+
entryDirty: false
|
|
3609
|
+
};
|
|
3610
|
+
case "tender-recorded": {
|
|
3611
|
+
const linesPaidBy = { ...state.linesPaidBy };
|
|
3612
|
+
if (state.plan?.kind === "items" && action.rowsSinceFrom.reduce((sum, row) => sum + row.amountMinor, 0) >= state.plan.firstMinor) {
|
|
3613
|
+
for (const id2 of state.plan.lineIds) {
|
|
3614
|
+
linesPaidBy[id2] ??= [...new Set(action.rowsSinceFrom.map((row) => row.title))];
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
const rows = action.rowsSinceFrom.map((row) => ({
|
|
3618
|
+
minor: row.amountMinor,
|
|
3619
|
+
title: row.title
|
|
3620
|
+
}));
|
|
3621
|
+
const plan = activePlan(state.plan, rows.length, action.balanceMinor);
|
|
3622
|
+
const entryMinor = plan ? planLegs(plan, rows, action.balanceMinor).thisPaymentMinor : action.balanceMinor;
|
|
3623
|
+
return {
|
|
3624
|
+
...state,
|
|
3625
|
+
view: state.methodId ? "amount" : "select",
|
|
3626
|
+
entryMinor,
|
|
3627
|
+
entryDirty: false,
|
|
3628
|
+
splitView: false,
|
|
3629
|
+
pickedLineIds: [],
|
|
3630
|
+
linesPaidBy
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
case "open-split":
|
|
3634
|
+
return {
|
|
3635
|
+
...state,
|
|
3636
|
+
splitView: true,
|
|
3637
|
+
pickedLineIds: state.plan?.kind === "items" ? state.plan.lineIds.filter((id2) => !state.linesPaidBy[id2]) : state.pickedLineIds
|
|
3638
|
+
};
|
|
3639
|
+
case "close-split":
|
|
3640
|
+
return { ...state, splitView: false };
|
|
3641
|
+
case "set-split-tab":
|
|
3642
|
+
return { ...state, splitTab: action.tab };
|
|
3643
|
+
case "toggle-split-line":
|
|
3644
|
+
if (state.linesPaidBy[action.lineId]) return state;
|
|
3645
|
+
return {
|
|
3646
|
+
...state,
|
|
3647
|
+
pickedLineIds: state.pickedLineIds.includes(action.lineId) ? state.pickedLineIds.filter((id2) => id2 !== action.lineId) : [...state.pickedLineIds, action.lineId]
|
|
3648
|
+
};
|
|
3649
|
+
case "set-plan":
|
|
3650
|
+
return {
|
|
3651
|
+
...state,
|
|
3652
|
+
plan: action.plan,
|
|
3653
|
+
splitView: false,
|
|
3654
|
+
pickedLineIds: [],
|
|
3655
|
+
entryMinor: state.view === "amount" ? planLegs(action.plan, [], action.balanceMinor).thisPaymentMinor : state.entryMinor,
|
|
3656
|
+
entryDirty: state.view !== "amount" && state.entryDirty
|
|
3657
|
+
};
|
|
3658
|
+
case "clear-plan":
|
|
3659
|
+
case "arm-custom":
|
|
3660
|
+
return {
|
|
3661
|
+
...state,
|
|
3662
|
+
plan: null,
|
|
3663
|
+
splitView: false,
|
|
3664
|
+
view: action.type === "arm-custom" ? "amount" : state.view,
|
|
3665
|
+
entryMinor: action.type === "clear-plan" ? action.balanceMinor : 0,
|
|
3666
|
+
entryDirty: false
|
|
3667
|
+
};
|
|
3668
|
+
case "request-cancel":
|
|
3669
|
+
return { ...state, view: "cancel", splitView: false };
|
|
3670
|
+
case "reset":
|
|
3671
|
+
return initialTenderState;
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
function appliedMinor(entryMinor, balanceMinor) {
|
|
3675
|
+
return Math.max(0, Math.min(entryMinor, balanceMinor));
|
|
3676
|
+
}
|
|
3677
|
+
function changeMinor(entryMinor, appliedAmountMinor, givesChange) {
|
|
3678
|
+
return givesChange ? Math.max(0, entryMinor - appliedAmountMinor) : 0;
|
|
3679
|
+
}
|
|
3680
|
+
function quickTenderedAmounts(balanceMinor, stepsMinor) {
|
|
3681
|
+
if (balanceMinor === 0) {
|
|
3682
|
+
return [];
|
|
3683
|
+
}
|
|
3684
|
+
const amounts = /* @__PURE__ */ new Set([balanceMinor]);
|
|
3685
|
+
for (const stepMinor of stepsMinor) {
|
|
3686
|
+
if (stepMinor <= 0) continue;
|
|
3687
|
+
const remainder = balanceMinor % stepMinor;
|
|
3688
|
+
amounts.add(remainder === 0 ? balanceMinor : balanceMinor + stepMinor - remainder);
|
|
3689
|
+
}
|
|
3690
|
+
return [...amounts].sort((left, right) => left - right);
|
|
3691
|
+
}
|
|
3692
|
+
function evenSplitShareMinor(balanceMinor, ways) {
|
|
3693
|
+
if (ways < 2) {
|
|
3694
|
+
return balanceMinor;
|
|
3695
|
+
}
|
|
3696
|
+
const wholeShare = Math.floor(balanceMinor / ways);
|
|
3697
|
+
const remainder = balanceMinor % ways;
|
|
3698
|
+
return wholeShare + (remainder * 2 >= ways ? 1 : 0);
|
|
3699
|
+
}
|
|
3700
|
+
function activePlan(plan, taken, balanceMinor) {
|
|
3701
|
+
if (!plan || balanceMinor === 0) return null;
|
|
3702
|
+
if (plan.kind !== "items" && taken >= (plan.kind === "even" ? plan.ways : 2)) return null;
|
|
3703
|
+
return plan;
|
|
3704
|
+
}
|
|
3705
|
+
function planLegs(plan, rowsSinceFrom, balanceMinor) {
|
|
3706
|
+
const taken = rowsSinceFrom.length;
|
|
3707
|
+
const legs = rowsSinceFrom.map((row) => ({ ...row, state: "done" }));
|
|
3708
|
+
const groupPaid = rowsSinceFrom.reduce((sum, row) => sum + row.minor, 0);
|
|
3709
|
+
const groupLeft = plan.kind === "items" ? Math.min(balanceMinor, Math.max(0, plan.firstMinor - groupPaid)) : balanceMinor;
|
|
3710
|
+
const rest = plan.kind === "items" && groupLeft === 0;
|
|
3711
|
+
const ways = plan.kind === "fixed" ? 2 : Math.max(plan.ways, taken + 1);
|
|
3712
|
+
const left = plan.kind === "fixed" || rest ? 1 : Math.max(1, ways - taken);
|
|
3713
|
+
const amount = rest ? balanceMinor : groupLeft;
|
|
3714
|
+
const each = plan.kind === "fixed" && taken === 0 ? Math.min(plan.firstMinor, balanceMinor) : evenSplitShareMinor(amount, left);
|
|
3715
|
+
let remaining = amount;
|
|
3716
|
+
for (let i = 0; i < left; i++) {
|
|
3717
|
+
const minor2 = Math.min(remaining, i === left - 1 && plan.kind !== "fixed" ? remaining : each);
|
|
3718
|
+
legs.push({ minor: minor2, state: i === 0 ? "now" : "todo" });
|
|
3719
|
+
remaining -= minor2;
|
|
3720
|
+
}
|
|
3721
|
+
if (plan.kind === "fixed" && remaining > 0) legs.push({ minor: remaining, state: "todo" });
|
|
3722
|
+
if (plan.kind === "items" && !rest && balanceMinor > groupLeft)
|
|
3723
|
+
legs.push({ minor: balanceMinor - groupLeft, state: "rest" });
|
|
3724
|
+
const title = plan.kind === "fixed" && taken === 0 ? plan.title : null;
|
|
3725
|
+
return { legs, thisPaymentMinor: each, label: { n: taken + 1, ways, rest, title } };
|
|
3726
|
+
}
|
|
616
3727
|
export {
|
|
3728
|
+
CartError,
|
|
617
3729
|
CurrencyProvider,
|
|
3730
|
+
DISCOUNTS_UNSUPPORTED,
|
|
3731
|
+
MAX_TENDER_MINOR,
|
|
3732
|
+
MICROS_PER_MINOR,
|
|
3733
|
+
OrderContentMismatchError,
|
|
3734
|
+
PosOrderOpenClosedError,
|
|
3735
|
+
RegisterApprovalRequiredError,
|
|
3736
|
+
RegisterCloseIncompleteError,
|
|
3737
|
+
RegisterMovementStrandedError,
|
|
3738
|
+
RegisterSessionAlreadyOpenError,
|
|
3739
|
+
RegisterSessionClosedError,
|
|
3740
|
+
RegisterSessionRequiredError,
|
|
3741
|
+
RegisterTenderInProgressError,
|
|
3742
|
+
SALE_SAVING,
|
|
618
3743
|
TaxProvider,
|
|
619
|
-
|
|
3744
|
+
activePlan,
|
|
3745
|
+
addEntryToCart,
|
|
3746
|
+
addPosOrderCollection,
|
|
3747
|
+
advancePerpetual,
|
|
3748
|
+
allocateOrderDiscount,
|
|
3749
|
+
appliedMinor,
|
|
3750
|
+
backToSelling,
|
|
3751
|
+
bindRegister,
|
|
3752
|
+
buildClosureDocument,
|
|
620
3753
|
buildReceiptData,
|
|
621
|
-
|
|
3754
|
+
buildXReportDocument,
|
|
622
3755
|
callbackSink,
|
|
3756
|
+
cashMovementSchema,
|
|
3757
|
+
catalogueEntries,
|
|
3758
|
+
changeMinor,
|
|
3759
|
+
clampClosureScope,
|
|
3760
|
+
closeNeedsApproval,
|
|
3761
|
+
closeSession,
|
|
3762
|
+
closureSchema,
|
|
3763
|
+
computeOrderTax,
|
|
623
3764
|
consoleSink,
|
|
3765
|
+
countFresh,
|
|
3766
|
+
countVariance,
|
|
3767
|
+
createHttpCommandTransport,
|
|
624
3768
|
createLogger,
|
|
625
3769
|
createOrderBuilder,
|
|
626
3770
|
createOrderManager,
|
|
3771
|
+
createOrderOutbox,
|
|
627
3772
|
createRepository,
|
|
628
|
-
|
|
629
|
-
|
|
3773
|
+
denominationTotal,
|
|
3774
|
+
denominations,
|
|
3775
|
+
deriveExpected,
|
|
3776
|
+
deriveSettled,
|
|
3777
|
+
ensureRegister,
|
|
3778
|
+
evenSplitShareMinor,
|
|
3779
|
+
exportCsv,
|
|
3780
|
+
finalizeOrder,
|
|
3781
|
+
findEntryByCode,
|
|
3782
|
+
formatClosureDate,
|
|
3783
|
+
getBoundRegisterId,
|
|
3784
|
+
getDeviceId,
|
|
3785
|
+
getProductStock,
|
|
3786
|
+
initTenderState,
|
|
3787
|
+
initialTenderState,
|
|
3788
|
+
isServerDecimal,
|
|
3789
|
+
labelKeys,
|
|
3790
|
+
minorToDecimal,
|
|
3791
|
+
mintClosureNumber,
|
|
3792
|
+
mintUuid,
|
|
3793
|
+
movementFieldError,
|
|
3794
|
+
needsAttention,
|
|
3795
|
+
nextSaleCounter,
|
|
3796
|
+
normalizeAmount,
|
|
3797
|
+
observeRegister$,
|
|
3798
|
+
openSession,
|
|
3799
|
+
openSessionSelector,
|
|
3800
|
+
outboxLogger,
|
|
3801
|
+
overThreshold,
|
|
3802
|
+
parseMinor,
|
|
3803
|
+
planLegs,
|
|
3804
|
+
posOrderCollection,
|
|
3805
|
+
posOrderSchema,
|
|
3806
|
+
posOrdersLogger,
|
|
3807
|
+
quickTenderedAmounts,
|
|
3808
|
+
ratePpmFromPercent,
|
|
3809
|
+
readBoundRegister,
|
|
3810
|
+
readFresh,
|
|
3811
|
+
readRegister,
|
|
3812
|
+
recordMovement,
|
|
3813
|
+
recordRegisterFact,
|
|
3814
|
+
registerFactsLogger,
|
|
3815
|
+
registerSessionCollection,
|
|
3816
|
+
registerSessionSchema,
|
|
3817
|
+
requireOpenSession,
|
|
3818
|
+
resolveStoreSettings,
|
|
3819
|
+
roundMicrosToMinor,
|
|
3820
|
+
saleLogger,
|
|
3821
|
+
sameSale,
|
|
3822
|
+
searchProducts,
|
|
3823
|
+
selectClosureRows,
|
|
3824
|
+
stampSession,
|
|
3825
|
+
startCounting,
|
|
3826
|
+
stockOverlay$,
|
|
3827
|
+
stockOverlayAsOf$,
|
|
3828
|
+
taxLinesByRate,
|
|
3829
|
+
taxMicros,
|
|
3830
|
+
taxProviderProps,
|
|
3831
|
+
tenderReducer,
|
|
3832
|
+
toOrderCreateEnvelope,
|
|
3833
|
+
unbindRegister,
|
|
3834
|
+
useCurrencyCode,
|
|
630
3835
|
useCurrencyFormatter,
|
|
631
|
-
|
|
3836
|
+
useOrderOutbox,
|
|
3837
|
+
useRegisterSession,
|
|
3838
|
+
useSale,
|
|
3839
|
+
useStoreSettings,
|
|
3840
|
+
useTax,
|
|
3841
|
+
uuidv7,
|
|
3842
|
+
validAmount,
|
|
3843
|
+
varianceText,
|
|
3844
|
+
variantPriceLabel,
|
|
3845
|
+
voidMovement,
|
|
3846
|
+
watchFresh,
|
|
3847
|
+
withPricingContext,
|
|
3848
|
+
withStockOverlay,
|
|
3849
|
+
writeClosure
|
|
632
3850
|
};
|