@tallyui/pos 3.7.0 → 3.8.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.d.ts CHANGED
@@ -1074,8 +1074,8 @@ interface RegisterSession {
1074
1074
  register_id: string;
1075
1075
  /** The app's neutral store key (see `bindRegister`). */
1076
1076
  store_key?: string | null;
1077
- /** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. */
1078
- status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded';
1077
+ /** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. `abandoned` is local only and terminal (the cashier chose another register while in conflict). */
1078
+ status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded' | 'abandoned';
1079
1079
  /** The store session this local id was resumed as (ADR-078 decision 2), never the session that superseded it; null until a resume. */
1080
1080
  server_session_id?: string | null;
1081
1081
  /** The store's day the session opened, `yyyy-MM-dd`. */
@@ -1261,6 +1261,15 @@ declare class RegisterNeedsUpgradeError extends Error {
1261
1261
  readonly code = "REGISTER_NEEDS_UPGRADE";
1262
1262
  constructor();
1263
1263
  }
1264
+ /** Opening is refused while the register has a `conflict` session (ADR-078); its message can be shown to a cashier as it is. */
1265
+ declare class RegisterSessionConflictError extends Error {
1266
+ readonly code = "REGISTER_SESSION_CONFLICT";
1267
+ constructor();
1268
+ }
1269
+ declare class RegisterTakeOverError extends Error {
1270
+ readonly code: 'REGISTER_TAKEOVER_UNSUPPORTED' | 'REGISTER_TAKEOVER_PENDING';
1271
+ constructor(code: 'REGISTER_TAKEOVER_UNSUPPORTED' | 'REGISTER_TAKEOVER_PENDING');
1272
+ }
1264
1273
  /** ADR-068 6a: movement amounts must be safe integers, positive for paid_in/paid_out and zero for no_sale. */
1265
1274
  declare class RegisterMovementAmountError extends Error {
1266
1275
  constructor(type: 'paid_in' | 'paid_out' | 'no_sale', amountMinor: number);
@@ -1840,9 +1849,11 @@ declare const registerCommandCollection: () => {
1840
1849
  schema: RxJsonSchema<RegisterCommand>;
1841
1850
  };
1842
1851
  type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & {
1843
- version: 1;
1852
+ version: 1 | 2;
1844
1853
  };
1845
- declare function sessionOpenCommand(s: RegisterSession): BuiltCommand;
1854
+ declare function sessionOpenCommand(s: RegisterSession, v2?: {
1855
+ deviceName?: string | null;
1856
+ }): BuiltCommand;
1846
1857
  declare function sessionTransitionCommand(s: RegisterSession & {
1847
1858
  status: RegisterSessionTransitionPayload['status'];
1848
1859
  status_at: string;
@@ -1850,7 +1861,7 @@ declare function sessionTransitionCommand(s: RegisterSession & {
1850
1861
  declare function movementCommand(m: CashMovement): BuiltCommand;
1851
1862
  declare function closureCommand(c: Closure): BuiltCommand;
1852
1863
  /** Appends missing facts in dependency order; the bytes of an existing key never change. */
1853
- declare function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }: {
1864
+ declare function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed, registerContract, deviceName }: {
1854
1865
  commands: RegisterCommandCollection;
1855
1866
  sessions: RegisterSessionCollection;
1856
1867
  movements: CashMovementCollection;
@@ -1860,6 +1871,8 @@ declare function reconcileRegisterCommands({ commands, sessions, movements, clos
1860
1871
  registerId: string;
1861
1872
  now?: string;
1862
1873
  observed?: RegisterSession[];
1874
+ registerContract?: number;
1875
+ deviceName?: string | null;
1863
1876
  }): Promise<string[]>;
1864
1877
 
1865
1878
  /** A sale is at tender, so the till can't count or close under it. Its message can be shown to a cashier as it is. */
@@ -1888,6 +1901,8 @@ interface UseRegisterSessionOptions {
1888
1901
  /** `register_commands`, created with `registerCommandCollection()`; `null` while it opens. */
1889
1902
  commands?: RegisterCommandCollection | null;
1890
1903
  capabilities?: ServerCapabilities;
1904
+ /** The till's name from the app's settings, sent on a register v2 open; the app supplies its platform fallback. */
1905
+ deviceName?: string | null;
1891
1906
  /** `pos_orders`: a session's sales are those whose `sessionId` is its id. */
1892
1907
  orders: RxCollection<PosOrder> | null;
1893
1908
  /** The register document's host, for the closure number and perpetual totals (`writeClosure`); `null` while it opens. */
@@ -1920,6 +1935,20 @@ interface UseRegisterSessionOptions {
1920
1935
  }
1921
1936
  declare function useRegisterSession(options: UseRegisterSessionOptions): {
1922
1937
  session: RegisterSession | null;
1938
+ /** The other till's live session, or the session this till is waiting to take over. */
1939
+ conflict: {
1940
+ sessionId: string;
1941
+ takingOver: boolean;
1942
+ openedAt?: undefined;
1943
+ openedBy?: undefined;
1944
+ deviceName?: undefined;
1945
+ } | {
1946
+ sessionId: string;
1947
+ openedAt: string | undefined;
1948
+ openedBy: string | undefined;
1949
+ deviceName: string | undefined;
1950
+ takingOver: boolean;
1951
+ } | undefined;
1923
1952
  /** Any unknown session status in the current snapshot means the register needs upgrade. */
1924
1953
  needsUpgrade: boolean;
1925
1954
  movements: CashMovement[];
@@ -1946,11 +1975,14 @@ declare function useRegisterSession(options: UseRegisterSessionOptions): {
1946
1975
  sessions: RegisterSessionCollection;
1947
1976
  } | null>;
1948
1977
  actions: {
1978
+ takeOver: () => Promise<void>;
1979
+ chooseAnotherRegister: () => Promise<void>;
1949
1980
  /**
1950
1981
  * Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
1951
1982
  * another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
1952
1983
  * while an earlier close hasn't finished: a new session would lock the till behind it.
1953
1984
  * An unknown stored status refuses with `RegisterNeedsUpgradeError`.
1985
+ * A conflict session refuses with `RegisterSessionConflictError`; superseded and abandoned sessions do not block.
1954
1986
  */
1955
1987
  openSession: (input: {
1956
1988
  expectedFloatMinor: number | null;
@@ -1982,6 +2014,25 @@ declare function useRegisterSession(options: UseRegisterSessionOptions): {
1982
2014
  };
1983
2015
  };
1984
2016
 
2017
+ /**
2018
+ * Applied opens fill a missing resume id; refused opens make open/counting sessions conflict.
2019
+ * Superseded rejections make open/counting sessions terminal; a conflict session leaves only by an applied open or abandon (ADR-078 decision 9); terminal/unknown states stay untouched.
2020
+ * Applied conflict opens restore the latest transition's counting state, otherwise open; pending rows do nothing.
2021
+ * Answers are walked in seq/key order; adoption is idempotent and safe to run at any time.
2022
+ */
2023
+ declare function adoptRegisterResults({ commands, sessions, registerId }: {
2024
+ commands: RegisterCommandCollection;
2025
+ sessions: RegisterSessionCollection;
2026
+ registerId: string;
2027
+ }): Promise<void>;
2028
+ declare function takeOverSession({ commands, sessions, sessionId, now }: {
2029
+ commands: RegisterCommandCollection;
2030
+ sessions: RegisterSessionCollection;
2031
+ sessionId: string;
2032
+ now?: string;
2033
+ }): Promise<void>;
2034
+ declare function abandonSession(input: Parameters<typeof takeOverSession>[0]): Promise<void>;
2035
+
1985
2036
  type CatalogueEntry<Doc> = {
1986
2037
  product: Doc;
1987
2038
  variant: VariantSummary;
@@ -2287,6 +2338,9 @@ declare function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox;
2287
2338
 
2288
2339
  interface RegisterOutboxOptions {
2289
2340
  collection: RxCollection<RegisterCommand>;
2341
+ /** The till's `register_sessions`. With it, a refused open (`register_session_already_open`) stops blocking its register
2342
+ * once its session is `abandoned` (ADR-078 decision 9). Without it, that refusal always blocks. */
2343
+ sessions?: RegisterSessionCollection;
2290
2344
  transport: CommandTransport<RegisterCommandEnvelope>;
2291
2345
  deviceId: string;
2292
2346
  /** Checked at the start of every run; false sends nothing. */
@@ -2314,6 +2368,8 @@ declare function createRegisterOutbox(options: RegisterOutboxOptions): RegisterO
2314
2368
  interface UseRegisterOutboxOptions {
2315
2369
  /** `register_commands` (see `registerCommandCollection()`); `null` while it opens or when there's no store. A new collection (a store switch) restarts the outbox. */
2316
2370
  commands: RegisterCommandCollection | null;
2371
+ /** The till's `register_sessions`; abandoned sessions unblock refused opens. A new collection restarts the outbox. */
2372
+ sessions?: RegisterSessionCollection | null;
2317
2373
  /** Builds the transport; called when the outbox is created: on each new collection or device id (twice under React StrictMode's double effect, which stops the first outbox). */
2318
2374
  transport(): CommandTransport<RegisterCommandEnvelope>;
2319
2375
  /** The device id sent on every command. A change restarts the outbox. */
@@ -2577,4 +2633,4 @@ declare function tenderVoidCollection(): {
2577
2633
  readonly schema: RxJsonSchema<TenderVoid>;
2578
2634
  };
2579
2635
 
2580
- export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type CatalogueGridColumns, type CatalogueView, type CatalogueViewAction, type CatalogueViewState, type ChargeInput, type ChargeLine, type Closure, type ClosureCollection, type ClosureContext, type ClosureScope, type CommandTransport, type Correction, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, DEFAULT_CATALOGUE_VIEW_STATE, DISCOUNTS_UNSUPPORTED, type Discount, type DisplayLine, type DisplayTotals, type FeeLine, type FinalizeOptions, type HttpTransportOptions, type LedgerRow, type LineItem, type LineTaxLine, type LogEntry, type LogLevel, type LogSink, type Logger, MAX_TENDER_MINOR, MICROS_PER_MINOR, type Movement$1 as Movement, type MovementType, type Order, type OrderBuilder, type OrderBuilderOptions, OrderContentMismatchError, type OrderManager, type OrderManagerOptions, type OrderOutbox, type OrderOutboxOptions, type OrderTaxTotals, type OrderTransportContext, type OutboxState, PRODUCT_SORT_FIELDS, type ParkedOrderSummary, type Payment, type PaymentMethod, type PaymentTransport, type PlanLeg, type PosOrder, type PosOrderLine, type PosOrderLocalWarning, PosOrderOpenClosedError, type PosOrderPayment, type PosOrderServerFailures, type PosOrderSyncStatus, type ProductSort, type ProductSortValue, RECEIPT_SCHEMA_VERSION, REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS, type RateTaxLine, type ReceiptConfig, type ReceiptData, type ReceiptFiscal, type ReceiptLineItem, type ReceiptRegister, type ReceiptSoftware, type RecordedFigures, RegisterApprovalRequiredError, type RegisterBucket, RegisterCloseIncompleteError, type RegisterCommand, type RegisterCommandCollection, type RegisterCounters, type RegisterDocument, type RegisterFact, type RegisterHost, RegisterIdInvalidError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, RegisterNeedsUpgradeError, type RegisterOutbox, type RegisterOutboxOptions, type RegisterSession, RegisterSessionAlreadyOpenError, RegisterSessionClosedError, type RegisterSessionCollection, RegisterSessionOpenClosedError, RegisterSessionRequiredError, type RegisterStore, RegisterTenderInProgressError, type Repository, type ResolveStoreSettingsOptions, SALE_SAVING, type SaleStage, type SentOrder, type ShippingLine, type SplitTab, type StoreSettingsResolution, type StoreSettingsState, TAX_ROUNDING_MIXED_NOTE, type TaxContext, type TaxLineInput, TaxProvider, type TaxProviderProps, type TaxRateMap, type TenderAction, type TenderKey, type TenderLineId, type TenderPlan, type TenderState, type TenderView, type TenderVoid, type TransportOutcome, UnsupportedOrderVersionError, type UseOrderOutboxOptions, type UseOrderOutboxResult, type UseRegisterOutboxOptions, type UseRegisterOutboxResult, type UseRegisterSessionOptions, activePlan, addEntryToCart, addPosOrderCollection, addRegisterSessionCollection, advancePerpetual, allocateOrderDiscount, appliedMinor, backToSelling, bindRegister, buildClosureDocument, buildReceiptData, buildXReportDocument, callbackSink, cashMovementSchema, catalogueEntries, catalogueViewReducer, changeMinor, clampClosureScope, closeNeedsApproval, closeSession, closureCommand, closureSchema, computeOrderTax, consoleSink, countVariance, createBackendNotFound, createHttpCommandTransport, createLogger, createOrderBuilder, createOrderManager, createOrderOutbox, createRegisterOutbox, createRepository, denominationTotal, denominations, deriveExpected, deriveSettled, ensureRegister, evenSplitShareMinor, exportCsv, finalizeOrder, findEntryByCode, formatClosureDate, getBoundRegisterId, getDeviceId, inCategory, initTenderState, initialTenderState, isKnownSessionStatus, isServerDecimal, labelKeys, listCategories, minorToDecimal, mintClosureNumber, mintUuid, movementCommand, movementFieldError, needsAttention, nextSaleCounter, normalizeAmount, normalizeCatalogueViewState, observeRegister$, openSession, openSessionSelector, orderDraftSchema, outboxLogger, overThreshold, parkedOrderSummaries$, parseMinor, planLegs, posOrderCollection, posOrderSchema, posOrdersLogger, productCategories, productSortValue, quickTenderedAmounts, ratePpmFromPercent, readBoundRegister, readRegister, reconcileRegisterCommands, recordMovement, recordRegisterFact, registerCommandCollection, registerCommandSchema, registerCommandsLogger, registerFactsLogger, registerSessionCollection, registerSessionSchema, registerSessionsLogger, requireOpenSession, resolveGridColumns, resolveStoreSettings, restoreOrderDraft, roundMicrosToMinor, saleLogger, sameSale, searchProducts, selectClosureRows, sessionOpenCommand, sessionTransitionCommand, sortProducts, stampSession, startCounting, stockOverlay$, stockOverlayAsOf$, taxFiguresForBasket, taxLinesByRate, taxLogger, taxMicros, taxProviderProps, tenderReducer, tenderVoidCollection, tenderVoidSchema, toOrderCreateEnvelope, unbindRegister, useCurrencyCode, useCurrencyFormatter, useOrderOutbox, useParkedSales, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure, writeOrderDraft };
2636
+ export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type CatalogueGridColumns, type CatalogueView, type CatalogueViewAction, type CatalogueViewState, type ChargeInput, type ChargeLine, type Closure, type ClosureCollection, type ClosureContext, type ClosureScope, type CommandTransport, type Correction, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, DEFAULT_CATALOGUE_VIEW_STATE, DISCOUNTS_UNSUPPORTED, type Discount, type DisplayLine, type DisplayTotals, type FeeLine, type FinalizeOptions, type HttpTransportOptions, type LedgerRow, type LineItem, type LineTaxLine, type LogEntry, type LogLevel, type LogSink, type Logger, MAX_TENDER_MINOR, MICROS_PER_MINOR, type Movement$1 as Movement, type MovementType, type Order, type OrderBuilder, type OrderBuilderOptions, OrderContentMismatchError, type OrderManager, type OrderManagerOptions, type OrderOutbox, type OrderOutboxOptions, type OrderTaxTotals, type OrderTransportContext, type OutboxState, PRODUCT_SORT_FIELDS, type ParkedOrderSummary, type Payment, type PaymentMethod, type PaymentTransport, type PlanLeg, type PosOrder, type PosOrderLine, type PosOrderLocalWarning, PosOrderOpenClosedError, type PosOrderPayment, type PosOrderServerFailures, type PosOrderSyncStatus, type ProductSort, type ProductSortValue, RECEIPT_SCHEMA_VERSION, REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS, type RateTaxLine, type ReceiptConfig, type ReceiptData, type ReceiptFiscal, type ReceiptLineItem, type ReceiptRegister, type ReceiptSoftware, type RecordedFigures, RegisterApprovalRequiredError, type RegisterBucket, RegisterCloseIncompleteError, type RegisterCommand, type RegisterCommandCollection, type RegisterCounters, type RegisterDocument, type RegisterFact, type RegisterHost, RegisterIdInvalidError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, RegisterNeedsUpgradeError, type RegisterOutbox, type RegisterOutboxOptions, type RegisterSession, RegisterSessionAlreadyOpenError, RegisterSessionClosedError, type RegisterSessionCollection, RegisterSessionConflictError, RegisterSessionOpenClosedError, RegisterSessionRequiredError, type RegisterStore, RegisterTakeOverError, RegisterTenderInProgressError, type Repository, type ResolveStoreSettingsOptions, SALE_SAVING, type SaleStage, type SentOrder, type ShippingLine, type SplitTab, type StoreSettingsResolution, type StoreSettingsState, TAX_ROUNDING_MIXED_NOTE, type TaxContext, type TaxLineInput, TaxProvider, type TaxProviderProps, type TaxRateMap, type TenderAction, type TenderKey, type TenderLineId, type TenderPlan, type TenderState, type TenderView, type TenderVoid, type TransportOutcome, UnsupportedOrderVersionError, type UseOrderOutboxOptions, type UseOrderOutboxResult, type UseRegisterOutboxOptions, type UseRegisterOutboxResult, type UseRegisterSessionOptions, abandonSession, activePlan, addEntryToCart, addPosOrderCollection, addRegisterSessionCollection, adoptRegisterResults, advancePerpetual, allocateOrderDiscount, appliedMinor, backToSelling, bindRegister, buildClosureDocument, buildReceiptData, buildXReportDocument, callbackSink, cashMovementSchema, catalogueEntries, catalogueViewReducer, changeMinor, clampClosureScope, closeNeedsApproval, closeSession, closureCommand, closureSchema, computeOrderTax, consoleSink, countVariance, createBackendNotFound, createHttpCommandTransport, createLogger, createOrderBuilder, createOrderManager, createOrderOutbox, createRegisterOutbox, createRepository, denominationTotal, denominations, deriveExpected, deriveSettled, ensureRegister, evenSplitShareMinor, exportCsv, finalizeOrder, findEntryByCode, formatClosureDate, getBoundRegisterId, getDeviceId, inCategory, initTenderState, initialTenderState, isKnownSessionStatus, isServerDecimal, labelKeys, listCategories, minorToDecimal, mintClosureNumber, mintUuid, movementCommand, movementFieldError, needsAttention, nextSaleCounter, normalizeAmount, normalizeCatalogueViewState, observeRegister$, openSession, openSessionSelector, orderDraftSchema, outboxLogger, overThreshold, parkedOrderSummaries$, parseMinor, planLegs, posOrderCollection, posOrderSchema, posOrdersLogger, productCategories, productSortValue, quickTenderedAmounts, ratePpmFromPercent, readBoundRegister, readRegister, reconcileRegisterCommands, recordMovement, recordRegisterFact, registerCommandCollection, registerCommandSchema, registerCommandsLogger, registerFactsLogger, registerSessionCollection, registerSessionSchema, registerSessionsLogger, requireOpenSession, resolveGridColumns, resolveStoreSettings, restoreOrderDraft, roundMicrosToMinor, saleLogger, sameSale, searchProducts, selectClosureRows, sessionOpenCommand, sessionTransitionCommand, sortProducts, stampSession, startCounting, stockOverlay$, stockOverlayAsOf$, takeOverSession, taxFiguresForBasket, taxLinesByRate, taxLogger, taxMicros, taxProviderProps, tenderReducer, tenderVoidCollection, tenderVoidSchema, toOrderCreateEnvelope, unbindRegister, useCurrencyCode, useCurrencyFormatter, useOrderOutbox, useParkedSales, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure, writeOrderDraft };
package/dist/index.js CHANGED
@@ -2813,6 +2813,20 @@ var RegisterNeedsUpgradeError = class extends Error {
2813
2813
  this.name = "RegisterNeedsUpgradeError";
2814
2814
  }
2815
2815
  };
2816
+ var RegisterSessionConflictError = class extends Error {
2817
+ code = "REGISTER_SESSION_CONFLICT";
2818
+ constructor() {
2819
+ super("This register is open on another till. Take it over or choose another register.");
2820
+ this.name = "RegisterSessionConflictError";
2821
+ }
2822
+ };
2823
+ var RegisterTakeOverError = class extends Error {
2824
+ constructor(code) {
2825
+ super(code === "REGISTER_TAKEOVER_UNSUPPORTED" ? "This store cannot take over a register. Choose another register." : "Taking over this register is still waiting for the store.");
2826
+ this.code = code;
2827
+ this.name = "RegisterTakeOverError";
2828
+ }
2829
+ };
2816
2830
  var RegisterMovementAmountError = class extends Error {
2817
2831
  constructor(type, amountMinor) {
2818
2832
  super(`movement_amount_invalid:${type}:${amountMinor}`);
@@ -2840,7 +2854,8 @@ var TRANSITIONS = {
2840
2854
  counting: ["open", "closed"],
2841
2855
  closed: [],
2842
2856
  conflict: [],
2843
- superseded: []
2857
+ superseded: [],
2858
+ abandoned: []
2844
2859
  };
2845
2860
  function isKnownSessionStatus(status) {
2846
2861
  return Object.hasOwn(TRANSITIONS, status);
@@ -2858,6 +2873,7 @@ async function requireLiveSession(sessions, id2) {
2858
2873
  if (!session) throw new RegisterSessionRequiredError();
2859
2874
  if (!isKnownSessionStatus(session.status)) throw new RegisterNeedsUpgradeError();
2860
2875
  if (session.status === "closed") throw new RegisterSessionClosedError();
2876
+ if (session.status === "abandoned") throw new RegisterSessionClosedError();
2861
2877
  return session;
2862
2878
  }
2863
2879
  var openSessionSelector = { status: "open" };
@@ -3603,8 +3619,17 @@ var registerCommandSchema = {
3603
3619
  indexes: [["registerId", "seq"], ["syncStatus", "registerId", "seq"]]
3604
3620
  };
3605
3621
  var registerCommandCollection = () => ({ schema: registerCommandSchema });
3606
- function sessionOpenCommand(s) {
3607
- return { key: `session.open:${s.id}`, type: "register.session.open", version: 1, payload: {
3622
+ function deviceNameOf(name) {
3623
+ let trimmed = name?.trim();
3624
+ if (trimmed && trimmed.length > 64) {
3625
+ const last = trimmed.charCodeAt(63);
3626
+ trimmed = trimmed.slice(0, last >= 55296 && last <= 56319 ? 63 : 64).trimEnd();
3627
+ }
3628
+ return trimmed || void 0;
3629
+ }
3630
+ function sessionOpenCommand(s, v2) {
3631
+ const deviceName = deviceNameOf(v2?.deviceName);
3632
+ return { key: `session.open:${s.id}`, type: "register.session.open", version: v2 ? 2 : 1, payload: {
3608
3633
  sessionId: s.id,
3609
3634
  registerId: s.register_id,
3610
3635
  openedAt: s.opened_at_gmt,
@@ -3613,7 +3638,8 @@ function sessionOpenCommand(s) {
3613
3638
  ...s.business_day == null ? {} : { businessDay: s.business_day },
3614
3639
  ...s.opened_by == null ? {} : { openedBy: s.opened_by },
3615
3640
  ...s.expected_float_minor == null ? {} : { expectedFloatMinor: s.expected_float_minor },
3616
- ...s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor }
3641
+ ...s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor },
3642
+ ...deviceName === void 0 ? {} : { deviceName }
3617
3643
  } };
3618
3644
  }
3619
3645
  function sessionTransitionCommand(s) {
@@ -3670,7 +3696,7 @@ function closureCommand(c) {
3670
3696
  } };
3671
3697
  }
3672
3698
  var chains = /* @__PURE__ */ new WeakMap();
3673
- function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }) {
3699
+ function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed, registerContract, deviceName }) {
3674
3700
  const sentStatus = (s) => s.status === "open" || s.status === "counting" || s.status === "closed";
3675
3701
  let registers = chains.get(commands);
3676
3702
  if (!registers) chains.set(commands, registers = /* @__PURE__ */ new Map());
@@ -3681,7 +3707,7 @@ function reconcileRegisterCommands({ commands, sessions, movements, closures, ho
3681
3707
  const transitioned = new Set((await readFresh(commands, { selector: { registerId, type: "register.session.transition" } })).map(({ payload }) => payload.sessionId));
3682
3708
  const rows = await readFresh(sessions, { selector: { register_id: registerId } });
3683
3709
  const complete = new Set((await commands.storageInstance.findDocumentsById(rows.flatMap((session) => [`session.open:${session.id}`, ...session.status === "closed" && session.closure_id ? [`closure.submit:${session.closure_id}`] : []]), false)).map(({ key }) => key));
3684
- const facts = await Promise.all(rows.filter((session) => session.status !== "closed" || (session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`)) && !complete.has(`closure.submit:${session.closure_id}`)).map(async (session) => {
3710
+ const facts = await Promise.all(rows.filter((session) => session.status !== "abandoned" && (session.status !== "closed" || (session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`)) && !complete.has(`closure.submit:${session.closure_id}`))).map(async (session) => {
3685
3711
  const [entries, closureRows] = await Promise.all([
3686
3712
  readFresh(movements, { selector: { session_id: session.id } }),
3687
3713
  session.closure_id ? closures.storageInstance.findDocumentsById([session.closure_id], false) : Promise.resolve([])
@@ -3693,7 +3719,7 @@ function reconcileRegisterCommands({ commands, sessions, movements, closures, ho
3693
3719
  );
3694
3720
  if (session.status_at != null && sentStatus(session)) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
3695
3721
  return { session, number: closureRows[0]?.number, built: [
3696
- sessionOpenCommand(session),
3722
+ (registerContract ?? 0) >= 2 ? sessionOpenCommand(session, { deviceName }) : sessionOpenCommand(session),
3697
3723
  ...events,
3698
3724
  ...closureRows.map((closure) => closureCommand(closure))
3699
3725
  ] };
@@ -3721,6 +3747,94 @@ function reconcileRegisterCommands({ commands, sessions, movements, closures, ho
3721
3747
  return run;
3722
3748
  }
3723
3749
 
3750
+ // src/register/register-adoption.ts
3751
+ var chains2 = /* @__PURE__ */ new WeakMap();
3752
+ async function adoptRegisterResults({ commands, sessions, registerId }) {
3753
+ const run = (chains2.get(commands) ?? Promise.resolve()).then(async () => {
3754
+ const [answers, transitions, rows] = await Promise.all([
3755
+ readFresh(commands, { selector: { registerId, syncStatus: { $in: ["applied", "rejected"] } }, sort: [{ seq: "asc" }, { key: "asc" }] }),
3756
+ readFresh(commands, { selector: { registerId, type: "register.session.transition" }, sort: [{ seq: "desc" }, { key: "desc" }] }),
3757
+ readFresh(sessions, { selector: { register_id: registerId } })
3758
+ ]);
3759
+ for (const session of rows) {
3760
+ if (session.status === "conflict" && answers.some((answer) => answer.payload.sessionId === session.id && answer.syncStatus === "rejected" && answer.error?.code === "register_session_abandoned")) {
3761
+ await finishAbandon({ commands, sessions, sessionId: session.id });
3762
+ continue;
3763
+ }
3764
+ const latest = transitions.find(({ payload }) => payload.sessionId === session.id);
3765
+ const modify = (doc) => {
3766
+ let next = doc;
3767
+ for (const answer of answers) {
3768
+ if (!isKnownSessionStatus(next.status) || next.status === "closed" || next.status === "superseded" || next.status === "abandoned") break;
3769
+ if (answer.payload.sessionId !== doc.id) continue;
3770
+ if (answer.syncStatus === "rejected") {
3771
+ if (answer.error?.code === "register_session_superseded" && (next.status === "open" || next.status === "counting")) next = { ...next, status: "superseded" };
3772
+ else if (answer.type === "register.session.open" && answer.error?.code === "register_session_already_open" && (next.status === "open" || next.status === "counting")) next = { ...next, status: "conflict" };
3773
+ } else if (answer.type === "register.session.open") {
3774
+ const resumed = answer.result?.resumed?.fromSessionId;
3775
+ if (typeof resumed === "string" && next.server_session_id == null) next = { ...next, server_session_id: resumed };
3776
+ if (next.status === "conflict") next = { ...next, status: latest?.payload.status === "counting" ? "counting" : "open" };
3777
+ }
3778
+ }
3779
+ return next;
3780
+ };
3781
+ if (modify(session) === session) continue;
3782
+ const row = await sessions.findOne(session.id).exec();
3783
+ if (row) await row.incrementalModify(modify);
3784
+ }
3785
+ }).catch((error) => {
3786
+ registerCommandsLogger.error("Register result adoption failed", { context: { registerId, error: String(error) } });
3787
+ });
3788
+ chains2.set(commands, run.then(() => void 0, () => void 0));
3789
+ return run;
3790
+ }
3791
+ async function takeOverSession({ commands, sessions, sessionId, now }) {
3792
+ const run = (chains2.get(commands) ?? Promise.resolve()).then(async () => {
3793
+ const [session] = await readFresh(sessions, { selector: { id: sessionId } });
3794
+ const row = await commands.findOne(`session.open:${sessionId}`).exec();
3795
+ if (session?.status !== "conflict" || !row) throw new RegisterSessionRequiredError();
3796
+ await row.incrementalModify((doc) => {
3797
+ if (doc.syncStatus !== "rejected" || doc.error?.code !== "register_session_already_open" || typeof doc.error.data?.sessionId !== "string") throw new RegisterSessionRequiredError();
3798
+ const { error, ...rest } = doc;
3799
+ return {
3800
+ ...rest,
3801
+ commandId: uuidv7(),
3802
+ version: 2,
3803
+ syncStatus: "pending",
3804
+ payload: { ...doc.payload, supersedes: error.data.sessionId },
3805
+ updatedAt: now ?? (/* @__PURE__ */ new Date()).toISOString()
3806
+ };
3807
+ });
3808
+ });
3809
+ chains2.set(commands, run.then(() => void 0, () => void 0));
3810
+ return run;
3811
+ }
3812
+ async function finishAbandon({ commands, sessions, sessionId, now }) {
3813
+ const rows = await readFresh(commands, { selector: { "payload.sessionId": sessionId, syncStatus: "pending" } });
3814
+ for (const row of rows) {
3815
+ await (await commands.findOne(row.key).exec(true)).incrementalModify((doc) => doc.syncStatus !== "pending" ? doc : {
3816
+ ...doc,
3817
+ syncStatus: "rejected",
3818
+ error: { code: "register_session_abandoned", message: "The cashier chose another register." },
3819
+ updatedAt: now ?? (/* @__PURE__ */ new Date()).toISOString()
3820
+ });
3821
+ }
3822
+ await (await sessions.findOne(sessionId).exec(true)).incrementalModify((doc) => doc.status === "conflict" ? { ...doc, status: "abandoned" } : doc);
3823
+ }
3824
+ async function abandonSession(input) {
3825
+ const { commands, sessions, sessionId } = input;
3826
+ const run = (chains2.get(commands) ?? Promise.resolve()).then(async () => {
3827
+ const [session] = await readFresh(sessions, { selector: { id: sessionId } });
3828
+ if (session?.status === "abandoned") return;
3829
+ if (session?.status !== "conflict") throw new RegisterSessionRequiredError();
3830
+ const [open] = await readFresh(commands, { selector: { key: `session.open:${sessionId}` } });
3831
+ if (open?.syncStatus === "pending") throw new RegisterTakeOverError("REGISTER_TAKEOVER_PENDING");
3832
+ await finishAbandon(input);
3833
+ });
3834
+ chains2.set(commands, run.then(() => void 0, () => void 0));
3835
+ return run;
3836
+ }
3837
+
3724
3838
  // src/register/use-register-session.ts
3725
3839
  var RegisterTenderInProgressError = class extends Error {
3726
3840
  constructor() {
@@ -3761,7 +3875,7 @@ var reservationOf = (register, storeKey, registerId) => register?.stores[storeKe
3761
3875
  var unwritten = (row, closureRows) => row.status === "closed" && row.closure_id === row.id && !closureRows.some((closure) => closure.id === row.id);
3762
3876
  function currentSession(rows, closureRows, reservation) {
3763
3877
  if (rows.some((row) => !isKnownSessionStatus(row.status))) return null;
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;
3878
+ return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : void 0) ?? rows.find((row) => row.status === "open" || row.status === "counting") ?? rows.find((row) => row.status === "conflict") ?? rows.find((row) => unwritten(row, closureRows)) ?? null;
3765
3879
  }
3766
3880
  function ledgerRows(orders) {
3767
3881
  return orders.flatMap((order) => order.payments.map((payment) => ({
@@ -3777,8 +3891,8 @@ function useRegisterSession(options) {
3777
3891
  const { commands } = options;
3778
3892
  const commandEnabled = (options.capabilities?.register ?? 0) >= 1;
3779
3893
  const commandTarget = useMemo3(
3780
- () => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId ? { commands, sessions, movements, closures, host: register, storeKey, registerId } : null,
3781
- [enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId]
3894
+ () => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId ? { commands, sessions, movements, closures, host: register, storeKey, registerId, registerContract: options.capabilities?.register, deviceName: options.deviceName } : null,
3895
+ [enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId, options.capabilities?.register, options.deviceName]
3782
3896
  );
3783
3897
  const commandTargetRef = useRef2(commandTarget);
3784
3898
  commandTargetRef.current = commandTarget;
@@ -3793,6 +3907,13 @@ function useRegisterSession(options) {
3793
3907
  });
3794
3908
  };
3795
3909
  useEffect3(reconcile, [commandTarget]);
3910
+ useEffect3(() => {
3911
+ if (!commandTarget) return;
3912
+ const subscription = commandTarget.commands.find({ selector: { registerId: commandTarget.registerId } }).$.subscribe(() => {
3913
+ void adoptRegisterResults(commandTarget);
3914
+ });
3915
+ return () => subscription.unsubscribe();
3916
+ }, [commandTarget]);
3796
3917
  const source = useMemo3(() => {
3797
3918
  if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
3798
3919
  return combineLatest([
@@ -3804,10 +3925,11 @@ function useRegisterSession(options) {
3804
3925
  if (!current2) return of({ rows, closureRows, reservation, entries: [], sales: [] });
3805
3926
  return combineLatest([
3806
3927
  watchFresh(movements, { selector: { session_id: current2.id } }),
3807
- watchFresh(orders, { selector: { sessionId: current2.id } })
3808
- ]).pipe(map5(([entries2, sales2]) => ({ rows, closureRows, reservation, entries: entries2, sales: sales2 })));
3928
+ watchFresh(orders, { selector: { sessionId: current2.id } }),
3929
+ commandTarget ? watchFresh(commandTarget.commands, { selector: { key: `session.open:${current2.id}` } }) : of([])
3930
+ ]).pipe(map5(([entries2, sales2, opens]) => ({ rows, closureRows, reservation, entries: entries2, sales: sales2, open: opens[0] })));
3809
3931
  }));
3810
- }, [enabled, sessions, movements, closures, orders, register, storeKey, registerId]);
3932
+ }, [enabled, sessions, movements, closures, orders, register, storeKey, registerId, commandTarget]);
3811
3933
  const tender = useRef2(tenderInProgress);
3812
3934
  tender.current = tenderInProgress;
3813
3935
  const closing = useSyncExternalStore(subscribeClosing, () => closingByRegister.has(registerId ?? ""));
@@ -3862,6 +3984,8 @@ function useRegisterSession(options) {
3862
3984
  }
3863
3985
  }, [data, source]);
3864
3986
  const session = data ? currentSession(data.rows, data.closureRows, data.reservation) : null;
3987
+ const refusal = data?.open?.error?.data;
3988
+ const conflict = session?.status !== "conflict" || !commandTarget ? void 0 : data?.open?.syncStatus === "pending" && typeof data.open.payload.supersedes === "string" ? { sessionId: data.open.payload.supersedes, takingOver: true } : data?.open?.syncStatus === "rejected" && refusal ? { sessionId: refusal.sessionId, openedAt: refusal.openedAt, openedBy: refusal.openedBy, deviceName: refusal.deviceName, takingOver: false } : void 0;
3865
3989
  const entries = data?.entries ?? [];
3866
3990
  const sales = data?.sales ?? [];
3867
3991
  const expected = session ? deriveExpected({ session: { id: session.id, countedFloatMinor: session.counted_float_minor }, movements: entries, ledgerRowsBySession: ledgerRows(sales) }) : {};
@@ -3887,6 +4011,8 @@ function useRegisterSession(options) {
3887
4011
  const nameOf = (id2) => !id2 ? "" : id2 === actor.id ? actor.name : labels?.resolveCashierName?.(id2) ?? "";
3888
4012
  return {
3889
4013
  session,
4014
+ /** The other till's live session, or the session this till is waiting to take over. */
4015
+ conflict,
3890
4016
  /** Any unknown session status in the current snapshot means the register needs upgrade. */
3891
4017
  needsUpgrade: data?.rows.some((row) => !isKnownSessionStatus(row.status)) ?? false,
3892
4018
  movements: entries,
@@ -3901,17 +4027,39 @@ function useRegisterSession(options) {
3901
4027
  enabled,
3902
4028
  blind: options.blind ?? false,
3903
4029
  /** Pass straight to useSale's `session` option: `complete()` then stamps through `stampSession`. */
3904
- saleSession: session && sessions && session.status !== "closed" ? { id: session.id, sessions } : void 0,
4030
+ saleSession: session && sessions && (session.status === "open" || session.status === "counting") ? { id: session.id, sessions } : void 0,
3905
4031
  /** The open session's id, `null` when sessions are off, else `RegisterSessionRequiredError`. Call it when tender starts and before a card terminal captures. */
3906
4032
  requireOpen,
3907
4033
  /** `requireOpen()`, returning `{ id, sessions }`: pass it to useSale's `startTender`, which pins it (the rendered `saleSession` can lag). */
3908
4034
  requireSaleSession,
3909
4035
  actions: {
4036
+ takeOver: async () => {
4037
+ if (!commandTarget) throw new RegisterSessionRequiredError();
4038
+ const row = currentSession(
4039
+ await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
4040
+ await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
4041
+ reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId)
4042
+ );
4043
+ if (row?.status !== "conflict") throw new RegisterSessionRequiredError();
4044
+ if ((options.capabilities?.register ?? 0) < 2) throw new RegisterTakeOverError("REGISTER_TAKEOVER_UNSUPPORTED");
4045
+ await takeOverSession({ ...commandTarget, sessionId: row.id });
4046
+ },
4047
+ chooseAnotherRegister: async () => {
4048
+ if (!commandTarget) throw new RegisterSessionRequiredError();
4049
+ const row = currentSession(
4050
+ await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
4051
+ await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
4052
+ reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId)
4053
+ );
4054
+ if (row?.status !== "conflict") throw new RegisterSessionRequiredError();
4055
+ await abandonSession({ ...commandTarget, sessionId: row.id });
4056
+ },
3910
4057
  /**
3911
4058
  * Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
3912
4059
  * another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
3913
4060
  * while an earlier close hasn't finished: a new session would lock the till behind it.
3914
4061
  * An unknown stored status refuses with `RegisterNeedsUpgradeError`.
4062
+ * A conflict session refuses with `RegisterSessionConflictError`; superseded and abandoned sessions do not block.
3915
4063
  */
3916
4064
  openSession: async (input) => {
3917
4065
  const key = registerId ?? "";
@@ -3920,7 +4068,8 @@ function useRegisterSession(options) {
3920
4068
  const { sessions: sessions2, closures: closures2, registerId: registerId2, register: register2 } = live();
3921
4069
  const rows = await readFresh(sessions2, { selector: { register_id: registerId2 } });
3922
4070
  if (rows.some((row2) => !isKnownSessionStatus(row2.status))) throw new RegisterNeedsUpgradeError();
3923
- if (rows.some((row2) => row2.status !== "closed")) throw new RegisterSessionAlreadyOpenError();
4071
+ if (rows.some((row2) => row2.status === "conflict")) throw new RegisterSessionConflictError();
4072
+ if (rows.some((row2) => row2.status === "open" || row2.status === "counting")) throw new RegisterSessionAlreadyOpenError();
3924
4073
  const closureRows = await readFresh(closures2, { selector: { register_id: registerId2 } });
3925
4074
  const reservation = reservationOf(await readRegister(register2), storeKey, registerId2);
3926
4075
  if (reservation && !reservation.applied || rows.some((row2) => unwritten(row2, closureRows))) {
@@ -5203,7 +5352,7 @@ function plainCommand({
5203
5352
  });
5204
5353
  }
5205
5354
  function createRegisterOutbox(options) {
5206
- const { collection, transport, deviceId } = options;
5355
+ const { collection, sessions, transport, deviceId } = options;
5207
5356
  const size = options.batchSize;
5208
5357
  const batchSize = Math.max(1, Math.min(typeof size === "number" && Number.isFinite(size) ? Math.floor(size) : 10, 10));
5209
5358
  const initialBackoff = options.initialBackoffMs ?? 1e3;
@@ -5223,6 +5372,7 @@ function createRegisterOutbox(options) {
5223
5372
  let running;
5224
5373
  let timer;
5225
5374
  let subscription;
5375
+ let sessionsSubscription;
5226
5376
  let stopped = false;
5227
5377
  let insertedDuringRun = false;
5228
5378
  let stoppedDuringRun = false;
@@ -5244,7 +5394,11 @@ function createRegisterOutbox(options) {
5244
5394
  const at2 = now();
5245
5395
  for (const { commandId } of commands) clocks.set(commandId, { ...clocks.get(commandId) ?? { since: at2, firstFailedAt: at2 }, seq: ++failureSeq, reason });
5246
5396
  }
5397
+ function isNonBlocking(command, abandoned) {
5398
+ return NON_BLOCKING_REJECTIONS.has(command.error?.code ?? "") || command.error?.code === "register_session_already_open" && typeof command.payload.sessionId === "string" && abandoned.has(command.payload.sessionId);
5399
+ }
5247
5400
  async function updateState(patch = {}) {
5401
+ const abandoned = new Set(sessions && clocks.size ? (await readFresh(sessions, { selector: { status: "abandoned" } })).map((session) => session.id) : []);
5248
5402
  const pending = await countFresh(collection, { syncStatus: "pending" });
5249
5403
  const sendable2 = /* @__PURE__ */ new Set();
5250
5404
  const blocked = /* @__PURE__ */ new Set();
@@ -5253,7 +5407,7 @@ function createRegisterOutbox(options) {
5253
5407
  sort: [{ seq: "asc" }, { key: "asc" }]
5254
5408
  })) {
5255
5409
  if (command.syncStatus === "rejected") {
5256
- if (!NON_BLOCKING_REJECTIONS.has(command.error?.code ?? "")) blocked.add(command.registerId);
5410
+ if (!isNonBlocking(command, abandoned)) blocked.add(command.registerId);
5257
5411
  } else if (!blocked.has(command.registerId)) sendable2.add(command.commandId);
5258
5412
  }
5259
5413
  for (const id2 of clocks.keys()) if (!sendable2.has(id2)) clocks.delete(id2);
@@ -5282,6 +5436,7 @@ function createRegisterOutbox(options) {
5282
5436
  if (options.isEnabled?.() === false) return;
5283
5437
  const pending = await readFresh(collection, { selector: { syncStatus: "pending" } });
5284
5438
  const registerIds = [...new Set(pending.map((command) => command.registerId))].sort();
5439
+ const abandoned = new Set(sessions ? (await readFresh(sessions, { selector: { status: "abandoned" } })).map((session) => session.id) : []);
5285
5440
  let sent = false;
5286
5441
  for (const registerId of registerIds) {
5287
5442
  const ledger = await readFresh(collection, {
@@ -5290,7 +5445,7 @@ function createRegisterOutbox(options) {
5290
5445
  });
5291
5446
  const commands = [];
5292
5447
  for (const command of ledger) {
5293
- if (command.syncStatus === "rejected" && NON_BLOCKING_REJECTIONS.has(command.error?.code ?? "")) continue;
5448
+ if (command.syncStatus === "rejected" && isNonBlocking(command, abandoned)) continue;
5294
5449
  if (command.syncStatus === "rejected") break;
5295
5450
  commands.push(command);
5296
5451
  if (command.type === "register.session.open" || commands.length >= batchSize) break;
@@ -5426,6 +5581,13 @@ function createRegisterOutbox(options) {
5426
5581
  });
5427
5582
  }
5428
5583
  });
5584
+ if (sessions && !sessionsSubscription) sessionsSubscription = sessions.$.subscribe((event) => {
5585
+ if (event.documentData?.status === "abandoned") {
5586
+ insertedDuringRun = true;
5587
+ flush().catch(() => {
5588
+ });
5589
+ }
5590
+ });
5429
5591
  if (!stopped) flush().catch(() => {
5430
5592
  });
5431
5593
  },
@@ -5435,6 +5597,8 @@ function createRegisterOutbox(options) {
5435
5597
  timer = void 0;
5436
5598
  subscription?.unsubscribe();
5437
5599
  subscription = void 0;
5600
+ sessionsSubscription?.unsubscribe();
5601
+ sessionsSubscription = void 0;
5438
5602
  missingSubscription?.unsubscribe();
5439
5603
  missingSubscription = void 0;
5440
5604
  state$.next({ ...state$.value, nextAttemptAt: void 0 });
@@ -5446,7 +5610,7 @@ function createRegisterOutbox(options) {
5446
5610
  import { useEffect as useEffect5, useRef as useRef4, useState as useState5 } from "react";
5447
5611
  var idle = { pending: 0, sending: false };
5448
5612
  function useRegisterOutbox(options) {
5449
- const { commands, deviceId } = options;
5613
+ const { commands, sessions, deviceId } = options;
5450
5614
  const latest = useRef4(options);
5451
5615
  latest.current = options;
5452
5616
  const current = useRef4(null);
@@ -5458,6 +5622,7 @@ function useRegisterOutbox(options) {
5458
5622
  collection: commands,
5459
5623
  deviceId,
5460
5624
  transport: latest.current.transport(),
5625
+ sessions: sessions ?? void 0,
5461
5626
  isEnabled: latest.current.isEnabled ? () => latest.current.isEnabled?.() ?? true : void 0,
5462
5627
  onResult: latest.current.onResult ? async (command, result) => {
5463
5628
  await latest.current.onResult?.(command, result);
@@ -5473,7 +5638,7 @@ function useRegisterOutbox(options) {
5473
5638
  current.current = null;
5474
5639
  setState(idle);
5475
5640
  };
5476
- }, [commands, deviceId]);
5641
+ }, [commands, sessions, deviceId]);
5477
5642
  const opened = current.current;
5478
5643
  return {
5479
5644
  state: opened?.commands === commands && opened.deviceId === deviceId ? state : idle,
@@ -5860,17 +6025,21 @@ export {
5860
6025
  RegisterNeedsUpgradeError,
5861
6026
  RegisterSessionAlreadyOpenError,
5862
6027
  RegisterSessionClosedError,
6028
+ RegisterSessionConflictError,
5863
6029
  RegisterSessionOpenClosedError,
5864
6030
  RegisterSessionRequiredError,
6031
+ RegisterTakeOverError,
5865
6032
  RegisterTenderInProgressError,
5866
6033
  SALE_SAVING,
5867
6034
  TAX_ROUNDING_MIXED_NOTE,
5868
6035
  TaxProvider,
5869
6036
  UnsupportedOrderVersionError,
6037
+ abandonSession,
5870
6038
  activePlan,
5871
6039
  addEntryToCart,
5872
6040
  addPosOrderCollection,
5873
6041
  addRegisterSessionCollection,
6042
+ adoptRegisterResults,
5874
6043
  advancePerpetual,
5875
6044
  allocateOrderDiscount,
5876
6045
  appliedMinor,
@@ -5975,6 +6144,7 @@ export {
5975
6144
  startCounting,
5976
6145
  stockOverlay$,
5977
6146
  stockOverlayAsOf$,
6147
+ takeOverSession,
5978
6148
  taxFiguresForBasket,
5979
6149
  taxLinesByRate,
5980
6150
  taxLogger,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/pos",
3
- "version": "3.7.0",
3
+ "version": "3.8.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.7.0",
30
+ "@tallyui/core": "^3.8.0",
31
31
  "react": ">=18",
32
32
  "rxdb": "~17.5.0",
33
33
  "rxjs": ">=7"
34
34
  },
35
35
  "devDependencies": {
36
- "@tallyui/core": "3.7.0",
36
+ "@tallyui/core": "3.8.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.7.0",
42
- "@tallyui/connector-woocommerce": "3.7.0",
43
- "@tallyui/database": "3.7.0"
41
+ "@tallyui/connector-medusa": "3.8.0",
42
+ "@tallyui/connector-woocommerce": "3.8.0",
43
+ "@tallyui/database": "3.8.0"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsup",
package/src/index.ts CHANGED
@@ -78,11 +78,11 @@ export type { MovementType, LedgerRow, Movement } from './register';
78
78
  export { registerSessionSchema, registerSessionCollection, cashMovementSchema, closureSchema } from './register';
79
79
  export { addRegisterSessionCollection, RegisterSessionOpenClosedError, registerSessionsLogger, REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS } from './register';
80
80
  export type { RegisterSession, CashMovement, Closure } from './register';
81
- export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands } from './register';
81
+ export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands, adoptRegisterResults, takeOverSession, abandonSession } from './register';
82
82
  export type { RegisterCommand, RegisterCommandCollection } from './register';
83
83
  export { mintUuid, readRegister, ensureRegister, observeRegister$, getBoundRegisterId, readBoundRegister, bindRegister, unbindRegister, nextSaleCounter, mintClosureNumber, advancePerpetual, RegisterIdInvalidError } from './register';
84
84
  export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register';
85
- export { RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector, requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure } from './register';
85
+ export { RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, RegisterSessionConflictError, RegisterTakeOverError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector, requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure } from './register';
86
86
  export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './register';
87
87
  export { deriveSettled, exportCsv, labelKeys, clampClosureScope, selectClosureRows } from './register';
88
88
  export type { Correction, RecordedFigures, ClosureScope } from './register';
@@ -2,6 +2,7 @@ import type { CommandResult, RegisterCommandEnvelope } from '@tallyui/core';
2
2
  import type { RxCollection } from 'rxdb';
3
3
  import { BehaviorSubject, type Observable, type Subscription } from 'rxjs';
4
4
  import { registerCommandsLogger, type RegisterCommand } from '../register/register-commands';
5
+ import type { RegisterSessionCollection } from '../register/session-store';
5
6
  import { countFresh, readFresh } from '../rxdb';
6
7
  import { createBackendNotFound, type BackendNotFound } from './backend-not-found';
7
8
  import { STUCK_AFTER_MS } from './order-outbox';
@@ -12,6 +13,9 @@ export const NON_BLOCKING_REJECTIONS: ReadonlySet<string> = new Set(['register_s
12
13
 
13
14
  export interface RegisterOutboxOptions {
14
15
  collection: RxCollection<RegisterCommand>;
16
+ /** The till's `register_sessions`. With it, a refused open (`register_session_already_open`) stops blocking its register
17
+ * once its session is `abandoned` (ADR-078 decision 9). Without it, that refusal always blocks. */
18
+ sessions?: RegisterSessionCollection;
15
19
  transport: CommandTransport<RegisterCommandEnvelope>;
16
20
  deviceId: string;
17
21
  /** Checked at the start of every run; false sends nothing. */
@@ -42,7 +46,7 @@ function plainCommand({ key, registerId, seq, commandId, type, version, payload,
42
46
  }
43
47
 
44
48
  export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOutbox {
45
- const { collection, transport, deviceId } = options;
49
+ const { collection, sessions, transport, deviceId } = options;
46
50
  const size = options.batchSize;
47
51
  const batchSize = Math.max(1, Math.min(typeof size === 'number' && Number.isFinite(size) ? Math.floor(size) : 10, 10));
48
52
  const initialBackoff = options.initialBackoffMs ?? 1000;
@@ -62,6 +66,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
62
66
  let running: Promise<void> | undefined;
63
67
  let timer: ReturnType<typeof setTimeout> | undefined;
64
68
  let subscription: Subscription | undefined;
69
+ let sessionsSubscription: Subscription | undefined;
65
70
  let stopped = false;
66
71
  let insertedDuringRun = false;
67
72
  let stoppedDuringRun = false;
@@ -92,7 +97,15 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
92
97
  for (const { commandId } of commands) clocks.set(commandId, { ...(clocks.get(commandId) ?? { since: at, firstFailedAt: at }), seq: ++failureSeq, reason });
93
98
  }
94
99
 
100
+ function isNonBlocking(command: RegisterCommand, abandoned: ReadonlySet<string>): boolean {
101
+ return NON_BLOCKING_REJECTIONS.has(command.error?.code ?? '') ||
102
+ (command.error?.code === 'register_session_already_open' &&
103
+ typeof command.payload.sessionId === 'string' && abandoned.has(command.payload.sessionId));
104
+ }
105
+
95
106
  async function updateState(patch: Partial<OutboxState> = {}) {
107
+ const abandoned = new Set(sessions && clocks.size
108
+ ? (await readFresh(sessions, { selector: { status: 'abandoned' } })).map((session) => session.id) : []);
96
109
  const pending = await countFresh(collection, { syncStatus: 'pending' });
97
110
  // Only commands in a register's sendable prefix (pending, before its first rejected command) keep a clock.
98
111
  const sendable = new Set<string>();
@@ -100,7 +113,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
100
113
  if (clocks.size) for (const command of await readFresh(collection, { selector: { syncStatus: { $in: ['pending', 'rejected'] } },
101
114
  sort: [{ seq: 'asc' }, { key: 'asc' }] })) {
102
115
  if (command.syncStatus === 'rejected') {
103
- if (!NON_BLOCKING_REJECTIONS.has(command.error?.code ?? '')) blocked.add(command.registerId);
116
+ if (!isNonBlocking(command, abandoned)) blocked.add(command.registerId);
104
117
  } else if (!blocked.has(command.registerId)) sendable.add(command.commandId);
105
118
  }
106
119
  for (const id of clocks.keys()) if (!sendable.has(id)) clocks.delete(id);
@@ -123,6 +136,8 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
123
136
  if (options.isEnabled?.() === false) return;
124
137
  const pending = await readFresh(collection, { selector: { syncStatus: 'pending' } });
125
138
  const registerIds = [...new Set(pending.map((command) => command.registerId))].sort();
139
+ const abandoned = new Set(sessions
140
+ ? (await readFresh(sessions, { selector: { status: 'abandoned' } })).map((session) => session.id) : []);
126
141
  let sent = false;
127
142
  for (const registerId of registerIds) {
128
143
  const ledger = await readFresh(collection, {
@@ -131,7 +146,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
131
146
  });
132
147
  const commands: RegisterCommand[] = [];
133
148
  for (const command of ledger) {
134
- if (command.syncStatus === 'rejected' && NON_BLOCKING_REJECTIONS.has(command.error?.code ?? '')) continue;
149
+ if (command.syncStatus === 'rejected' && isNonBlocking(command, abandoned)) continue;
135
150
  if (command.syncStatus === 'rejected') break;
136
151
  commands.push(command);
137
152
  if (command.type === 'register.session.open' || commands.length >= batchSize) break;
@@ -241,6 +256,12 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
241
256
  flush().catch(() => {});
242
257
  }
243
258
  });
259
+ if (sessions && !sessionsSubscription) sessionsSubscription = sessions.$.subscribe((event) => {
260
+ if (event.documentData?.status === 'abandoned') {
261
+ insertedDuringRun = true;
262
+ flush().catch(() => {});
263
+ }
264
+ });
244
265
  if (!stopped) flush().catch(() => {});
245
266
  },
246
267
  stop() {
@@ -249,6 +270,8 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
249
270
  timer = undefined;
250
271
  subscription?.unsubscribe();
251
272
  subscription = undefined;
273
+ sessionsSubscription?.unsubscribe();
274
+ sessionsSubscription = undefined;
252
275
  missingSubscription?.unsubscribe(); missingSubscription = undefined;
253
276
  state$.next({ ...state$.value, nextAttemptAt: undefined });
254
277
  },
@@ -1,6 +1,7 @@
1
1
  import type { RegisterCommandEnvelope } from '@tallyui/core';
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import type { RegisterCommandCollection } from '../register/register-commands';
4
+ import type { RegisterSessionCollection } from '../register/session-store';
4
5
  import { createRegisterOutbox, type RegisterOutboxOptions } from './register-outbox';
5
6
  import type { CommandTransport, OutboxState } from './types';
6
7
 
@@ -9,6 +10,8 @@ const idle: OutboxState = { pending: 0, sending: false };
9
10
  export interface UseRegisterOutboxOptions {
10
11
  /** `register_commands` (see `registerCommandCollection()`); `null` while it opens or when there's no store. A new collection (a store switch) restarts the outbox. */
11
12
  commands: RegisterCommandCollection | null;
13
+ /** The till's `register_sessions`; abandoned sessions unblock refused opens. A new collection restarts the outbox. */
14
+ sessions?: RegisterSessionCollection | null;
12
15
  /** Builds the transport; called when the outbox is created: on each new collection or device id (twice under React StrictMode's double effect, which stops the first outbox). */
13
16
  transport(): CommandTransport<RegisterCommandEnvelope>;
14
17
  /** The device id sent on every command. A change restarts the outbox. */
@@ -27,7 +30,7 @@ export interface UseRegisterOutboxResult {
27
30
  }
28
31
 
29
32
  export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegisterOutboxResult {
30
- const { commands, deviceId } = options;
33
+ const { commands, sessions, deviceId } = options;
31
34
  const latest = useRef(options);
32
35
  latest.current = options;
33
36
  const current = useRef<{ commands: RegisterCommandCollection; deviceId: string; outbox: ReturnType<typeof createRegisterOutbox> } | null>(null);
@@ -37,6 +40,7 @@ export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegiste
37
40
  setState(idle);
38
41
  if (!commands) return;
39
42
  const outbox = createRegisterOutbox({ collection: commands, deviceId, transport: latest.current.transport(),
43
+ sessions: sessions ?? undefined,
40
44
  isEnabled: latest.current.isEnabled ? () => latest.current.isEnabled?.() ?? true : undefined,
41
45
  onResult: latest.current.onResult ? async (command, result) => { await latest.current.onResult?.(command, result); } : undefined,
42
46
  backendNotFound: latest.current.backendNotFound });
@@ -44,7 +48,7 @@ export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegiste
44
48
  const status = outbox.state$.subscribe(setState);
45
49
  outbox.start();
46
50
  return () => { outbox.stop(); status.unsubscribe(); current.current = null; setState(idle); };
47
- }, [commands, deviceId]);
51
+ }, [commands, sessions, deviceId]);
48
52
 
49
53
  const opened = current.current;
50
54
  return { state: opened?.commands === commands && opened.deviceId === deviceId ? state : idle,
@@ -13,7 +13,7 @@ export {
13
13
  } from './register-document';
14
14
  export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register-document';
15
15
  export {
16
- RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector,
16
+ RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, RegisterSessionConflictError, RegisterTakeOverError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector,
17
17
  requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure,
18
18
  } from './session-store';
19
19
  export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './session-store';
@@ -30,4 +30,5 @@ export { registerFactsLogger, recordRegisterFact, type Actor, type RegisterFact
30
30
  export { useRegisterSession, RegisterTenderInProgressError, RegisterSessionAlreadyOpenError, RegisterCloseIncompleteError, RegisterApprovalRequiredError } from './use-register-session';
31
31
  export type { UseRegisterSessionOptions } from './use-register-session';
32
32
  export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands } from './register-commands';
33
+ export { adoptRegisterResults, takeOverSession, abandonSession } from './register-adoption';
33
34
  export type { RegisterCommand, RegisterCommandCollection } from './register-commands';
@@ -0,0 +1,102 @@
1
+ import { readFresh } from '../rxdb';
2
+ import { uuidv7 } from '../pos-order/uuidv7';
3
+ import { registerCommandsLogger, type RegisterCommandCollection } from './register-commands';
4
+ import type { RegisterSession } from './schemas';
5
+ import { isKnownSessionStatus, RegisterSessionRequiredError, RegisterTakeOverError, type RegisterSessionCollection } from './session-store';
6
+
7
+ const chains = new WeakMap<RegisterCommandCollection, Promise<void>>();
8
+
9
+ /**
10
+ * Applied opens fill a missing resume id; refused opens make open/counting sessions conflict.
11
+ * Superseded rejections make open/counting sessions terminal; a conflict session leaves only by an applied open or abandon (ADR-078 decision 9); terminal/unknown states stay untouched.
12
+ * Applied conflict opens restore the latest transition's counting state, otherwise open; pending rows do nothing.
13
+ * Answers are walked in seq/key order; adoption is idempotent and safe to run at any time.
14
+ */
15
+ export async function adoptRegisterResults({ commands, sessions, registerId }: {
16
+ commands: RegisterCommandCollection; sessions: RegisterSessionCollection; registerId: string;
17
+ }): Promise<void> {
18
+ const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
19
+ const [answers, transitions, rows] = await Promise.all([
20
+ readFresh(commands, { selector: { registerId, syncStatus: { $in: ['applied', 'rejected'] } }, sort: [{ seq: 'asc' }, { key: 'asc' }] }),
21
+ readFresh(commands, { selector: { registerId, type: 'register.session.transition' }, sort: [{ seq: 'desc' }, { key: 'desc' }] }),
22
+ readFresh(sessions, { selector: { register_id: registerId } }),
23
+ ]);
24
+ for (const session of rows) {
25
+ if (session.status === 'conflict' && answers.some((answer) => answer.payload.sessionId === session.id
26
+ && answer.syncStatus === 'rejected' && answer.error?.code === 'register_session_abandoned')) {
27
+ await finishAbandon({ commands, sessions, sessionId: session.id });
28
+ continue;
29
+ }
30
+ const latest = transitions.find(({ payload }) => payload.sessionId === session.id);
31
+ const modify = (doc: RegisterSession): RegisterSession => {
32
+ let next = doc;
33
+ for (const answer of answers) {
34
+ if (!isKnownSessionStatus(next.status) || next.status === 'closed' || next.status === 'superseded' || next.status === 'abandoned') break;
35
+ if (answer.payload.sessionId !== doc.id) continue;
36
+ if (answer.syncStatus === 'rejected') {
37
+ if (answer.error?.code === 'register_session_superseded'
38
+ && (next.status === 'open' || next.status === 'counting')) next = { ...next, status: 'superseded' };
39
+ else if (answer.type === 'register.session.open' && answer.error?.code === 'register_session_already_open'
40
+ && (next.status === 'open' || next.status === 'counting')) next = { ...next, status: 'conflict' };
41
+ } else if (answer.type === 'register.session.open') {
42
+ const resumed = answer.result?.resumed?.fromSessionId;
43
+ if (typeof resumed === 'string' && next.server_session_id == null) next = { ...next, server_session_id: resumed };
44
+ if (next.status === 'conflict') next = { ...next, status: latest?.payload.status === 'counting' ? 'counting' : 'open' };
45
+ }
46
+ }
47
+ return next;
48
+ };
49
+ if (modify(session) === session) continue;
50
+ const row = await sessions.findOne(session.id).exec();
51
+ if (row) await row.incrementalModify(modify);
52
+ }
53
+ }).catch((error: unknown) => {
54
+ registerCommandsLogger.error('Register result adoption failed', { context: { registerId, error: String(error) } });
55
+ });
56
+ chains.set(commands, run.then(() => undefined, () => undefined));
57
+ return run;
58
+ }
59
+
60
+ export async function takeOverSession({ commands, sessions, sessionId, now }: {
61
+ commands: RegisterCommandCollection; sessions: RegisterSessionCollection; sessionId: string; now?: string;
62
+ }): Promise<void> {
63
+ const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
64
+ const [session] = await readFresh(sessions, { selector: { id: sessionId } });
65
+ const row = await commands.findOne(`session.open:${sessionId}`).exec();
66
+ if (session?.status !== 'conflict' || !row) throw new RegisterSessionRequiredError();
67
+ await row.incrementalModify((doc) => {
68
+ if (doc.syncStatus !== 'rejected' || doc.error?.code !== 'register_session_already_open'
69
+ || typeof doc.error.data?.sessionId !== 'string') throw new RegisterSessionRequiredError();
70
+ const { error, ...rest } = doc;
71
+ return { ...rest, commandId: uuidv7(), version: 2, syncStatus: 'pending',
72
+ payload: { ...doc.payload, supersedes: error.data!.sessionId }, updatedAt: now ?? new Date().toISOString() };
73
+ });
74
+ });
75
+ chains.set(commands, run.then(() => undefined, () => undefined));
76
+ return run;
77
+ }
78
+
79
+ async function finishAbandon({ commands, sessions, sessionId, now }: Parameters<typeof takeOverSession>[0]) {
80
+ const rows = await readFresh(commands, { selector: { 'payload.sessionId': sessionId, syncStatus: 'pending' } });
81
+ for (const row of rows) {
82
+ await (await commands.findOne(row.key).exec(true)).incrementalModify((doc) => doc.syncStatus !== 'pending' ? doc : ({
83
+ ...doc, syncStatus: 'rejected', error: { code: 'register_session_abandoned', message: 'The cashier chose another register.' },
84
+ updatedAt: now ?? new Date().toISOString(),
85
+ }));
86
+ }
87
+ await (await sessions.findOne(sessionId).exec(true)).incrementalModify((doc) => doc.status === 'conflict' ? { ...doc, status: 'abandoned' } : doc);
88
+ }
89
+
90
+ export async function abandonSession(input: Parameters<typeof takeOverSession>[0]): Promise<void> {
91
+ const { commands, sessions, sessionId } = input;
92
+ const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
93
+ const [session] = await readFresh(sessions, { selector: { id: sessionId } });
94
+ if (session?.status === 'abandoned') return;
95
+ if (session?.status !== 'conflict') throw new RegisterSessionRequiredError();
96
+ const [open] = await readFresh(commands, { selector: { key: `session.open:${sessionId}` } });
97
+ if (open?.syncStatus === 'pending') throw new RegisterTakeOverError('REGISTER_TAKEOVER_PENDING');
98
+ await finishAbandon(input);
99
+ });
100
+ chains.set(commands, run.then(() => undefined, () => undefined));
101
+ return run;
102
+ }
@@ -38,16 +38,28 @@ export const registerCommandSchema: RxJsonSchema<RegisterCommand> = {
38
38
  };
39
39
  export const registerCommandCollection = () => ({ schema: registerCommandSchema });
40
40
 
41
- type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & { version: 1 };
41
+ type BuiltCommand = Pick<RegisterCommand, 'key' | 'type' | 'payload'> & { version: 1 | 2 };
42
42
 
43
- export function sessionOpenCommand(s: RegisterSession): BuiltCommand {
44
- return { key: `session.open:${s.id}`, type: 'register.session.open', version: 1, payload: {
43
+ /** ADR-078: 1 to 64 characters after trim. */
44
+ export function deviceNameOf(name: string | null | undefined): string | undefined {
45
+ let trimmed = name?.trim();
46
+ if (trimmed && trimmed.length > 64) {
47
+ const last = trimmed.charCodeAt(63);
48
+ trimmed = trimmed.slice(0, last >= 0xd800 && last <= 0xdbff ? 63 : 64).trimEnd();
49
+ }
50
+ return trimmed || undefined;
51
+ }
52
+
53
+ export function sessionOpenCommand(s: RegisterSession, v2?: { deviceName?: string | null }): BuiltCommand {
54
+ const deviceName = deviceNameOf(v2?.deviceName);
55
+ return { key: `session.open:${s.id}`, type: 'register.session.open', version: v2 ? 2 : 1, payload: {
45
56
  sessionId: s.id, registerId: s.register_id, openedAt: s.opened_at_gmt, countedFloatMinor: s.counted_float_minor,
46
57
  ...(s.store_key == null ? {} : { storeKey: s.store_key }),
47
58
  ...(s.business_day == null ? {} : { businessDay: s.business_day }),
48
59
  ...(s.opened_by == null ? {} : { openedBy: s.opened_by }),
49
60
  ...(s.expected_float_minor == null ? {} : { expectedFloatMinor: s.expected_float_minor }),
50
61
  ...(s.opening_variance_minor == null ? {} : { openingVarianceMinor: s.opening_variance_minor }),
62
+ ...(deviceName === undefined ? {} : { deviceName }),
51
63
  } satisfies RegisterSessionOpenPayload };
52
64
  }
53
65
 
@@ -88,9 +100,10 @@ export function closureCommand(c: Closure): BuiltCommand {
88
100
  const chains = new WeakMap<RegisterCommandCollection, Map<string, Promise<void>>>();
89
101
 
90
102
  /** Appends missing facts in dependency order; the bytes of an existing key never change. */
91
- export function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed }: {
103
+ export function reconcileRegisterCommands({ commands, sessions, movements, closures, host, storeKey, registerId, now, observed, registerContract, deviceName }: {
92
104
  commands: RegisterCommandCollection; sessions: RegisterSessionCollection; movements: CashMovementCollection;
93
105
  closures: ClosureCollection; host: RegisterHost; storeKey: string; registerId: string; now?: string; observed?: RegisterSession[];
106
+ registerContract?: number; deviceName?: string | null;
94
107
  }): Promise<string[]> {
95
108
  // `conflict` and `superseded` are local states derived from the store's answers (ADR-078 decision 9), never sent as a transition.
96
109
  const sentStatus = (s: RegisterSession): s is RegisterSession & { status: RegisterSessionTransitionPayload['status'] } => s.status === 'open' || s.status === 'counting' || s.status === 'closed';
@@ -106,8 +119,8 @@ export function reconcileRegisterCommands({ commands, sessions, movements, closu
106
119
  const complete = new Set((await commands.storageInstance.findDocumentsById(rows.flatMap((session) =>
107
120
  [`session.open:${session.id}`, ...(session.status === 'closed' && session.closure_id ? [`closure.submit:${session.closure_id}`] : [])]), false)).map(({ key }) => key));
108
121
  const facts = await Promise.all(rows.filter((session) =>
109
- session.status !== 'closed' || ((session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`))
110
- && !complete.has(`closure.submit:${session.closure_id}`)))
122
+ session.status !== 'abandoned' && (session.status !== 'closed' || ((session.status_at == null || session.status_at >= since || complete.has(`session.open:${session.id}`))
123
+ && !complete.has(`closure.submit:${session.closure_id}`))))
111
124
  .map(async (session) => {
112
125
  const [entries, closureRows] = await Promise.all([
113
126
  readFresh(movements, { selector: { session_id: session.id } }),
@@ -119,7 +132,7 @@ export function reconcileRegisterCommands({ commands, sessions, movements, closu
119
132
  events.push(...entries.filter((entry) => entry.type !== 'void').map(movementCommand),
120
133
  ...entries.filter((entry) => entry.type === 'void').map(movementCommand));
121
134
  if (session.status_at != null && sentStatus(session)) events.push(sessionTransitionCommand({ ...session, status_at: session.status_at }));
122
- return { session, number: closureRows[0]?.number, built: [sessionOpenCommand(session), ...events,
135
+ return { session, number: closureRows[0]?.number, built: [(registerContract ?? 0) >= 2 ? sessionOpenCommand(session, { deviceName }) : sessionOpenCommand(session), ...events,
123
136
  ...closureRows.map((closure) => closureCommand(closure))] };
124
137
  }));
125
138
  facts.sort((a, b) => Number(a.session.status !== 'closed') - Number(b.session.status !== 'closed')
@@ -24,8 +24,8 @@ export interface RegisterSession {
24
24
  register_id: string;
25
25
  /** The app's neutral store key (see `bindRegister`). */
26
26
  store_key?: string | null;
27
- /** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. */
28
- status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded';
27
+ /** Stored status is an open string so a later local state costs no migration (ADR-078 decision 9); the union names today's states. `abandoned` is local only and terminal (the cashier chose another register while in conflict). */
28
+ status: 'open' | 'counting' | 'closed' | 'conflict' | 'superseded' | 'abandoned';
29
29
  /** The store session this local id was resumed as (ADR-078 decision 2), never the session that superseded it; null until a resume. */
30
30
  server_session_id?: string | null;
31
31
  /** The store's day the session opened, `yyyy-MM-dd`. */
@@ -49,6 +49,24 @@ export class RegisterNeedsUpgradeError extends Error {
49
49
  }
50
50
  }
51
51
 
52
+ /** Opening is refused while the register has a `conflict` session (ADR-078); its message can be shown to a cashier as it is. */
53
+ export class RegisterSessionConflictError extends Error {
54
+ readonly code = 'REGISTER_SESSION_CONFLICT';
55
+ constructor() {
56
+ super('This register is open on another till. Take it over or choose another register.');
57
+ this.name = 'RegisterSessionConflictError';
58
+ }
59
+ }
60
+
61
+ export class RegisterTakeOverError extends Error {
62
+ constructor(readonly code: 'REGISTER_TAKEOVER_UNSUPPORTED' | 'REGISTER_TAKEOVER_PENDING') {
63
+ super(code === 'REGISTER_TAKEOVER_UNSUPPORTED'
64
+ ? 'This store cannot take over a register. Choose another register.'
65
+ : 'Taking over this register is still waiting for the store.');
66
+ this.name = 'RegisterTakeOverError';
67
+ }
68
+ }
69
+
52
70
  /** ADR-068 6a: movement amounts must be safe integers, positive for paid_in/paid_out and zero for no_sale. */
53
71
  export class RegisterMovementAmountError extends Error {
54
72
  constructor(type: 'paid_in' | 'paid_out' | 'no_sale', amountMinor: number) {
@@ -80,7 +98,7 @@ export class RegisterMovementStrandedError extends Error {
80
98
  }
81
99
  }
82
100
 
83
- /** Every move a session may make. Nothing leaves `closed`; everything else is refused.
101
+ /** Every move a session may make. `closed` and `abandoned` are terminal: nothing leaves them; everything else is refused.
84
102
  * No cashier move leaves `conflict` or `superseded`; the store's answers set them (ADR-078). */
85
103
  const TRANSITIONS: Record<RegisterSession['status'], readonly RegisterSession['status'][]> = {
86
104
  open: ['counting', 'closed'],
@@ -88,6 +106,7 @@ const TRANSITIONS: Record<RegisterSession['status'], readonly RegisterSession['s
88
106
  closed: [],
89
107
  conflict: [],
90
108
  superseded: [],
109
+ abandoned: [],
91
110
  };
92
111
 
93
112
  /** Whether this build knows the stored session status. */
@@ -116,6 +135,7 @@ async function requireLiveSession(sessions: RegisterSessionCollection, id: strin
116
135
  if (!session) throw new RegisterSessionRequiredError();
117
136
  if (!isKnownSessionStatus(session.status)) throw new RegisterNeedsUpgradeError();
118
137
  if (session.status === 'closed') throw new RegisterSessionClosedError();
138
+ if (session.status === 'abandoned') throw new RegisterSessionClosedError();
119
139
  return session;
120
140
  }
121
141
 
@@ -17,13 +17,14 @@
17
17
  import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
18
18
  import { combineLatest, map, of, switchMap } from 'rxjs';
19
19
  import type { RxCollection } from 'rxdb';
20
- import type { ServerCapabilities } from '@tallyui/core';
20
+ import type { RegisterSessionAlreadyOpenData, ServerCapabilities } from '@tallyui/core';
21
21
  import type { PosOrder } from '../pos-order/types';
22
22
  import { readFresh, watchFresh } from '../rxdb';
23
23
  import { deriveExpected, type LedgerRow } from './expected';
24
24
  import { recordRegisterFact, registerFactsLogger, type Actor } from './facts';
25
25
  import { closeNeedsApproval } from './register-count.helpers';
26
- import { reconcileRegisterCommands, registerCommandsLogger, type RegisterCommandCollection } from './register-commands';
26
+ import { abandonSession, adoptRegisterResults, takeOverSession } from './register-adoption';
27
+ import { reconcileRegisterCommands, registerCommandsLogger, type RegisterCommand, type RegisterCommandCollection } from './register-commands';
27
28
  import { observeRegister$, readRegister, type RegisterBucket, type RegisterDocument, type RegisterHost } from './register-document';
28
29
  import type { CashMovement, Closure, RegisterSession } from './schemas';
29
30
  import * as store from './session-store';
@@ -84,6 +85,8 @@ export interface UseRegisterSessionOptions {
84
85
  /** `register_commands`, created with `registerCommandCollection()`; `null` while it opens. */
85
86
  commands?: RegisterCommandCollection | null;
86
87
  capabilities?: ServerCapabilities;
88
+ /** The till's name from the app's settings, sent on a register v2 open; the app supplies its platform fallback. */
89
+ deviceName?: string | null;
87
90
  /** `pos_orders`: a session's sales are those whose `sessionId` is its id. */
88
91
  orders: RxCollection<PosOrder> | null;
89
92
  /** The register document's host, for the closure number and perpetual totals (`writeClosure`); `null` while it opens. */
@@ -116,6 +119,7 @@ type Reservation = RegisterBucket['closure_reservation'];
116
119
  type Snapshot = {
117
120
  rows: RegisterSession[]; closureRows: Closure[]; reservation: Reservation;
118
121
  entries: CashMovement[]; sales: PosOrder[];
122
+ open?: RegisterCommand;
119
123
  };
120
124
 
121
125
  const reservationOf = (register: RegisterDocument | null, storeKey: string, registerId: string) =>
@@ -128,12 +132,13 @@ const unwritten = (row: RegisterSession, closureRows: readonly { id: string }[])
128
132
  /**
129
133
  * The session for this register: first the one an unapplied closure reservation names (its close
130
134
  * was interrupted after the number was reserved, perhaps after its row was written), so it can
131
- * finish; then the open or counting one; then a closed one whose closure row was never written; null if any status is unknown.
135
+ * finish; then the open or counting one; then a conflict one; then a closed one whose closure row was never written; null if any status is unknown.
132
136
  */
133
137
  function currentSession(rows: RegisterSession[], closureRows: Closure[], reservation: Reservation) {
134
138
  if (rows.some((row) => !store.isKnownSessionStatus(row.status))) return null;
135
139
  return (reservation && !reservation.applied ? rows.find((row) => row.id === reservation.row.session_id) : undefined)
136
- ?? rows.find((row) => row.status !== 'closed')
140
+ ?? rows.find((row) => row.status === 'open' || row.status === 'counting')
141
+ ?? rows.find((row) => row.status === 'conflict')
137
142
  ?? rows.find((row) => unwritten(row, closureRows))
138
143
  ?? null;
139
144
  }
@@ -151,8 +156,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
151
156
  const { commands } = options;
152
157
  const commandEnabled = (options.capabilities?.register ?? 0) >= 1;
153
158
  const commandTarget = useMemo(() => enabled && commandEnabled && commands && sessions && movements && closures && register && registerId
154
- ? { commands, sessions, movements, closures, host: register, storeKey, registerId } : null,
155
- [enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId]);
159
+ ? { commands, sessions, movements, closures, host: register, storeKey, registerId, registerContract: options.capabilities?.register, deviceName: options.deviceName } : null,
160
+ [enabled, commandEnabled, commands, sessions, movements, closures, register, storeKey, registerId, options.capabilities?.register, options.deviceName]);
156
161
  const commandTargetRef = useRef(commandTarget);
157
162
  commandTargetRef.current = commandTarget;
158
163
  const reconcile = (row?: RegisterSession) => {
@@ -167,6 +172,13 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
167
172
  });
168
173
  };
169
174
  useEffect(reconcile, [commandTarget]);
175
+ useEffect(() => {
176
+ if (!commandTarget) return;
177
+ const subscription = commandTarget.commands.find({ selector: { registerId: commandTarget.registerId } }).$.subscribe(() => {
178
+ void adoptRegisterResults(commandTarget);
179
+ });
180
+ return () => subscription.unsubscribe();
181
+ }, [commandTarget]);
170
182
  const source = useMemo(() => {
171
183
  if (!enabled || !sessions || !movements || !closures || !orders || !registerId || !register) return null;
172
184
  return combineLatest([
@@ -179,9 +191,10 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
179
191
  return combineLatest([
180
192
  watchFresh(movements, { selector: { session_id: current.id } }),
181
193
  watchFresh(orders, { selector: { sessionId: current.id } }),
182
- ]).pipe(map(([entries, sales]) => ({ rows, closureRows, reservation, entries, sales })));
194
+ commandTarget ? watchFresh(commandTarget.commands, { selector: { key: `session.open:${current.id}` } }) : of([]),
195
+ ]).pipe(map(([entries, sales, opens]) => ({ rows, closureRows, reservation, entries, sales, open: opens[0] })));
183
196
  }));
184
- }, [enabled, sessions, movements, closures, orders, register, storeKey, registerId]);
197
+ }, [enabled, sessions, movements, closures, orders, register, storeKey, registerId, commandTarget]);
185
198
  // A latest-value ref: an `actions` object from an earlier render still sees the current flag.
186
199
  const tender = useRef(tenderInProgress);
187
200
  tender.current = tenderInProgress;
@@ -251,6 +264,12 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
251
264
  }
252
265
  }, [data, source]);
253
266
  const session = data ? currentSession(data.rows, data.closureRows, data.reservation) : null;
267
+ const refusal = data?.open?.error?.data as RegisterSessionAlreadyOpenData | undefined;
268
+ const conflict = session?.status !== 'conflict' || !commandTarget ? undefined
269
+ : data?.open?.syncStatus === 'pending' && typeof data.open.payload.supersedes === 'string'
270
+ ? { sessionId: data.open.payload.supersedes, takingOver: true }
271
+ : data?.open?.syncStatus === 'rejected' && refusal
272
+ ? { sessionId: refusal.sessionId, openedAt: refusal.openedAt, openedBy: refusal.openedBy, deviceName: refusal.deviceName, takingOver: false } : undefined;
254
273
  const entries = data?.entries ?? [];
255
274
  const sales = data?.sales ?? [];
256
275
  const expected = session
@@ -282,6 +301,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
282
301
 
283
302
  return {
284
303
  session,
304
+ /** The other till's live session, or the session this till is waiting to take over. */
305
+ conflict,
285
306
  /** Any unknown session status in the current snapshot means the register needs upgrade. */
286
307
  needsUpgrade: data?.rows.some((row) => !store.isKnownSessionStatus(row.status)) ?? false,
287
308
  movements: entries,
@@ -297,17 +318,35 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
297
318
  enabled,
298
319
  blind: options.blind ?? false,
299
320
  /** Pass straight to useSale's `session` option: `complete()` then stamps through `stampSession`. */
300
- saleSession: session && sessions && session.status !== 'closed' ? { id: session.id, sessions } : undefined,
321
+ saleSession: session && sessions && (session.status === 'open' || session.status === 'counting') ? { id: session.id, sessions } : undefined,
301
322
  /** The open session's id, `null` when sessions are off, else `RegisterSessionRequiredError`. Call it when tender starts and before a card terminal captures. */
302
323
  requireOpen,
303
324
  /** `requireOpen()`, returning `{ id, sessions }`: pass it to useSale's `startTender`, which pins it (the rendered `saleSession` can lag). */
304
325
  requireSaleSession,
305
326
  actions: {
327
+ takeOver: async () => {
328
+ if (!commandTarget) throw new RegisterSessionRequiredError();
329
+ const row = currentSession(await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
330
+ await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
331
+ reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId));
332
+ if (row?.status !== 'conflict') throw new RegisterSessionRequiredError();
333
+ if ((options.capabilities?.register ?? 0) < 2) throw new store.RegisterTakeOverError('REGISTER_TAKEOVER_UNSUPPORTED');
334
+ await takeOverSession({ ...commandTarget, sessionId: row.id });
335
+ },
336
+ chooseAnotherRegister: async () => {
337
+ if (!commandTarget) throw new RegisterSessionRequiredError();
338
+ const row = currentSession(await readFresh(commandTarget.sessions, { selector: { register_id: commandTarget.registerId } }),
339
+ await readFresh(commandTarget.closures, { selector: { register_id: commandTarget.registerId } }),
340
+ reservationOf(await readRegister(commandTarget.host), commandTarget.storeKey, commandTarget.registerId));
341
+ if (row?.status !== 'conflict') throw new RegisterSessionRequiredError();
342
+ await abandonSession({ ...commandTarget, sessionId: row.id });
343
+ },
306
344
  /**
307
345
  * Refuses with `RegisterSessionAlreadyOpenError` while the register has a live session or
308
346
  * another open is running (in any hook instance), and with `RegisterCloseIncompleteError`
309
347
  * while an earlier close hasn't finished: a new session would lock the till behind it.
310
348
  * An unknown stored status refuses with `RegisterNeedsUpgradeError`.
349
+ * A conflict session refuses with `RegisterSessionConflictError`; superseded and abandoned sessions do not block.
311
350
  */
312
351
  openSession: async (input: { expectedFloatMinor: number | null; countedFloatMinor: number }) => {
313
352
  // A double tap: the second call sees the first's promise, set before its first await.
@@ -318,7 +357,8 @@ export function useRegisterSession(options: UseRegisterSessionOptions) {
318
357
  // The storage, not the rendered snapshot or a cached query, which can lag a new write.
319
358
  const rows = await readFresh(sessions, { selector: { register_id: registerId } });
320
359
  if (rows.some((row) => !store.isKnownSessionStatus(row.status))) throw new store.RegisterNeedsUpgradeError();
321
- if (rows.some((row) => row.status !== 'closed')) throw new RegisterSessionAlreadyOpenError();
360
+ if (rows.some((row) => row.status === 'conflict')) throw new store.RegisterSessionConflictError();
361
+ if (rows.some((row) => row.status === 'open' || row.status === 'counting')) throw new RegisterSessionAlreadyOpenError();
322
362
  // Closure rows before the reservation: a close reserves, then inserts its row, then applies the
323
363
  // reservation, so a close landing between the two reads is still caught by one of them.
324
364
  const closureRows = await readFresh(closures, { selector: { register_id: registerId } });