@tallyui/pos 3.0.1 → 3.0.3

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
@@ -1,6 +1,6 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode } from 'react';
3
- import { Money, TaxRounding, TallyConnector, SyncContext, StoreSettingsChoice, StoreSettings, StoreSettingsChoices, ProductTraits, PaymentMethodKind, CommandServerRefs, CommandWarning, CommandError, ServerCapabilities, OrderCreateEnvelope, RegisterCommandType, RegisterCommandResult, VariantSummary, CommandResult, AnyCommandEnvelope, CommandEnvelope, OrderCreatePayload, RegisterCommandEnvelope } from '@tallyui/core';
3
+ import { Money, TaxRounding, TallyConnector, SyncContext, StoreSettingsChoice, StoreSettings, StoreSettingsChoices, ProductTraits, PaymentMethodKind, CommandServerRefs, CommandWarning, CommandError, ServerCapabilities, OrderCreateEnvelope, RegisterCommandType, RegisterCommandResult, VariantSummary, TraitContext, CommandResult, AnyCommandEnvelope, CommandEnvelope, OrderCreatePayload, RegisterCommandEnvelope } from '@tallyui/core';
4
4
  export { getProductStock, withStockOverlay } from '@tallyui/core';
5
5
  export { countFresh, readFresh, watchFresh } from '@tallyui/core/rxdb';
6
6
  import * as rxdb from 'rxdb';
@@ -376,6 +376,8 @@ interface OrderBuilder {
376
376
  }): string;
377
377
  addLine(input: AddLineInput): string;
378
378
  updateQuantity(lineId: string, quantity: number): void;
379
+ /** Sets a line's unit price (a till price edit), in minor units of the order's currency and the line's own tax mode; its discounts and tax are recomputed. */
380
+ setUnitPrice(lineId: string, amountMinor: number): void;
379
381
  removeItem(lineId: string): void;
380
382
  applyLineDiscount(lineId: string, discount: Discount): void;
381
383
  applyOrderDiscount(discount: Discount): void;
