@tallyui/pos 3.5.3 → 3.7.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/dist/index.js CHANGED
@@ -1364,6 +1364,9 @@ function useParkedSales(drafts) {
1364
1364
  return { parked, discard };
1365
1365
  }
1366
1366
 
1367
+ // src/receipt/types.ts
1368
+ var RECEIPT_SCHEMA_VERSION = "1.4.0";
1369
+
1367
1370
  // src/receipt/build-receipt-data.ts
1368
1371
  function buildReceiptData(order, config) {
1369
1372
  const taxedLines = [...order.lineItems.map((line) => order.taxRounding?.granularity === "woocommerce" && line.netMicros !== void 0 ? { ...line, taxInclusive: order.pricesIncludeTax } : line), ...[...order.fees ?? [], ...order.shipping ?? []].map((charge) => ({
@@ -1387,6 +1390,23 @@ function buildReceiptData(order, config) {
1387
1390
  const label = (d) => d.label ?? d.couponCode ?? `${d.type} discount`;
1388
1391
  const customer = order.customer?.name || order.customer?.email;
1389
1392
  return {
1393
+ schemaVersion: RECEIPT_SCHEMA_VERSION,
1394
+ software: {
1395
+ name: "WCPOS",
1396
+ plugin_version: "",
1397
+ app_version: "",
1398
+ app_build: "",
1399
+ platform: "",
1400
+ ...config.software
1401
+ },
1402
+ register: { id: config.register ?? "", name: config.registerName ?? "" },
1403
+ fiscal: {
1404
+ document_type: "sale",
1405
+ is_reprint: false,
1406
+ reprint_count: 0,
1407
+ qr_payload: "",
1408
+ ...config.fiscal
1409
+ },
1390
1410
  header: {
1391
1411
  storeName: config.storeName,
1392
1412
  storeAddress: config.storeAddress,
@@ -2101,7 +2121,7 @@ function posOrderCollection() {
2101
2121
  };
2102
2122
  }
2103
2123
 
2104
- // src/pos-order/open.ts
2124
+ // src/rxdb/open-migrated-collection.ts
2105
2125
  import {
2106
2126
  deepEqual,
2107
2127
  defaultConflictHandler,
@@ -2115,16 +2135,6 @@ import {
2115
2135
  } from "rxdb";
2116
2136
  import { getOldCollectionMeta, migrateDocumentData } from "rxdb/plugins/migration-schema";
2117
2137
  import { Subject, takeUntil } from "rxjs";
2118
- var posOrdersLogger = createLogger("pos-orders");
2119
- var POS_ORDER_MIGRATION_CLOSE_WAIT_MS = 1e4;
2120
- var PosOrderOpenClosedError = class extends Error {
2121
- constructor(databaseName) {
2122
- super(`addPosOrderCollection: database ${databaseName} closed during the open; reopen it and open pos_orders again`);
2123
- this.databaseName = databaseName;
2124
- this.name = "PosOrderOpenClosedError";
2125
- }
2126
- code = "POS_ORDER_OPEN_CLOSED";
2127
- };
2128
2138
  var latestOpens = /* @__PURE__ */ new WeakMap();
2129
2139
  var closedUnderOpen = /* @__PURE__ */ new WeakSet();
2130
2140
  function waitWithTimeout(promise, ms) {
@@ -2137,35 +2147,40 @@ function waitWithTimeout(promise, ms) {
2137
2147
  });
2138
2148
  }
2139
2149
  async function writeOverStaleCopies(collection, from, to, stopIfClosing) {
2150
+ const primaryKey = collection.schema.primaryPath;
2140
2151
  const handler = rxStorageInstanceToReplicationHandler(to, defaultConflictHandler, collection.database.token, true);
2141
2152
  for (let page = await getChangedDocumentsSince(from, 200); page.documents.length > 0; page = await getChangedDocumentsSince(from, 200, page.checkpoint)) {
2142
2153
  stopIfClosing();
2143
- const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc.id), false)).map((copy) => [copy.id, copy]));
2144
- 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) })));
2154
+ const copies = new Map((await to.findDocumentsById(page.documents.map((doc) => doc[primaryKey]), false)).map((copy) => [copy[primaryKey], copy]));
2155
+ const rows = await Promise.all(page.documents.filter((doc) => copies.has(doc[primaryKey]) && !doc._deleted).map(async (doc) => ({ assumedMasterState: copies.get(doc[primaryKey]), newDocumentState: await migrateDocumentData(collection, from.schema.version, doc) })));
2145
2156
  const stale = rows.filter((row) => row.newDocumentState && !deepEqual(row.assumedMasterState, row.newDocumentState));
2146
2157
  if (stale.length > 0) await handler.masterWrite(stale);
2147
2158
  }
2148
2159
  }