@@ -424,6 +426,15 @@ declare function taxFiguresForBasket(currency: string, pricesIncludeTax: boolean
424
426
  */
425
427
  declare function allocateOrderDiscount(lineAmountsMinor: readonly number[], totalMinor: number): number[];
426
428
 
429
+ /** Apps create their drafts collection with this schema. */
430
+ declare const orderDraftSchema: RxJsonSchema<{
431
+ id: string;
432
+ data: string;
433
+ customerName?: string;
434
+ itemCount?: number;
435
+ total?: number;
436
+ parkedAt?: string;
437
+ }>;
427
438
  interface ParkedOrderSummary {
428
439
  id: string;
429
440
  customerName?: string;
@@ -432,6 +443,13 @@ interface ParkedOrderSummary {
432
443
  parkedAt: string;
433
444
  source: 'local' | 'server';
434
445
  }
446
+ declare function writeOrderDraft(drafts: RxCollection, snapshot: Order): Promise<string>;
447
+ declare function restoreOrderDraft(saved: Order, options: {
448
+ currency: string;
449
+ taxContext: TaxContext;
450
+ }): OrderBuilder;
451
+ declare function parkedOrderSummaries$(drafts: RxCollection): Observable<ParkedOrderSummary[]>;
452
+
435
453
  interface OrderManagerOptions {
436
454
  currency: string;
437
455
  taxContext: TaxContext;
@@ -1790,8 +1808,9 @@ type CatalogueEntry<Doc> = {
1790
1808
  product: Doc;
1791
1809
  variant: VariantSummary;
1792
1810
  };
1793
- /** Every variant of every product, in product order then variant order. */
1794
- declare function catalogueEntries<Doc>(products: Doc[], traits: ProductTraits<Doc>): CatalogueEntry<Doc>[];
1811
+ /** Variants in product/variant order, passing context through; without getVariants,
1812
+ * simple products use their own traits and products with variants contribute no entries. */
1813
+ declare function catalogueEntries<Doc>(products: Doc[], traits: ProductTraits<Doc>, context?: TraitContext): CatalogueEntry<Doc>[];
1795
1814
  /** Barcode-then-SKU lookup across all products. */
1796
1815
  declare function findEntryByCode<Doc>(entries: CatalogueEntry<Doc>[], code: string): CatalogueEntry<Doc> | undefined;
1797
1816
  /** Display the resolved price in the requested currency. */
@@ -1818,6 +1837,8 @@ declare function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
1818
1837
  registerId: string;
1819
1838
  cashierRef: string;
1820
1839
  capabilities?: ServerCapabilities;
1840
+ /** Where `park()` keeps parked carts; create it with `orderDraftSchema` */
1841
+ drafts?: RxCollection;
1821
1842
  /**
1822
1843
  * When set, `complete()` stamps the finalized order with this session before `onSaleCompleted`.
1823
1844
  * `complete()` runs after the money is taken, so a refused stamp (the session closed or went
@@ -1850,6 +1871,8 @@ declare function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
1850
1871
  canContinue: boolean;
1851
1872
  add(entry: CatalogueEntry<any>, traits: ProductTraits<any>): void;
1852
1873
  setQuantity(lineId: string, quantity: number): void;
1874
+ /** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
1875
+ setUnitPrice(lineId: string, amountMinor: number): string | null;
1853
1876
  remove(lineId: string): void;
1854
1877
  /** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
1855
1878
  applyDiscount(lineId: string | null, discount: Discount): string | null;
@@ -1903,6 +1926,10 @@ declare function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
1903
1926
  * updates or requeues `pos_orders` itself.
1904
1927
  */
1905
1928
  newSale(): void;
1929
+ /** Parks the cart in `drafts`; resolves to a refusal message, or null once parked. */
1930
+ park(): Promise<string | null>;
1931
+ /** Resumes a draft into an empty cart; resolves to a refusal message, or null once resumed. */
1932
+ resume(draftId: string): Promise<string | null>;
1906
1933
  /**
1907
1934
  * Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
1908
1935
  * The order stays pending in the outbox, which will send it; it isn't handed to `onSaleCompleted` again, and
@@ -2277,4 +2304,4 @@ declare function planLegs(plan: TenderPlan, rowsSinceFrom: {
2277
2304
  };
2278
2305
  };
2279
2306
 
2280
- export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type Closure, type ClosureCollection, type ClosureContext, type ClosureScope, type CommandTransport, type Correction, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, DISCOUNTS_UNSUPPORTED, type Discount, type DisplayLine, type DisplayTotals, 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 OutboxState, type ParkedOrderSummary, type Payment, type PaymentMethod, type PaymentTransport, type PlanLeg, type PosOrder, type PosOrderLine, type PosOrderLocalWarning, PosOrderOpenClosedError, type PosOrderPayment, type PosOrderServerFailures, type PosOrderSyncStatus, type RateTaxLine, type ReceiptConfig, type ReceiptData, type ReceiptLineItem, type RecordedFigures, RegisterApprovalRequiredError, type RegisterBucket, RegisterCloseIncompleteError, type RegisterCommand, type RegisterCommandCollection, type RegisterCounters, type RegisterDocument, type RegisterFact, type RegisterHost, RegisterIdInvalidError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, type RegisterOutbox, type RegisterOutboxOptions, type RegisterSession, RegisterSessionAlreadyOpenError, RegisterSessionClosedError, type RegisterSessionCollection, RegisterSessionRequiredError, type RegisterStore, RegisterTenderInProgressError, type Repository, type ResolveStoreSettingsOptions, SALE_SAVING, type SaleStage, type SentOrder, 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 TransportOutcome, UnsupportedOrderVersionError, type UseOrderOutboxOptions, type UseOrderOutboxResult, type UseRegisterOutboxOptions, type UseRegisterOutboxResult, type UseRegisterSessionOptions, activePlan, addEntryToCart, addPosOrderCollection, advancePerpetual, allocateOrderDiscount, appliedMinor, backToSelling, bindRegister, buildClosureDocument, buildReceiptData, buildXReportDocument, callbackSink, cashMovementSchema, catalogueEntries, 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, initTenderState, initialTenderState, isServerDecimal, labelKeys, minorToDecimal, mintClosureNumber, mintUuid, movementCommand, movementFieldError, needsAttention, nextSaleCounter, normalizeAmount, observeRegister$, openSession, openSessionSelector, outboxLogger, overThreshold, parseMinor, planLegs, posOrderCollection, posOrderSchema, posOrdersLogger, quickTenderedAmounts, ratePpmFromPercent, readBoundRegister, readRegister, reconcileRegisterCommands, recordMovement, recordRegisterFact, registerCommandCollection, registerCommandSchema, registerCommandsLogger, registerFactsLogger, registerSessionCollection, registerSessionSchema, requireOpenSession, resolveStoreSettings, roundMicrosToMinor, saleLogger, sameSale, searchProducts, selectClosureRows, sessionOpenCommand, sessionTransitionCommand, stampSession, startCounting, stockOverlay$, stockOverlayAsOf$, taxFiguresForBasket, taxLinesByRate, taxLogger, taxMicros, taxProviderProps, tenderReducer, toOrderCreateEnvelope, unbindRegister, useCurrencyCode, useCurrencyFormatter, useOrderOutbox, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure };
2307
+ export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type Closure, type ClosureCollection, type ClosureContext, type ClosureScope, type CommandTransport, type Correction, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, DISCOUNTS_UNSUPPORTED, type Discount, type DisplayLine, type DisplayTotals, 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 OutboxState, type ParkedOrderSummary, type Payment, type PaymentMethod, type PaymentTransport, type PlanLeg, type PosOrder, type PosOrderLine, type PosOrderLocalWarning, PosOrderOpenClosedError, type PosOrderPayment, type PosOrderServerFailures, type PosOrderSyncStatus, type RateTaxLine, type ReceiptConfig, type ReceiptData, type ReceiptLineItem, type RecordedFigures, RegisterApprovalRequiredError, type RegisterBucket, RegisterCloseIncompleteError, type RegisterCommand, type RegisterCommandCollection, type RegisterCounters, type RegisterDocument, type RegisterFact, type RegisterHost, RegisterIdInvalidError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, type RegisterOutbox, type RegisterOutboxOptions, type RegisterSession, RegisterSessionAlreadyOpenError, RegisterSessionClosedError, type RegisterSessionCollection, RegisterSessionRequiredError, type RegisterStore, RegisterTenderInProgressError, type Repository, type ResolveStoreSettingsOptions, SALE_SAVING, type SaleStage, type SentOrder, 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 TransportOutcome, UnsupportedOrderVersionError, type UseOrderOutboxOptions, type UseOrderOutboxResult, type UseRegisterOutboxOptions, type UseRegisterOutboxResult, type UseRegisterSessionOptions, activePlan, addEntryToCart, addPosOrderCollection, advancePerpetual, allocateOrderDiscount, appliedMinor, backToSelling, bindRegister, buildClosureDocument, buildReceiptData, buildXReportDocument, callbackSink, cashMovementSchema, catalogueEntries, 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, initTenderState, initialTenderState, isServerDecimal, labelKeys, minorToDecimal, mintClosureNumber, mintUuid, movementCommand, movementFieldError, needsAttention, nextSaleCounter, normalizeAmount, observeRegister$, openSession, openSessionSelector, orderDraftSchema, outboxLogger, overThreshold, parkedOrderSummaries$, parseMinor, planLegs, posOrderCollection, posOrderSchema, posOrdersLogger, quickTenderedAmounts, ratePpmFromPercent, readBoundRegister, readRegister, reconcileRegisterCommands, recordMovement, recordRegisterFact, registerCommandCollection, registerCommandSchema, registerCommandsLogger, registerFactsLogger, registerSessionCollection, registerSessionSchema, requireOpenSession, resolveStoreSettings, restoreOrderDraft, roundMicrosToMinor, saleLogger, sameSale, searchProducts, selectClosureRows, sessionOpenCommand, sessionTransitionCommand, stampSession, startCounting, stockOverlay$, stockOverlayAsOf$, taxFiguresForBasket, taxLinesByRate, taxLogger, taxMicros, taxProviderProps, tenderReducer, toOrderCreateEnvelope, unbindRegister, useCurrencyCode, useCurrencyFormatter, useOrderOutbox, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure, writeOrderDraft };
package/dist/index.js CHANGED
@@ -745,6 +745,15 @@ function createOrderBuilder(options) {
745
745
  }
746
746
  emit();
747
747
  },
748
+ setUnitPrice(lineId, amountMinor) {
749
+ if (!Number.isInteger(amountMinor)) throw new RangeError("Price must be integer minor units");
750
+ if (amountMinor < 0) throw new RangeError("Price must be >= 0");
751
+ if (!lineItems.some((li) => li.id === lineId)) throw new Error(`Unknown line ${lineId}`);
752
+ lineItems = lineItems.map(
753
+ (li) => li.id === lineId ? recalculateLine({ ...li, unitPriceMinor: amountMinor }) : li
754
+ );
755
+ emit();
756
+ },
748
757
  removeItem(lineId) {
749
758
  lineItems = lineItems.filter((li) => li.id !== lineId);
750
759
  emit();
@@ -833,7 +842,10 @@ function taxFiguresForBasket(currency, pricesIncludeTax, lines, rounding) {
833
842
  }
834
843
 
835
844
  // src/order/order-manager.ts
836
- import { BehaviorSubject as BehaviorSubject2, map as map2 } from "rxjs";
845
+ import { BehaviorSubject as BehaviorSubject2 } from "rxjs";
846
+
847
+ // src/order/order-drafts.ts
848
+ import { map as map2 } from "rxjs";
837
849
 
838
850
  // src/pos-order/command.ts
839
851
  var UnsupportedOrderVersionError = class extends Error {
@@ -934,13 +946,86 @@ function toOrderCreateEnvelope(order, deviceId, attempt2 = 1, options) {
934
946
  };
935
947
  }
936
948
 
937
- // src/order/order-manager.ts
938
- function createOrderManager(options) {
939
- const { taxContext, draftsCollection } = options;
940
- const currency = options.currency.toUpperCase();
941
- let activeBuilder = createOrderBuilder({ currency, taxContext });
942
- const activeSubject = new BehaviorSubject2(activeBuilder);
943
- const parkedOrders$ = draftsCollection.find().$.pipe(
949
+ // src/order/order-drafts.ts
950
+ var orderDraftSchema = {
951
+ version: 0,
952
+ primaryKey: "id",
953
+ type: "object",
954
+ properties: {
955
+ id: { type: "string", maxLength: 100 },
956
+ data: { type: "string" },
957
+ customerName: { type: "string" },
958
+ itemCount: { type: "integer", minimum: 0, maximum: 2147483647, multipleOf: 1 },
959
+ total: { type: "number" },
960
+ parkedAt: { type: "string" }
961
+ },
962
+ required: ["id", "data"]
963
+ };
964
+ async function writeOrderDraft(drafts, snapshot) {
965
+ await drafts.upsert({
966
+ id: snapshot.id,
967
+ data: JSON.stringify(snapshot),
968
+ customerName: snapshot.customer?.name ?? "",
969
+ itemCount: snapshot.lineItems.length,
970
+ total: snapshot.totalMinor,
971
+ parkedAt: (/* @__PURE__ */ new Date()).toISOString()
972
+ });
973
+ return snapshot.id;
974
+ }
975
+ function restoreOrderDraft(saved, options) {
976
+ const { currency, taxContext } = options;
977
+ const builder = createOrderBuilder({
978
+ currency,
979
+ taxContext,
980
+ id: saved.id
981
+ });
982
+ if (saved.customer && !customerRefusal(saved.customer)) {
983
+ builder.setCustomer(saved.customer);
984
+ }
985
+ if (saved.note) {
986
+ builder.setNote(saved.note);
987
+ }
988
+ for (const line of saved.lineItems) {
989
+ const lineId = builder.addLine({
990
+ productId: line.productId,
991
+ variantId: line.variantId,
992
+ name: line.name,
993
+ sku: line.sku,
994
+ imageUrl: line.imageUrl,
995
+ unitPrice: { amount: line.unitPriceMinor, currency, taxInclusive: line.taxInclusive },
996
+ quantity: line.quantity,
997
+ taxRates: line.taxLines.map(({ code, ratePpm }) => ({ code, ratePpm }))
998
+ });
999
+ for (const discount of line.discounts) {
1000
+ builder.applyLineDiscount(lineId, {
1001
+ type: discount.type,
1002
+ value: discount.value,
1003
+ label: discount.label,
1004
+ couponCode: discount.couponCode
1005
+ });
1006
+ }
1007
+ }
1008
+ for (const discount of saved.discounts) {
1009
+ builder.applyOrderDiscount({
1010
+ type: discount.type,
1011
+ value: discount.value,
1012
+ label: discount.label,
1013
+ couponCode: discount.couponCode
1014
+ });
1015
+ }
1016
+ for (const payment of saved.payments) {
1017
+ builder.addPayment({
1018
+ method: payment.method,
1019
+ amountMinor: payment.amountMinor,
1020
+ tenderedMinor: payment.tenderedMinor,
1021
+ changeMinor: payment.changeMinor,
1022
+ reference: payment.reference
1023
+ });
1024
+ }
1025
+ return builder;
1026
+ }
1027
+ function parkedOrderSummaries$(drafts) {
1028
+ return drafts.find().$.pipe(
944
1029
  map2(
945
1030
  (docs) => docs.map((doc) => {
946
1031
  const json = doc.toJSON();
@@ -955,6 +1040,15 @@ function createOrderManager(options) {
955
1040
  })
956
1041
  )
957
1042
  );
1043
+ }
1044
+
1045
+ // src/order/order-manager.ts
1046
+ function createOrderManager(options) {
1047
+ const { taxContext, draftsCollection } = options;
1048
+ const currency = options.currency.toUpperCase();
1049
+ let activeBuilder = createOrderBuilder({ currency, taxContext });
1050
+ const activeSubject = new BehaviorSubject2(activeBuilder);
1051
+ const parkedOrders$ = parkedOrderSummaries$(draftsCollection);
958
1052
  return {
959
1053
  activeOrder$: activeSubject.asObservable(),
960
1054
  parkedOrders$,
@@ -965,14 +1059,7 @@ function createOrderManager(options) {
965
1059
  },
966
1060
  async parkCurrentOrder() {
967
1061
  const snapshot = activeBuilder.getSnapshot();
968
- await draftsCollection.upsert({
969
- id: snapshot.id,
970
- data: JSON.stringify(snapshot),
971
- customerName: snapshot.customer?.name ?? "",
972
- itemCount: snapshot.lineItems.length,
973
- total: snapshot.totalMinor,
974
- parkedAt: (/* @__PURE__ */ new Date()).toISOString()
975
- });
1062
+ await writeOrderDraft(draftsCollection, snapshot);
976
1063
  activeBuilder = createOrderBuilder({ currency, taxContext });
977
1064
  activeSubject.next(activeBuilder);
978
1065
  return snapshot.id;
@@ -982,54 +1069,7 @@ function createOrderManager(options) {
982
1069
  if (!doc) throw new Error(`Parked order ${orderId} not found`);
983
1070
  const json = doc.toJSON();
984
1071
  const savedOrder = JSON.parse(json.data);
985
- const builder = createOrderBuilder({
986
- currency,
987
- taxContext,
988
- id: savedOrder.id
989
- });
990
- if (savedOrder.customer && !customerRefusal(savedOrder.customer)) {
991
- builder.setCustomer(savedOrder.customer);
992
- }
993
- if (savedOrder.note) {
994
- builder.setNote(savedOrder.note);
995
- }
996
- for (const line of savedOrder.lineItems) {
997
- const lineId = builder.addLine({
998
- productId: line.productId,
999
- variantId: line.variantId,
1000
- name: line.name,
1001
- sku: line.sku,
1002
- imageUrl: line.imageUrl,
1003
- unitPrice: { amount: line.unitPriceMinor, currency, taxInclusive: line.taxInclusive },
1004
- quantity: line.quantity,
1005
- taxRates: line.taxLines.map(({ code, ratePpm }) => ({ code, ratePpm }))
1006
- });
1007
- for (const discount of line.discounts) {
1008
- builder.applyLineDiscount(lineId, {
1009
- type: discount.type,
1010
- value: discount.value,
1011
- label: discount.label,
1012
- couponCode: discount.couponCode
1013
- });
1014
- }
1015
- }
1016
- for (const discount of savedOrder.discounts) {
1017
- builder.applyOrderDiscount({
1018
- type: discount.type,
1019
- value: discount.value,
1020
- label: discount.label,
1021
- couponCode: discount.couponCode
1022
- });
1023
- }
1024
- for (const payment of savedOrder.payments) {
1025
- builder.addPayment({
1026
- method: payment.method,
1027
- amountMinor: payment.amountMinor,
1028
- tenderedMinor: payment.tenderedMinor,
1029
- changeMinor: payment.changeMinor,
1030
- reference: payment.reference
1031
- });
1032
- }
1072
+ const builder = restoreOrderDraft(savedOrder, { currency, taxContext });
1033
1073
  await doc.remove();
1034
1074
  activeBuilder = builder;
1035
1075
  activeSubject.next(activeBuilder);
@@ -3540,7 +3580,7 @@ function useSale(settings, opts) {
3540
3580
  if (active) setError(SALE_SAVING);
3541
3581
  return active;
3542
3582
  }
3543
- function resetSale(customer) {
3583
+ function resetSale(customer, restored) {
3544
3584
  if (inFlight.current && !pending.current) return setError(SALE_SAVING);
3545
3585
  if (pending.current && confirmed.current !== pending.current) {
3546
3586
  if (inFlight.current) checkStored(pending.current);
@@ -3553,7 +3593,7 @@ function useSale(settings, opts) {
3553
3593
  droppedReference.current = null;
3554
3594
  setSaving(false);
3555
3595
  madeWith.current = { taxContext, currency: settings.currency };
3556
- const next = createOrderBuilder({ currency: settings.currency, taxContext });
3596
+ const next = restored ?? createOrderBuilder({ currency: settings.currency, taxContext });
3557
3597
  if (customer !== null) next.setCustomer(customer);
3558
3598
  builderNow.current = next;
3559
3599
  setBuilder(next);
@@ -3683,6 +3723,17 @@ function useSale(settings, opts) {
3683
3723
  setQuantity(lineId, quantity) {
3684
3724
  if (!locked()) builderNow.current.updateQuantity(lineId, quantity);
3685
3725
  },
3726
+ /** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
3727
+ setUnitPrice(lineId, amountMinor) {
3728
+ if (locked()) return SALE_SAVING;
3729
+ try {
3730
+ builderNow.current.setUnitPrice(lineId, amountMinor);
3731
+ return null;
3732
+ } catch (error2) {
3733
+ if (error2 instanceof RangeError || error2 instanceof Error && error2.message === `Unknown line ${lineId}`) return error2.message;
3734
+ throw error2;
3735
+ }
3736
+ },
3686
3737
  remove(lineId) {
3687
3738
  if (!locked()) builderNow.current.removeItem(lineId);
3688
3739
  },
@@ -3814,6 +3865,39 @@ function useSale(settings, opts) {
3814
3865
  newSale() {
3815
3866
  resetSale(null);
3816
3867
  },
3868
+ /** Parks the cart in `drafts`; resolves to a refusal message, or null once parked. */
3869
+ async park() {
3870
+ if (locked()) return SALE_SAVING;
3871
+ if (!opts.drafts) throw new Error("useSale: park() needs the drafts option");
3872
+ if (stageNow.current.kind !== "cart") return "Finish or cancel the payment before parking the sale";
3873
+ const builder = builderNow.current;
3874
+ const snapshot = builder.getSnapshot();
3875
+ if (!snapshot.lineItems.length) return "There is nothing to park";
3876
+ await writeOrderDraft(opts.drafts, snapshot);
3877
+ if (builderNow.current !== builder || builder.getSnapshot() !== snapshot) return "The sale changed while it was being parked; park it again";
3878
+ resetSale(null);
3879
+ return null;
3880
+ },
3881
+ /** Resumes a draft into an empty cart; resolves to a refusal message, or null once resumed. */
3882
+ async resume(draftId) {
3883
+ if (locked()) return SALE_SAVING;
3884
+ if (!opts.drafts) throw new Error("useSale: resume() needs the drafts option");
3885
+ const live = builderNow.current;
3886
+ const liveSnapshot = live.getSnapshot();
3887
+ if (stageNow.current.kind !== "cart" || liveSnapshot.lineItems.length) return "Park or clear the current sale first";
3888
+ const doc = await opts.drafts.findOne(draftId).exec();
3889
+ if (!doc) return "That parked sale is no longer there";
3890
+ const saved = JSON.parse(doc.toJSON().data);
3891
+ if (saved.currency.toUpperCase() !== settings.currency.toUpperCase()) return "That parked sale is in another currency";
3892
+ const builder = restoreOrderDraft(saved, { currency: settings.currency, taxContext });
3893
+ await doc.remove();
3894
+ if (builderNow.current !== live || live.getSnapshot() !== liveSnapshot) {
3895
+ await writeOrderDraft(opts.drafts, saved);
3896
+ return "The sale changed while it was being resumed; resume it again";
3897
+ }
3898
+ resetSale(null, builder);
3899
+ return null;
3900
+ },
3817
3901
  /**
3818
3902
  * Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
3819
3903
  * The order stays pending in the outbox, which will send it; it isn't handed to `onSaleCompleted` again, and
@@ -3828,8 +3912,17 @@ function useSale(settings, opts) {
3828
3912
 
3829
3913
  // src/sale/catalogue.ts
3830
3914
  import { findVariantByCode, formatMoney as formatMoney2, resolvePrice as resolvePrice3 } from "@tallyui/core";
3831
- function catalogueEntries(products, traits) {
3832
- return products.flatMap((product) => traits.getVariants(product).map((variant) => ({ product, variant })));
3915
+ function catalogueEntries(products, traits, context) {
3916
+ return products.flatMap((product) => {
3917
+ const variants = traits.getVariants ? traits.getVariants(product, context) : traits.hasVariants(product) ? [] : [{
3918
+ id: traits.getId(product),
3919
+ sku: traits.getSku(product),
3920
+ barcode: traits.getBarcode(product),
3921
+ prices: traits.getPrices(product, context),
3922
+ stock: traits.getStock(product)
3923
+ }];
3924
+ return variants.map((variant) => ({ product, variant }));
3925
+ });
3833
3926
  }
3834
3927
  function findEntryByCode(entries, code) {
3835
3928
  const variant = findVariantByCode(entries.map((entry) => entry.variant), code);
@@ -5077,8 +5170,10 @@ export {
5077
5170
  observeRegister$,
5078
5171
  openSession,
5079
5172
  openSessionSelector,
5173
+ orderDraftSchema,
5080
5174
  outboxLogger,
5081
5175
  overThreshold,
5176
+ parkedOrderSummaries$,
5082
5177
  parseMinor,
5083
5178
  planLegs,
5084
5179
  posOrderCollection,
@@ -5100,6 +5195,7 @@ export {
5100
5195
  registerSessionSchema,
5101
5196
  requireOpenSession,
5102
5197
  resolveStoreSettings,
5198
+ restoreOrderDraft,
5103
5199
  roundMicrosToMinor,
5104
5200
  saleLogger,
5105
5201
  sameSale,
@@ -5135,5 +5231,6 @@ export {
5135
5231
  watchFresh,
5136
5232
  withPricingContext,
5137
5233
  withStockOverlay,
5138
- writeClosure
5234
+ writeClosure,
5235
+ writeOrderDraft
5139
5236
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/pos",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
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.0.1",
30
+ "@tallyui/core": "^3.0.3",
31
31
  "react": ">=18",
32
32
  "rxdb": "~17.5.0",
33
33
  "rxjs": ">=7"
34
34
  },
35
35
  "devDependencies": {
36
- "@tallyui/core": "3.0.1",
36
+ "@tallyui/core": "3.0.3",
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.0.1",
42
- "@tallyui/connector-woocommerce": "3.0.1",
43
- "@tallyui/database": "3.0.1"
41
+ "@tallyui/connector-medusa": "3.0.3",
42
+ "@tallyui/connector-woocommerce": "3.0.3",
43
+ "@tallyui/database": "3.0.3"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsup",
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export type { Repository } from './repository';
26
26
 
27
27
  // Order
28
28
  export { createOrderBuilder, createOrderManager, allocateOrderDiscount, taxFiguresForBasket } from './order';
29
+ export { orderDraftSchema, writeOrderDraft, restoreOrderDraft, parkedOrderSummaries$ } from './order';
29
30
  export type { BasketLine } from './order';
30
31
  export type { OrderBuilder, OrderBuilderOptions } from './order';
31
32
  export type { OrderManager, OrderManagerOptions, ParkedOrderSummary } from './order';
@@ -4,6 +4,7 @@ export type { BasketLine } from './tax-figures';
4
4
  export { allocateOrderDiscount } from './allocate-order-discount';
5
5
  export type { OrderBuilder, OrderBuilderOptions } from './order-builder';
6
6
  export { createOrderManager } from './order-manager';
7
+ export { orderDraftSchema, writeOrderDraft, restoreOrderDraft, parkedOrderSummaries$ } from './order-drafts';
7
8
  export type { OrderManager, OrderManagerOptions, ParkedOrderSummary } from './order-manager';
8
9
  export type {
9
10
  Order,
@@ -71,6 +71,8 @@ export interface OrderBuilder {
71
71
  addProduct(doc: any, traits: ProductTraits, options?: { variantId?: string; quantity?: number }): string;
72
72
  addLine(input: AddLineInput): string;
73
73
  updateQuantity(lineId: string, quantity: number): void;
74
+ /** Sets a line's unit price (a till price edit), in minor units of the order's currency and the line's own tax mode; its discounts and tax are recomputed. */
75
+ setUnitPrice(lineId: string, amountMinor: number): void;
74
76
  removeItem(lineId: string): void;
75
77
  applyLineDiscount(lineId: string, discount: Discount): void;
76
78
  applyOrderDiscount(discount: Discount): void;
@@ -355,6 +357,16 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
355
357
  emit();
356
358
  },
357
359
 
360
+ setUnitPrice(lineId, amountMinor) {
361
+ if (!Number.isInteger(amountMinor)) throw new RangeError('Price must be integer minor units');
362
+ if (amountMinor < 0) throw new RangeError('Price must be >= 0');
363
+ if (!lineItems.some((li) => li.id === lineId)) throw new Error(`Unknown line ${lineId}`);
364
+ lineItems = lineItems.map((li) =>
365
+ li.id === lineId ? recalculateLine({ ...li, unitPriceMinor: amountMinor }) : li,
366
+ );
367
+ emit();
368
+ },
369
+
358
370
  removeItem(lineId) {
359
371
  lineItems = lineItems.filter((li) => li.id !== lineId);
360
372
  emit();
@@ -0,0 +1,125 @@
1
+ import { map, type Observable } from 'rxjs';
2
+ import type { RxCollection, RxJsonSchema } from 'rxdb';
3
+ import type { TaxContext } from '../tax/types';
4
+ import { customerRefusal } from '../pos-order/command';
5
+ import { createOrderBuilder, type OrderBuilder } from './order-builder';
6
+ import type { Order } from './types';
7
+
8
+ /** Apps create their drafts collection with this schema. */
9
+ export const orderDraftSchema: RxJsonSchema<{
10
+ id: string; data: string; customerName?: string; itemCount?: number; total?: number; parkedAt?: string;
11
+ }> = {
12
+ version: 0,
13
+ primaryKey: 'id',
14
+ type: 'object',
15
+ properties: {
16
+ id: { type: 'string', maxLength: 100 },
17
+ data: { type: 'string' },
18
+ customerName: { type: 'string' },
19
+ itemCount: { type: 'integer', minimum: 0, maximum: 2147483647, multipleOf: 1 },
20
+ total: { type: 'number' },
21
+ parkedAt: { type: 'string' },
22
+ },
23
+ required: ['id', 'data'],
24
+ };
25
+
26
+ export interface ParkedOrderSummary {
27
+ id: string;
28
+ customerName?: string;
29
+ itemCount: number;
30
+ totalMinor: number;
31
+ parkedAt: string;
32
+ source: 'local' | 'server';
33
+ }
34
+
35
+ export async function writeOrderDraft(drafts: RxCollection, snapshot: Order): Promise<string> {
36
+ await drafts.upsert({
37
+ id: snapshot.id,
38
+ data: JSON.stringify(snapshot),
39
+ customerName: snapshot.customer?.name ?? '',
40
+ itemCount: snapshot.lineItems.length,
41
+ total: snapshot.totalMinor,
42
+ parkedAt: new Date().toISOString(),
43
+ });
44
+ return snapshot.id;
45
+ }
46
+
47
+ export function restoreOrderDraft(saved: Order, options: { currency: string; taxContext: TaxContext }): OrderBuilder {
48
+ const { currency, taxContext } = options;
49
+ const builder = createOrderBuilder({
50
+ currency,
51
+ taxContext,
52
+ id: saved.id,
53
+ });
54
+
55
+ // Restore state from saved order
56
+ // A customer whose email or id order.create would refuse (see useSale's setCustomer) isn't restored.
57
+ if (saved.customer && !customerRefusal(saved.customer)) {
58
+ builder.setCustomer(saved.customer);
59
+ }
60
+ if (saved.note) {
61
+ builder.setNote(saved.note);
62
+ }
63
+
64
+ // Restore line items and their discounts
65
+ for (const line of saved.lineItems) {
66
+ const lineId = builder.addLine({
67
+ productId: line.productId,
68
+ variantId: line.variantId,
69
+ name: line.name,
70
+ sku: line.sku,
71
+ imageUrl: line.imageUrl,
72
+ unitPrice: { amount: line.unitPriceMinor, currency, taxInclusive: line.taxInclusive },
73
+ quantity: line.quantity,
74
+ taxRates: line.taxLines.map(({ code, ratePpm }) => ({ code, ratePpm })),
75
+ });
76
+ for (const discount of line.discounts) {
77
+ builder.applyLineDiscount(lineId, {
78
+ type: discount.type,
79
+ value: discount.value,
80
+ label: discount.label,
81
+ couponCode: discount.couponCode,
82
+ });
83
+ }
84
+ }
85
+
86
+ // Restore order-level discounts
87
+ for (const discount of saved.discounts) {
88
+ builder.applyOrderDiscount({
89
+ type: discount.type,
90
+ value: discount.value,
91
+ label: discount.label,
92
+ couponCode: discount.couponCode,
93
+ });
94
+ }
95
+
96
+ // Restore payments
97
+ for (const payment of saved.payments) {
98
+ builder.addPayment({
99
+ method: payment.method,
100
+ amountMinor: payment.amountMinor,
101
+ tenderedMinor: payment.tenderedMinor,
102
+ changeMinor: payment.changeMinor,
103
+ reference: payment.reference,
104
+ });
105
+ }
106
+ return builder;
107
+ }
108
+
109
+ export function parkedOrderSummaries$(drafts: RxCollection): Observable<ParkedOrderSummary[]> {
110
+ return drafts.find().$.pipe(
111
+ map((docs) =>
112
+ docs.map((doc) => {
113
+ const json = doc.toJSON() as any;
114
+ return {
115
+ id: json.id,
116
+ customerName: json.customerName || undefined,
117
+ itemCount: json.itemCount ?? 0,
118
+ totalMinor: json.total ?? 0,
119
+ parkedAt: json.parkedAt ?? '',
120
+ source: 'local' as const,
121
+ };
122
+ }),
123
+ ),
124
+ );
125
+ }
@@ -1,18 +1,11 @@
1
- import { BehaviorSubject, map, type Observable } from 'rxjs';
1
+ import { BehaviorSubject, type Observable } from 'rxjs';
2
2
  import type { RxCollection } from 'rxdb';
3
3
  import type { TaxContext } from '../tax/types';
4
- import { customerRefusal } from '../pos-order/command';
5
4
  import { createOrderBuilder, type OrderBuilder } from './order-builder';
6
5
  import type { Order } from './types';
6
+ import { writeOrderDraft, restoreOrderDraft, parkedOrderSummaries$, type ParkedOrderSummary } from './order-drafts';
7
7
 
8
- export interface ParkedOrderSummary {
9
- id: string;
10
- customerName?: string;
11
- itemCount: number;
12
- totalMinor: number;
13
- parkedAt: string;
14
- source: 'local' | 'server';
15
- }
8
+ export type { ParkedOrderSummary } from './order-drafts';
16
9
 
17
10
  export interface OrderManagerOptions {
18
11
  currency: string;
@@ -36,21 +29,7 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
36
29
  let activeBuilder = createOrderBuilder({ currency, taxContext });
37
30
  const activeSubject = new BehaviorSubject<OrderBuilder>(activeBuilder);
38
31
 
39
- const parkedOrders$: Observable<ParkedOrderSummary[]> = draftsCollection.find().$.pipe(
40
- map((docs) =>
41
- docs.map((doc) => {
42
- const json = doc.toJSON() as any;
43
- return {
44
- id: json.id,
45
- customerName: json.customerName || undefined,
46
- itemCount: json.itemCount ?? 0,
47
- totalMinor: json.total ?? 0,
48
- parkedAt: json.parkedAt ?? '',
49
- source: 'local' as const,
50
- };
51
- }),
52
- ),
53
- );
32
+ const parkedOrders$ = parkedOrderSummaries$(draftsCollection);
54
33
 
55
34
  return {
56
35
  activeOrder$: activeSubject.asObservable(),
@@ -64,14 +43,7 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
64
43
 
65
44
  async parkCurrentOrder() {
66
45
  const snapshot = activeBuilder.getSnapshot();
67
- await draftsCollection.upsert({
68
- id: snapshot.id,
69
- data: JSON.stringify(snapshot),
70
- customerName: snapshot.customer?.name ?? '',
71
- itemCount: snapshot.lineItems.length,
72
- total: snapshot.totalMinor,
73
- parkedAt: new Date().toISOString(),
74
- });
46
+ await writeOrderDraft(draftsCollection, snapshot);
75
47
 
76
48
  activeBuilder = createOrderBuilder({ currency, taxContext });
77
49
  activeSubject.next(activeBuilder);
@@ -85,63 +57,7 @@ export function createOrderManager(options: OrderManagerOptions): OrderManager {
85
57
  const json = doc.toJSON() as any;
86
58
  const savedOrder: Order = JSON.parse(json.data);
87
59
 
88
- const builder = createOrderBuilder({
89
- currency,
90
- taxContext,
91
- id: savedOrder.id,
92
- });
93
-
94
- // Restore state from saved order
95
- // A customer whose email or id order.create would refuse (see useSale's setCustomer) isn't restored.
96
- if (savedOrder.customer && !customerRefusal(savedOrder.customer)) {
97
- builder.setCustomer(savedOrder.customer);
98
- }
99
- if (savedOrder.note) {
100
- builder.setNote(savedOrder.note);
101
- }
102
-
103
- // Restore line items and their discounts
104
- for (const line of savedOrder.lineItems) {
105
- const lineId = builder.addLine({
106
- productId: line.productId,
107
- variantId: line.variantId,
108
- name: line.name,
109
- sku: line.sku,
110
- imageUrl: line.imageUrl,
111
- unitPrice: { amount: line.unitPriceMinor, currency, taxInclusive: line.taxInclusive },
112
- quantity: line.quantity,
113
- taxRates: line.taxLines.map(({ code, ratePpm }) => ({ code, ratePpm })),
114
- });
115
- for (const discount of line.discounts) {
116
- builder.applyLineDiscount(lineId, {
117
- type: discount.type,
118
- value: discount.value,
119
- label: discount.label,
120
- couponCode: discount.couponCode,
121
- });
122
- }
123
- }
124
-
125
- // Restore order-level discounts
126
- for (const discount of savedOrder.discounts) {
127
- builder.applyOrderDiscount({
128
- type: discount.type,
129
- value: discount.value,
130
- label: discount.label,
131
- couponCode: discount.couponCode,
132
- });
133
- }
134
-
135
- // Restore payments
136
- for (const payment of savedOrder.payments) {
137
- builder.addPayment({
138
- method: payment.method,
139
- amountMinor: payment.amountMinor,
140
- tenderedMinor: payment.tenderedMinor,
141
- changeMinor: payment.changeMinor,
142
- reference: payment.reference,
143
- });
144
- }
60
+ const builder = restoreOrderDraft(savedOrder, { currency, taxContext });
145
61
 
146
62
  await doc.remove();
147
63
 
@@ -1,11 +1,19 @@
1
1
  import { findVariantByCode, formatMoney, resolvePrice } from '@tallyui/core';
2
- import type { ProductTraits, VariantSummary } from '@tallyui/core';
2
+ import type { ProductTraits, TraitContext, VariantSummary } from '@tallyui/core';
3
3
 
4
4
  export type CatalogueEntry<Doc> = { product: Doc; variant: VariantSummary };
5
5
 
6
- /** Every variant of every product, in product order then variant order. */
7
- export function catalogueEntries<Doc>(products: Doc[], traits: ProductTraits<Doc>): CatalogueEntry<Doc>[] {
8
- return products.flatMap((product) => traits.getVariants!(product).map((variant) => ({ product, variant })));
6
+ /** Variants in product/variant order, passing context through; without getVariants,
7
+ * simple products use their own traits and products with variants contribute no entries. */
8
+ export function catalogueEntries<Doc>(products: Doc[], traits: ProductTraits<Doc>, context?: TraitContext): CatalogueEntry<Doc>[] {
9
+ return products.flatMap((product) => {
10
+ const variants = traits.getVariants ? traits.getVariants(product, context)
11
+ : traits.hasVariants(product) ? [] : [{
12
+ id: traits.getId(product), sku: traits.getSku(product), barcode: traits.getBarcode(product),
13
+ prices: traits.getPrices(product, context), stock: traits.getStock(product),
14
+ }];
15
+ return variants.map((variant) => ({ product, variant }));
16
+ });
9
17
  }
10
18
 
11
19
  /** Barcode-then-SKU lookup across all products. */
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import type { RxCollection } from 'rxdb';
2
3
  import type { ProductTraits, ServerCapabilities, StoreSettings } from '@tallyui/core';
3
- import { createOrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
4
+ import { createOrderBuilder, writeOrderDraft, restoreOrderDraft, type OrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
4
5
  import { finalizeOrder, type PosOrder } from '../pos-order';
5
6
  import { MESSAGE_NAME_MAX, referenceError, referenceReason, withSentForm } from '../pos-order/finalize';
6
7
  import { CUSTOMER_REFUSALS, customerRefusal, cutText, PAYLOAD_STRING_MAX } from '../pos-order/command';
@@ -30,6 +31,8 @@ export const HUNG_SAVE_CHECK_MS = 5000;
30
31
  /** Call under a `TaxProvider`: its tax context and the settings' currency price every sale. */
31
32
  export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
32
33
  registerId: string; cashierRef: string; capabilities?: ServerCapabilities;
34
+ /** Where `park()` keeps parked carts; create it with `orderDraftSchema` */
35
+ drafts?: RxCollection;
33
36
  /**
34
37
  * When set, `complete()` stamps the finalized order with this session before `onSaleCompleted`.
35
38
  * `complete()` runs after the money is taken, so a refused stamp (the session closed or went
@@ -103,7 +106,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
103
106
  return active;
104
107
  }
105
108
  // newSale()'s body (see its doc comment for the guards): `newSale()` is `resetSale(null)`.
106
- function resetSale(customer: CustomerSummary | null) {
109
+ function resetSale(customer: CustomerSummary | null, restored?: OrderBuilder) {
107
110
  if (inFlight.current && !pending.current) return setError(SALE_SAVING);
108
111
  if (pending.current && confirmed.current !== pending.current) {
109
112
  if (inFlight.current) checkStored(pending.current);
@@ -115,7 +118,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
115
118
  droppedReference.current = null;
116
119
  setSaving(false);
117
120
  madeWith.current = { taxContext, currency: settings.currency };
118
- const next = createOrderBuilder({ currency: settings.currency, taxContext });
121
+ const next = restored ?? createOrderBuilder({ currency: settings.currency, taxContext });
119
122
  if (customer !== null) next.setCustomer(customer);
120
123
  builderNow.current = next;
121
124
  setBuilder(next);
@@ -258,6 +261,17 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
258
261
  }
259
262
  },
260
263
  setQuantity(lineId: string, quantity: number) { if (!locked()) builderNow.current.updateQuantity(lineId, quantity); },
264
+ /** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
265
+ setUnitPrice(lineId: string, amountMinor: number): string | null {
266
+ if (locked()) return SALE_SAVING;
267
+ try {
268
+ builderNow.current.setUnitPrice(lineId, amountMinor);
269
+ return null;
270
+ } catch (error) {
271
+ if (error instanceof RangeError || (error instanceof Error && error.message === `Unknown line ${lineId}`)) return error.message;
272
+ throw error;
273
+ }
274
+ },
261
275
  remove(lineId: string) { if (!locked()) builderNow.current.removeItem(lineId); },
262
276
  /** A line's discount, or the order's without a line; returns the refusal to show, or null once applied. */
263
277
  applyDiscount(lineId: string | null, discount: Discount): string | null {
@@ -381,6 +395,39 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
381
395
  * updates or requeues `pos_orders` itself.
382
396
  */
383
397
  newSale() { resetSale(null); },
398
+ /** Parks the cart in `drafts`; resolves to a refusal message, or null once parked. */
399
+ async park(): Promise<string | null> {
400
+ if (locked()) return SALE_SAVING;
401
+ if (!opts.drafts) throw new Error('useSale: park() needs the drafts option');
402
+ if (stageNow.current.kind !== 'cart') return 'Finish or cancel the payment before parking the sale';
403
+ const builder = builderNow.current;
404
+ const snapshot = builder.getSnapshot();
405
+ if (!snapshot.lineItems.length) return 'There is nothing to park';
406
+ await writeOrderDraft(opts.drafts, snapshot);
407
+ if (builderNow.current !== builder || builder.getSnapshot() !== snapshot) return 'The sale changed while it was being parked; park it again';
408
+ resetSale(null);
409
+ return null;
410
+ },
411
+ /** Resumes a draft into an empty cart; resolves to a refusal message, or null once resumed. */
412
+ async resume(draftId: string): Promise<string | null> {
413
+ if (locked()) return SALE_SAVING;
414
+ if (!opts.drafts) throw new Error('useSale: resume() needs the drafts option');
415
+ const live = builderNow.current;
416
+ const liveSnapshot = live.getSnapshot();
417
+ if (stageNow.current.kind !== 'cart' || liveSnapshot.lineItems.length) return 'Park or clear the current sale first';
418
+ const doc = await opts.drafts.findOne(draftId).exec();
419
+ if (!doc) return 'That parked sale is no longer there';
420
+ const saved: Order = JSON.parse(doc.toJSON().data);
421
+ if (saved.currency.toUpperCase() !== settings.currency.toUpperCase()) return 'That parked sale is in another currency';
422
+ const builder = restoreOrderDraft(saved, { currency: settings.currency, taxContext });
423
+ await doc.remove();
424
+ if (builderNow.current !== live || live.getSnapshot() !== liveSnapshot) {
425
+ await writeOrderDraft(opts.drafts, saved);
426
+ return 'The sale changed while it was being resumed; resume it again';
427
+ }
428
+ resetSale(null, builder);
429
+ return null;
430
+ },
384
431
  /**
385
432
  * Continue after a failed save whose order is confirmed stored (`canContinue`): exactly `newSale()`.
386
433
  * The order stays pending in the outbox, which will send it; it isn't handed to `onSaleCompleted` again, and