2149
- async function addPosOrderCollection(db, closeWaitMs = POS_ORDER_MIGRATION_CLOSE_WAIT_MS) {
2150
- if (db.multiInstance) throw new Error("addPosOrderCollection: multiInstance databases are not supported (ADR-061)");
2151
- if (db.closed || db.closePromise) throw new PosOrderOpenClosedError(db.name);
2152
- const previous = latestOpens.get(db);
2160
+ async function openMigratedCollection(db, opener, closeWaitMs) {
2161
+ const { name, label, closedError } = opener;
2162
+ if (db.multiInstance) throw new Error(`${label}: multiInstance databases are not supported (ADR-061)`);
2163
+ if (db.closed || db.closePromise) throw closedError(db.name);
2164
+ const opens = latestOpens.get(db) ?? /* @__PURE__ */ new Map();
2165
+ latestOpens.set(db, opens);
2166
+ const previous = opens.get(name);
2153
2167
  if (!previous) {
2154
- db.onClose.push(() => waitWithTimeout(latestOpens.get(db), closeWaitMs).then((settled) => {
2168
+ db.onClose.push(() => waitWithTimeout(latestOpens.get(db).get(name), closeWaitMs).then((settled) => {
2155
2169
  if (!settled) closedUnderOpen.add(db);
2156
2170
  }));
2157
2171
  }
2158
- const opening = openPosOrders(db, previous);
2159
- latestOpens.set(db, Promise.all([previous, opening.catch(() => void 0)]));
2172
+ const opening = openCollection(db, opener, previous);
2173
+ opens.set(name, Promise.all([previous, opening.catch(() => void 0)]));
2160
2174
  return opening;
2161
2175
  }
2162
- async function openPosOrders(db, previous) {
2176
+ async function openCollection(db, opener, previous) {
2177
+ const { name, creator, logger, closedError } = opener;
2163
2178
  let cancelled = false;
2164
2179
  const closing = () => cancelled || closedUnderOpen.has(db) || db.closed;
2165
2180
  const stopIfClosing = () => {
2166
- if (closing()) throw new PosOrderOpenClosedError(db.name);
2181
+ if (closing()) throw closedError(db.name);
2167
2182
  };
2168
- const { pos_orders: collection } = await db.addCollections({ pos_orders: { ...posOrderCollection(), autoMigrate: false } });
2183
+ const { [name]: collection } = await db.addCollections({ [name]: { ...creator(), autoMigrate: false } });
2169
2184
  const state = collection.getMigrationState();
2170
2185
  try {
2171
2186
  stopIfClosing();
@@ -2188,7 +2203,7 @@ async function openPosOrders(db, previous) {
2188
2203
  stopIfClosing();
2189
2204
  const checkpoint = await db.storage.createStorageInstance({
2190
2205
  databaseName: db.name,
2191
- collectionName: `rx-migration-state-meta-pos_orders-${old.version}`,
2206
+ collectionName: `rx-migration-state-meta-${name}-${old.version}`,
2192
2207
  databaseInstanceToken: db.token,
2193
2208
  multiInstance: db.multiInstance,
2194
2209
  options: {},
@@ -2214,7 +2229,7 @@ async function openPosOrders(db, previous) {
2214
2229
  } });
2215
2230
  const internalStore = gate(db.internalStore, async (key, first) => {
2216
2231
  const ids = key === "bulkWrite" ? first.map((row) => row.document.id) : first;
2217
- posOrdersLogger.warn(
2232
+ logger.warn(
2218
2233
  `The database closed during the migration: dropped a ${key === "bulkWrite" ? "status write" : "status read"}`,
2219
2234
  { database: db.name, ids }
2220
2235
  );
@@ -2224,7 +2239,7 @@ async function openPosOrders(db, previous) {
2224
2239
  const migrateStorage = state.migrateStorage.bind(state);
2225
2240
  state.migrateStorage = async (from, current, batchSize) => {
2226
2241
  stopIfClosing();
2227
- const to = gate(current, () => Promise.reject(new PosOrderOpenClosedError(db.name)));
2242
+ const to = gate(current, () => Promise.reject(closedError(db.name)));
2228
2243
  if (from.collectionName === collection.name) await writeOverStaleCopies(collection, from, to, stopIfClosing);
2229
2244
  stopIfClosing();
2230
2245
  return migrateStorage(new Proxy(from, { get: (target, key) => {
@@ -2259,8 +2274,29 @@ async function openPosOrders(db, previous) {
2259
2274
  throw newRxError("DM4", { collection: collection.name, error: status?.error });
2260
2275
  } catch (error) {
2261
2276
  await collection.close();
2262
- throw closing() ? new PosOrderOpenClosedError(db.name) : error;
2277
+ throw closing() ? closedError(db.name) : error;
2278
+ }
2279
+ }
2280
+
2281
+ // src/pos-order/open.ts
2282
+ var posOrdersLogger = createLogger("pos-orders");
2283
+ var POS_ORDER_MIGRATION_CLOSE_WAIT_MS = 1e4;
2284
+ var PosOrderOpenClosedError = class extends Error {
2285
+ constructor(databaseName) {
2286
+ super(`addPosOrderCollection: database ${databaseName} closed during the open; reopen it and open pos_orders again`);
2287
+ this.databaseName = databaseName;
2288
+ this.name = "PosOrderOpenClosedError";
2263
2289
  }
2290
+ code = "POS_ORDER_OPEN_CLOSED";
2291
+ };
2292
+ function addPosOrderCollection(db, closeWaitMs = POS_ORDER_MIGRATION_CLOSE_WAIT_MS) {
2293
+ return openMigratedCollection(db, {
2294
+ name: "pos_orders",
2295
+ creator: posOrderCollection,
2296
+ label: "addPosOrderCollection",
2297
+ logger: posOrdersLogger,
2298
+ closedError: (name) => new PosOrderOpenClosedError(name)
2299
+ }, closeWaitMs);
2264
2300
  }
2265
2301
 
2266
2302
  // src/pos-order/device-id.ts
@@ -2282,7 +2318,7 @@ function getDeviceId(storage, key) {
2282
2318
  // src/pos-order/needs-attention.ts
2283
2319
  import { knownWarnings } from "@tallyui/core";
2284
2320
  function needsAttention(orders, options = {}) {
2285
- return orders.filter((order) => order.syncStatus === "rejected" || (order.localWarnings?.length ?? 0) > 0 || order.syncStatus === "applied" && knownWarnings(order.warnings).length > 0 || order.lateSessionId !== void 0 || order.syncStatus === "pending" && options.stuckCommandIds?.includes(order.commandId) === true).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2321
+ return orders.filter((order) => order.syncStatus === "rejected" || (order.localWarnings?.length ?? 0) > 0 || order.syncStatus === "applied" && knownWarnings(order.warnings).some((warning) => warning.code !== "register_session_unknown") || order.lateSessionId !== void 0 || order.syncStatus === "pending" && options.stuckCommandIds?.includes(order.commandId) === true).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2286
2322
  }
2287
2323
 
2288
2324
  // src/pos-order/same-sale.ts
@@ -2375,6 +2411,7 @@ var denominations = {
2375
2411
  // src/register/schemas.ts
2376
2412
  import { addRxPlugin as addRxPlugin2 } from "rxdb";
2377
2413
  import { RxDBLocalDocumentsPlugin } from "rxdb/plugins/local-documents";
2414
+ import { RxDBMigrationSchemaPlugin as RxDBMigrationSchemaPlugin2 } from "rxdb/plugins/migration-schema";
2378
2415
  var id = { type: "string", maxLength: 36 };
2379
2416
  var text = { type: "string" };
2380
2417
  var nullableText = { type: ["string", "null"] };
@@ -2383,7 +2420,7 @@ var nullableMinor = { type: ["integer", "null"] };
2383
2420
  var tenders = { type: "object", additionalProperties: minor };
2384
2421
  var registerSessionSchema = {
2385
2422
  title: "Register sessions",
2386
- version: 0,
2423
+ version: 1,
2387
2424
  primaryKey: "id",
2388
2425
  type: "object",
2389
2426
  additionalProperties: false,
@@ -2391,7 +2428,8 @@ var registerSessionSchema = {
2391
2428
  id,
2392
2429
  register_id: id,
2393
2430
  store_key: nullableText,
2394
- status: { type: "string", enum: ["open", "counting", "closed"], maxLength: 8 },
2431
+ status: { type: "string", maxLength: 20 },
2432
+ server_session_id: { ...nullableText, default: null },
2395
2433
  business_day: { type: "string", maxLength: 10 },
2396
2434
  opened_at_gmt: text,
2397
2435
  opened_by: nullableText,
@@ -2415,9 +2453,16 @@ var registerSessionSchema = {
2415
2453
  required: ["id", "register_id", "status", "opened_at_gmt", "counted_float_minor"],
2416
2454
  indexes: [["register_id", "status"]]
2417
2455
  };
2418
- function registerSessionCollection() {
2456
+ function registerSessionCreator() {
2419
2457
  addRxPlugin2(RxDBLocalDocumentsPlugin);
2420
- return { schema: registerSessionSchema, localDocuments: true };
2458
+ addRxPlugin2(RxDBMigrationSchemaPlugin2);
2459
+ return { schema: registerSessionSchema, localDocuments: true, migrationStrategies: { 1: (doc) => {
2460
+ doc.server_session_id ??= null;
2461
+ return doc;
2462
+ } } };
2463
+ }
2464
+ function registerSessionCollection() {
2465
+ throw new Error("registerSessionCollection() is gone: open register_sessions with await addRegisterSessionCollection(db). Adding it with addCollections would run RxDB's own migration of schema version 1, which can lose sessions (ADR-078, #371).");
2421
2466
  }
2422
2467
  var cashMovementSchema = {
2423
2468
  title: "Cash movements",
@@ -2501,6 +2546,27 @@ var closureSchema = {
2501
2546
  indexes: [["register_id"], ["session_id"]]
2502
2547
  };
2503
2548
 
2549
+ // src/register/open.ts
2550
+ var registerSessionsLogger = createLogger("register-sessions");
2551
+ var REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS = 1e4;
2552
+ var RegisterSessionOpenClosedError = class extends Error {
2553
+ constructor(databaseName) {
2554
+ super(`addRegisterSessionCollection: database ${databaseName} closed during the open; reopen it and open register_sessions again`);
2555
+ this.databaseName = databaseName;
2556
+ this.name = "RegisterSessionOpenClosedError";
2557
+ }
2558
+ code = "REGISTER_SESSION_OPEN_CLOSED";
2559
+ };
2560
+ function addRegisterSessionCollection(db, closeWaitMs = REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS) {
2561
+ return openMigratedCollection(db, {
2562
+ name: "register_sessions",
2563
+ creator: registerSessionCreator,
2564
+ label: "addRegisterSessionCollection",
2565
+ logger: registerSessionsLogger,
2566
+ closedError: (name) => new RegisterSessionOpenClosedError(name)
2567
+ }, closeWaitMs);
2568
+ }
2569
+
2504
2570
  // src/register/register-document.ts
2505
2571
  import { map as map4 } from "rxjs";
2506
2572
  var REGISTER = "register";
@@ -2740,6 +2806,13 @@ var RegisterSessionClosedError = class extends Error {
2740
2806
  this.name = "RegisterSessionClosedError";
2741
2807
  }
2742
2808
  };
2809
+ var RegisterNeedsUpgradeError = class extends Error {
2810
+ code = "REGISTER_NEEDS_UPGRADE";
2811
+ constructor() {
2812
+ super("This till has register data from a newer version of the app. Update the app to continue.");
2813
+ this.name = "RegisterNeedsUpgradeError";
2814
+ }
2815
+ };
2743
2816
  var RegisterMovementAmountError = class extends Error {
2744
2817
  constructor(type, amountMinor) {
2745
2818
  super(`movement_amount_invalid:${type}:${amountMinor}`);
@@ -2765,8 +2838,13 @@ var RegisterMovementStrandedError = class extends Error {
2765
2838
  var TRANSITIONS = {
2766
2839
  open: ["counting", "closed"],
2767
2840
  counting: ["open", "closed"],
2768
- closed: []
2841
+ closed: [],
2842
+ conflict: [],
2843
+ superseded: []
2769
2844
  };
2845
+ function isKnownSessionStatus(status) {
2846
+ return Object.hasOwn(TRANSITIONS, status);
2847
+ }
2770
2848
  async function readSession(sessions, id2) {
2771
2849
  const [stored] = await sessions.storageInstance.findDocumentsById([id2], false);
2772
2850
  return stored;
@@ -2778,6 +2856,7 @@ async function readClosure(closures, id2) {
2778
2856
  async function requireLiveSession(sessions, id2) {
2779
2857
  const session = await readSession(sessions, id2);
2780
2858
  if (!session) throw new RegisterSessionRequiredError();
2859
+ if (!isKnownSessionStatus(session.status)) throw new RegisterNeedsUpgradeError();
2781
2860
  if (session.status === "closed") throw new RegisterSessionClosedError();
2782
2861
  return session;
2783
2862
  }
@@ -2855,6 +2934,7 @@ async function transition(sessions, id2, status, extra = {}) {
2855
2934
  if (row.status === "closed" && status === "closed") return row;
2856
2935
  const at2 = (/* @__PURE__ */ new Date()).toISOString();
2857
2936
  return row.incrementalModify((doc) => {
2937
+ if (!isKnownSessionStatus(doc.status)) throw new RegisterNeedsUpgradeError();
2858
2938
  if (doc.status === "closed" && status === "closed") return doc;
2859
2939
  if (doc.status === "closed") throw new RegisterSessionClosedError();
2860
2940
  if (!TRANSITIONS[doc.status].includes(status)) throw new Error(`invalid_session_transition:${doc.status}->${status}`);
@@ -3591,6 +3671,7 @@ function closureCommand(c) {
3591
3671
  }
3592
3672
  var chains = /* @__PURE__ */ new WeakMap();
3593
3673
  function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }) {
3674
+ const sentStatus = (s) => s.status === "open" || s.status === "counting" || s.status === "closed";
3594
3675
  let registers = chains.get(commands);
3595
3676
  if (!registers) chains.set(commands, registers = /* @__PURE__ */ new Map());
3596
3677
  const run = (registers.get(registerId) ?? Promise.resolve()).then(async () => {
@@ -3605,12 +3686,12 @@ function reconcileRegisterCommands({ commands, sessions, movements, closures, ho
3605
3686
  readFresh(movements, { selector: { session_id: session.id } }),
3606
3687
  session.closure_id ? closures.storageInstance.findDocumentsById([session.closure_id], false) : Promise.resolve([])
3607
3688
  ]);
3608
- const events = (observed ?? []).filter((row) => row.id === session.id && !transitioned.has(session.id) && row.status !== session.status && row.status !== "closed").flatMap((row) => row.status_at == null ? [] : [sessionTransitionCommand({ ...row, status_at: row.status_at })]);
3689
+ const events = (observed ?? []).filter((row) => row.id === session.id && !transitioned.has(session.id) && row.status !== session.status && row.status !== "closed").flatMap((row) => row.status_at == null || !sentStatus(row) ? [] : [sessionTransitionCommand({ ...row, status_at: row.status_at })]);
3609
3690
  events.push(
3610
3691
  ...entries.filter((entry) => entry.type !== "void").map(movementCommand),
3611
3692
  ...entries.filter((entry) => entry.type === "void").map(movementCommand)
3612
3693
  );
3613
- if (session.status_at != null) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
3694
+ if (session.status_at != null && sentStatus(session)) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
3614
3695
  return { session, number: closureRows[0]?.number, built: [
3615
3696
  sessionOpenCommand(session),
3616
3697
  ...events,
@@ -3679,6 +3760,7 @@ var closedFacts = /* @__PURE__ */ new Set();
3679
3760
  var reservationOf = (register, storeKey, registerId) => register?.stores[storeKey]?.registers?.[registerId]?.closure_reservation;
3680
3761
  var unwritten = (row, closureRows) => row.status === "closed" && row.closure_id === row.id && !closureRows.some((closure) => closure.id === row.id);
3681
3762
  function currentSession(rows, closureRows, reservation) {
3763
+ if (rows.some((row) => !isKnownSessionStatus(row.status))) return null;
3682
3764
  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;
3683
3765
  }
3684
3766
  function ledgerRows(orders) {
@@ -3805,6 +3887,8 @@ function useRegisterSession(options) {
3805
3887
  const nameOf = (id2) => !id2 ? "" : id2 === actor.id ? actor.name : labels?.resolveCashierName?.(id2) ?? "";
3806
3888
  return {
3807
3889
  session,
3890
+ /** Any unknown session status in the current snapshot means the register needs upgrade. */
3891
+ needsUpgrade: data?.rows.some((row) => !isKnownSessionStatus(row.status)) ?? false,
3808
3892
  movements: entries,
3809
3893
  expected,
3810
3894
  salesCount: sales.length,
@@ -3827,6 +3911,7 @@ function useRegisterSession(options) {
3827
3911
  * Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
3828
3912
  * another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
3829
3913
  * while an earlier close hasn't finished: a new session would lock the till behind it.
3914
+ * An unknown stored status refuses with `RegisterNeedsUpgradeError`.
3830
3915
  */
3831
3916
  openSession: async (input) => {
3832
3917
  const key = registerId ?? "";
@@ -3834,6 +3919,7 @@ function useRegisterSession(options) {
3834
3919
  const run = (async () => {
3835
3920
  const { sessions: sessions2, closures: closures2, registerId: registerId2, register: register2 } = live();
3836
3921
  const rows = await readFresh(sessions2, { selector: { register_id: registerId2 } });
3922
+ if (rows.some((row2) => !isKnownSessionStatus(row2.status))) throw new RegisterNeedsUpgradeError();
3837
3923
  if (rows.some((row2) => row2.status !== "closed")) throw new RegisterSessionAlreadyOpenError();
3838
3924
  const closureRows = await readFresh(closures2, { selector: { register_id: registerId2 } });
3839
3925
  const reservation = reservationOf(await readRegister(register2), storeKey, registerId2);
@@ -5086,6 +5172,7 @@ function createOrderOutbox(options) {
5086
5172
 
5087
5173
  // src/outbox/register-outbox.ts
5088
5174
  import { BehaviorSubject as BehaviorSubject5 } from "rxjs";
5175
+ var NON_BLOCKING_REJECTIONS = /* @__PURE__ */ new Set(["register_session_superseded", "register_session_abandoned"]);
5089
5176
  function plainCommand({
5090
5177
  key,
5091
5178
  registerId,
@@ -5165,8 +5252,9 @@ function createRegisterOutbox(options) {
5165
5252
  selector: { syncStatus: { $in: ["pending", "rejected"] } },
5166
5253
  sort: [{ seq: "asc" }, { key: "asc" }]
5167
5254
  })) {
5168
- if (command.syncStatus === "rejected") blocked.add(command.registerId);
5169
- else if (!blocked.has(command.registerId)) sendable2.add(command.commandId);
5255
+ if (command.syncStatus === "rejected") {
5256
+ if (!NON_BLOCKING_REJECTIONS.has(command.error?.code ?? "")) blocked.add(command.registerId);
5257
+ } else if (!blocked.has(command.registerId)) sendable2.add(command.commandId);
5170
5258
  }
5171
5259
  for (const id2 of clocks.keys()) if (!sendable2.has(id2)) clocks.delete(id2);
5172
5260
  state$.next({ ...state$.value, ...patch, pending, stuck: stuckState() });
@@ -5202,9 +5290,10 @@ function createRegisterOutbox(options) {
5202
5290
  });
5203
5291
  const commands = [];
5204
5292
  for (const command of ledger) {
5293
+ if (command.syncStatus === "rejected" && NON_BLOCKING_REJECTIONS.has(command.error?.code ?? "")) continue;
5205
5294
  if (command.syncStatus === "rejected") break;
5206
5295
  commands.push(command);
5207
- if (commands.length >= batchSize) break;
5296
+ if (command.type === "register.session.open" || commands.length >= batchSize) break;
5208
5297
  }
5209
5298
  if (stopped) {
5210
5299
  stoppedDuringRun = true;
@@ -5707,6 +5796,49 @@ function planLegs(plan, rowsSinceFrom, balanceMinor) {
5707
5796
  const title = plan.kind === "fixed" && taken === 0 ? plan.title : null;
5708
5797
  return { legs, thisPaymentMinor: each, label: { n: taken + 1, ways, rest, title } };
5709
5798
  }
5799
+
5800
+ // src/tender/tender-void.ts
5801
+ var tenderVoidSchema = {
5802
+ title: "Tender voids",
5803
+ version: 0,
5804
+ primaryKey: "paymentId",
5805
+ type: "object",
5806
+ additionalProperties: false,
5807
+ properties: {
5808
+ paymentId: { type: "string", maxLength: 64 },
5809
+ saleId: { type: "string", maxLength: 64 },
5810
+ type: { type: "string", enum: ["void"], maxLength: 4 },
5811
+ // Not an enum: PaymentMethodKind grows, and a stored enum would need a schema migration per new kind.
5812
+ method: { type: "string", maxLength: 32 },
5813
+ amountMinor: { type: "integer" },
5814
+ tenderedMinor: { type: "integer" },
5815
+ reference: { type: "string" },
5816
+ currency: { type: "string", maxLength: 3 },
5817
+ reason: { type: "string", enum: ["removed", "cancelled"] },
5818
+ deviceTime: { type: "string", maxLength: 40 },
5819
+ deviceTz: { type: "string" },
5820
+ registerId: { type: "string" },
5821
+ sessionId: { type: "string" },
5822
+ cashierRef: { type: "string" }
5823
+ },
5824
+ required: [
5825
+ "paymentId",
5826
+ "saleId",
5827
+ "type",
5828
+ "method",
5829
+ "amountMinor",
5830
+ "currency",
5831
+ "reason",
5832
+ "deviceTime",
5833
+ "deviceTz",
5834
+ "registerId",
5835
+ "cashierRef"
5836
+ ],
5837
+ indexes: [["saleId"], ["deviceTime"]]
5838
+ };
5839
+ function tenderVoidCollection() {
5840
+ return { schema: tenderVoidSchema };
5841
+ }
5710
5842
  export {
5711
5843
  CartError,
5712
5844
  CurrencyProvider,
@@ -5717,14 +5849,18 @@ export {
5717
5849
  OrderContentMismatchError,
5718
5850
  PRODUCT_SORT_FIELDS,
5719
5851
  PosOrderOpenClosedError,
5852
+ RECEIPT_SCHEMA_VERSION,
5853
+ REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS,
5720
5854
  RegisterApprovalRequiredError,
5721
5855
  RegisterCloseIncompleteError,
5722
5856
  RegisterIdInvalidError,
5723
5857
  RegisterMovementAmountError,
5724
5858
  RegisterMovementReasonError,
5725
5859
  RegisterMovementStrandedError,
5860
+ RegisterNeedsUpgradeError,
5726
5861
  RegisterSessionAlreadyOpenError,
5727
5862
  RegisterSessionClosedError,
5863
+ RegisterSessionOpenClosedError,
5728
5864
  RegisterSessionRequiredError,
5729
5865
  RegisterTenderInProgressError,
5730
5866
  SALE_SAVING,
@@ -5734,6 +5870,7 @@ export {
5734
5870
  activePlan,
5735
5871
  addEntryToCart,
5736
5872
  addPosOrderCollection,
5873
+ addRegisterSessionCollection,
5737
5874
  advancePerpetual,
5738
5875
  allocateOrderDiscount,
5739
5876
  appliedMinor,
@@ -5780,6 +5917,7 @@ export {
5780
5917
  inCategory,
5781
5918
  initTenderState,
5782
5919
  initialTenderState,
5920
+ isKnownSessionStatus,
5783
5921
  isServerDecimal,
5784
5922
  labelKeys,
5785
5923
  listCategories,
@@ -5820,6 +5958,7 @@ export {
5820
5958
  registerFactsLogger,
5821
5959
  registerSessionCollection,
5822
5960
  registerSessionSchema,
5961
+ registerSessionsLogger,
5823
5962
  requireOpenSession,
5824
5963
  resolveGridColumns,
5825
5964
  resolveStoreSettings,
@@ -5842,6 +5981,8 @@ export {
5842
5981
  taxMicros,
5843
5982
  taxProviderProps,
5844
5983
  tenderReducer,
5984
+ tenderVoidCollection,
5985
+ tenderVoidSchema,
5845
5986
  toOrderCreateEnvelope,
5846
5987
  unbindRegister,
5847
5988
  useCurrencyCode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/pos",
3
- "version": "3.5.3",
3
+ "version": "3.7.0",
4
4
  "type": "module",
5
5
  "description": "Shared POS business logic for Tally UI — tax, orders, payments, discounts, receipts",
6
6
  "main": "./dist/index.js",
@@ -27,20 +27,20 @@
27
27
  "directory": "packages/pos"
28
28
  },
29
29
  "peerDependencies": {
30
- "@tallyui/core": "^3.5.3",
30
+ "@tallyui/core": "^3.7.0",
31
31
  "react": ">=18",
32
32
  "rxdb": "~17.5.0",
33
33
  "rxjs": ">=7"
34
34
  },
35
35
  "devDependencies": {
36
- "@tallyui/core": "3.5.3",
36
+ "@tallyui/core": "3.7.0",
37
37
  "@types/react": ">=18",
38
38
  "react": "^19.2.4",
39
39
  "rxdb": "17.5.0",
40
40
  "rxjs": "7.8.2",
41
- "@tallyui/connector-medusa": "3.5.3",
42
- "@tallyui/connector-woocommerce": "3.5.3",
43
- "@tallyui/database": "3.5.3"
41
+ "@tallyui/connector-medusa": "3.7.0",
42
+ "@tallyui/connector-woocommerce": "3.7.0",
43
+ "@tallyui/database": "3.7.0"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsup",
package/src/index.ts CHANGED
@@ -51,8 +51,8 @@ export type {
51
51
  } from './order';
52
52
 
53
53
  // Receipt
54
- export { buildReceiptData } from './receipt';
55
- export type { ReceiptData, ReceiptLineItem, ReceiptConfig } from './receipt';
54
+ export { buildReceiptData, RECEIPT_SCHEMA_VERSION } from './receipt';
55
+ export type { ReceiptData, ReceiptLineItem, ReceiptConfig, ReceiptSoftware, ReceiptRegister, ReceiptFiscal } from './receipt';
56
56
 
57
57
  // Product
58
58
  export { searchProducts, sortProducts, productSortValue, PRODUCT_SORT_FIELDS, type ProductSort, type ProductSortValue, catalogueViewReducer, normalizeCatalogueViewState, resolveGridColumns, DEFAULT_CATALOGUE_VIEW_STATE, type CatalogueView, type CatalogueGridColumns, type CatalogueViewState, type CatalogueViewAction, withStockOverlay, getProductStock, stockOverlay$, stockOverlayAsOf$, productCategories, listCategories, inCategory } from './product';
@@ -69,19 +69,20 @@ export type { RegisterOutboxOptions, RegisterOutbox, UseRegisterOutboxOptions, U
69
69
  export { createBackendNotFound } from './outbox/backend-not-found';
70
70
  export type { BackendNotFound } from './outbox/backend-not-found';
71
71
  export type { HttpTransportOptions, OrderOutboxOptions, OrderOutbox, TransportOutcome, CommandTransport, OrderTransportContext, OutboxState, UseOrderOutboxOptions, UseOrderOutboxResult } from './outbox';
72
- export { tenderReducer, initTenderState, initialTenderState, appliedMinor, changeMinor, quickTenderedAmounts, evenSplitShareMinor, activePlan, planLegs, MAX_TENDER_MINOR } from './tender';
73
- export type { PaymentTransport, TenderView, TenderLineId, TenderPlan, SplitTab, TenderState, TenderKey, TenderAction, PlanLeg } from './tender';
72
+ export { tenderReducer, initTenderState, initialTenderState, appliedMinor, changeMinor, quickTenderedAmounts, evenSplitShareMinor, activePlan, planLegs, MAX_TENDER_MINOR, tenderVoidSchema, tenderVoidCollection } from './tender';
73
+ export type { PaymentTransport, TenderView, TenderLineId, TenderPlan, SplitTab, TenderState, TenderKey, TenderAction, PlanLeg, TenderVoid } from './tender';
74
74
 
75
75
  // Register
76
76
  export { normalizeAmount, isServerDecimal, movementFieldError, deriveExpected, parseMinor, validAmount, countVariance, overThreshold, closeNeedsApproval, denominationTotal, varianceText, denominations } from './register';
77
77
  export type { MovementType, LedgerRow, Movement } from './register';
78
78
  export { registerSessionSchema, registerSessionCollection, cashMovementSchema, closureSchema } from './register';
79
+ export { addRegisterSessionCollection, RegisterSessionOpenClosedError, registerSessionsLogger, REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS } from './register';
79
80
  export type { RegisterSession, CashMovement, Closure } from './register';
80
81
  export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands } from './register';
81
82
  export type { RegisterCommand, RegisterCommandCollection } from './register';
82
83
  export { mintUuid, readRegister, ensureRegister, observeRegister$, getBoundRegisterId, readBoundRegister, bindRegister, unbindRegister, nextSaleCounter, mintClosureNumber, advancePerpetual, RegisterIdInvalidError } from './register';
83
84
  export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register';
84
- export { RegisterSessionRequiredError, RegisterSessionClosedError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector, requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure } from './register';
85
+ export { RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector, requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure } from './register';
85
86
  export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './register';
86
87
  export { deriveSettled, exportCsv, labelKeys, clampClosureScope, selectClosureRows } from './register';
87
88
  export type { Correction, RecordedFigures, ClosureScope } from './register';
@@ -7,6 +7,9 @@ import { createBackendNotFound, type BackendNotFound } from './backend-not-found
7
7
  import { STUCK_AFTER_MS } from './order-outbox';
8
8
  import type { CommandTransport, OutboxState } from './types';
9
9
 
10
+ /** Final rejections that do not stop their register's ledger (ADR-078): the commands behind them are still sent. */
11
+ export const NON_BLOCKING_REJECTIONS: ReadonlySet<string> = new Set(['register_session_superseded', 'register_session_abandoned']);
12
+
10
13
  export interface RegisterOutboxOptions {
11
14
  collection: RxCollection<RegisterCommand>;
12
15
  transport: CommandTransport<RegisterCommandEnvelope>;
@@ -68,7 +71,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
68
71
  // like a 503, or `no_progress`); a running clock keeps its `since` and takes the latest reason. A `network` failure
69
72
  // pauses every clock (`pausedAt`), and the next answer of any kind resumes them, moving `since` on by the offline
70
73
  // gap, so `since` is now minus the answered time. A clock clears when its command is no longer pending under its
71
- // commandId (applied or rejected), or when a rejected command comes ahead of it in its ledger (it is not being
74
+ // commandId (applied or rejected), or when a blocking rejected command comes ahead of it in its ledger (it is not being
72
75
  // sent: Front desk, 2026-09-30); commands never sent have none. Nothing is isolated: a stuck command at the head
73
76
  // of a register's ledger holds up the commands behind it, by design, since register facts apply in `seq` order.
74
77
  // In memory only: a restart starts with no clocks, and the first answered failure after it starts them afresh
@@ -96,8 +99,9 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
96
99
  const blocked = new Set<string>();
97
100
  if (clocks.size) for (const command of await readFresh(collection, { selector: { syncStatus: { $in: ['pending', 'rejected'] } },
98
101
  sort: [{ seq: 'asc' }, { key: 'asc' }] })) {
99
- if (command.syncStatus === 'rejected') blocked.add(command.registerId);
100
- else if (!blocked.has(command.registerId)) sendable.add(command.commandId);
102
+ if (command.syncStatus === 'rejected') {
103
+ if (!NON_BLOCKING_REJECTIONS.has(command.error?.code ?? '')) blocked.add(command.registerId);
104
+ } else if (!blocked.has(command.registerId)) sendable.add(command.commandId);
101
105
  }
102
106
  for (const id of clocks.keys()) if (!sendable.has(id)) clocks.delete(id);
103
107
  state$.next({ ...state$.value, ...patch, pending, stuck: stuckState() });
@@ -127,9 +131,10 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
127
131
  });
128
132
  const commands: RegisterCommand[] = [];
129
133
  for (const command of ledger) {
134
+ if (command.syncStatus === 'rejected' && NON_BLOCKING_REJECTIONS.has(command.error?.code ?? '')) continue;
130
135
  if (command.syncStatus === 'rejected') break;
131
136
  commands.push(command);
132
- if (commands.length >= batchSize) break;
137
+ if (command.type === 'register.session.open' || commands.length >= batchSize) break;
133
138
  }
134
139
  if (stopped) { stoppedDuringRun = true; return; }
135
140
  if (!commands.length) continue;
@@ -6,11 +6,12 @@ import type { PosOrder } from './types';
6
6
  * session closed (`lateSessionId`) or with local warnings (whatever the sync status), or pending with a `commandId` in
7
7
  * `stuckCommandIds` (the outbox's `OutboxState.stuck`: the store keeps failing it), newest first.
8
8
  * A warning code this till version doesn't know is not shown here (`knownWarnings`). Never changes `orders`.
9
+ * `register_session_unknown` is not shown, because the store counts the order once its session arrives (ADR-078).
9
10
  */
10
11
  export function needsAttention(orders: PosOrder[], options: { stuckCommandIds?: readonly string[] } = {}): PosOrder[] {
11
12
  return orders.filter((order) => order.syncStatus === 'rejected'
12
13
  || (order.localWarnings?.length ?? 0) > 0
13
- || (order.syncStatus === 'applied' && knownWarnings(order.warnings).length > 0) || order.lateSessionId !== undefined
14
+ || (order.syncStatus === 'applied' && knownWarnings(order.warnings).some((warning) => warning.code !== 'register_session_unknown')) || order.lateSessionId !== undefined
14
15
  || (order.syncStatus === 'pending' && options.stuckCommandIds?.includes(order.commandId) === true))
15
16
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
16
17
  }