@tallyui/pos 3.2.0 → 3.2.1

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
@@ -245,6 +245,8 @@ interface Order {
245
245
  id: string;
246
246
  status: 'draft' | 'parked' | 'saved' | 'completed';
247
247
  lineItems: LineItem[];
248
+ fees?: FeeLine[];
249
+ shipping?: ShippingLine[];
248
250
  discounts: AppliedDiscount[];
249
251
  payments: Payment[];
250
252
  customer: CustomerSummary | null;
@@ -281,6 +283,16 @@ interface DisplayTotals {
281
283
  taxMinor: number;
282
284
  totalMinor: number;
283
285
  lines: DisplayLine[];
286
+ fees?: Array<{
287
+ id: string;
288
+ name: string;
289
+ amountMinor: number;
290
+ }>;
291
+ shipping?: Array<{
292
+ id: string;
293
+ name: string;
294
+ amountMinor: number;
295
+ }>;
284
296
  orderDiscountMinor: number;
285
297
  }
286
298
  /** One line in the display mode (ADR-063): before any discount, with its own discounts as sub-rows. */
@@ -295,6 +307,8 @@ interface DisplayLine {
295
307
  }
296
308
  interface LineItem {
297
309
  id: string;
310
+ custom?: true;
311
+ taxStatus?: 'none';
298
312
  productId: string;
299
313
  variantId?: string;
300
314
  name: string;
@@ -312,6 +326,27 @@ interface LineItem {
312
326
  /** Set only when the price's tax mode differs from the order's; named price mode → order mode. */
313
327
  priceTaxModeConverted?: 'inclusive-to-exclusive' | 'exclusive-to-inclusive';
314
328
  }
329
+ interface ChargeLine {
330
+ id: string;
331
+ name: string;
332
+ amountMinor: number;
333
+ taxClass?: string;
334
+ taxStatus: 'taxable' | 'none';
335
+ taxLines: LineItem['taxLines'];
336
+ netMinor: number;
337
+ taxMicros: number;
338
+ }
339
+ interface FeeLine extends ChargeLine {
340
+ }
341
+ interface ShippingLine extends ChargeLine {
342
+ methodId?: string;
343
+ }
344
+ interface ChargeInput {
345
+ name: string;
346
+ amountMinor: number;
347
+ taxClass?: string;
348
+ taxStatus?: 'taxable' | 'none';
349
+ }
315
350
  interface LineTaxLine {
316
351
  code?: string;
317
352
  ratePpm: number;
@@ -348,6 +383,8 @@ interface PaymentMethod {
348
383
  }
349
384
  interface AddLineInput {
350
385
  productId: string;
386
+ custom?: true;
387
+ taxStatus?: 'taxable' | 'none';
351
388
  variantId?: string;
352
389
  name: string;
353
390
  sku?: string;
@@ -375,6 +412,16 @@ interface OrderBuilder {
375
412
  quantity?: number;
376
413
  }): string;
377
414
  addLine(input: AddLineInput): string;
415
+ addFee(input: ChargeInput): string;
416
+ updateFee(id: string, patch: Partial<ChargeInput>): void;
417
+ removeFee(id: string): void;
418
+ addShipping(input: ChargeInput & {
419
+ methodId?: string;
420
+ }): string;
421
+ updateShipping(id: string, patch: Partial<ChargeInput & {
422
+ methodId?: string;
423
+ }>): void;
424
+ removeShipping(id: string): void;
378
425
  updateQuantity(lineId: string, quantity: number): void;
379
426
  /** 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
427
  setUnitPrice(lineId: string, amountMinor: number): void;
@@ -496,6 +543,14 @@ interface ReceiptData {
496
543
  register?: string;
497
544
  };
498
545
  lineItems: ReceiptLineItem[];
546
+ fees: Array<{
547
+ name: string;
548
+ amountMinor: number;
549
+ }>;
550
+ shipping: Array<{
551
+ name: string;
552
+ amountMinor: number;
553
+ }>;
499
554
  discounts: {
500
555
  label: string;
501
556
  amountMinor: number;
@@ -1886,6 +1941,37 @@ declare function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
1886
1941
  canContinue: boolean;
1887
1942
  add(entry: CatalogueEntry<any>, traits: ProductTraits<any>): void;
1888
1943
  setQuantity(lineId: string, quantity: number): void;
1944
+ /** ADR-075: adds a fee; returns its id or the builder's refusal. */
1945
+ addFee(input: ChargeInput): {
1946
+ id: string;
1947
+ } | string;
1948
+ /** ADR-075: updates a fee; returns a refusal or null. */
1949
+ updateFee(id: string, patch: Partial<ChargeInput>): string | null;
1950
+ /** ADR-075: removes a fee; returns a refusal or null. */
1951
+ removeFee(id: string): string | null;
1952
+ /** ADR-075: adds shipping; returns its id or the builder's refusal. */
1953
+ addShipping(input: ChargeInput & {
1954
+ methodId?: string;
1955
+ }): {
1956
+ id: string;
1957
+ } | string;
1958
+ /** ADR-075: updates shipping; returns a refusal or null. */
1959
+ updateShipping(id: string, patch: Partial<ChargeInput & {
1960
+ methodId?: string;
1961
+ }>): string | null;
1962
+ /** ADR-075: removes shipping; returns a refusal or null. */
1963
+ removeShipping(id: string): string | null;
1964
+ /** ADR-075: adds a distinct custom product line; returns its id or the builder's refusal. */
1965
+ addCustomLine(input: {
1966
+ name: string;
1967
+ priceMinor: number;
1968
+ quantity?: number;
1969
+ taxClass?: string;
1970
+ taxStatus?: "taxable" | "none";
1971
+ sku?: string;
1972
+ }): {
1973
+ id: string;
1974
+ } | string;
1889
1975
  /** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
1890
1976
  setUnitPrice(lineId: string, amountMinor: number): string | null;
1891
1977
  remove(lineId: string): void;
@@ -2329,4 +2415,4 @@ declare function planLegs(plan: TenderPlan, rowsSinceFrom: {
2329
2415
  };
2330
2416
  };
2331
2417
 
2332
- 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, useParkedSales, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure, writeOrderDraft };
2418
+ export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type ChargeInput, type ChargeLine, 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 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 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 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 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, useParkedSales, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure, writeOrderDraft };
package/dist/index.js CHANGED
@@ -523,6 +523,8 @@ function createOrderBuilder(options) {
523
523
  const currency = options.currency.toUpperCase();
524
524
  const orderId = options.id ?? uid();
525
525
  let lineItems = [];
526
+ let fees = [];
527
+ let shipping = [];
526
528
  let orderDiscounts = [];
527
529
  let payments = [];
528
530
  let customer = null;
@@ -576,8 +578,15 @@ function createOrderBuilder(options) {
576
578
  });
577
579
  const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
578
580
  const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
579
- const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines, taxContext.rounding);
580
- if (taxContext.rounding?.granularity === "per_rate_group_items" && lines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
581
+ const { subtotalMinor } = sumLines(lines, taxContext.rounding);
582
+ const charges = [...fees, ...shipping];
583
+ const taxedLines = [...lines, ...charges.map((charge) => ({
584
+ ...charge,
585
+ taxInclusive: taxContext.pricesIncludeTax,
586
+ taxMicros: charge.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n).toString()
587
+ }))];
588
+ const { taxMinor, totalMinor } = sumLines(taxedLines, taxContext.rounding);
589
+ if (taxContext.rounding?.granularity === "per_rate_group_items" && taxedLines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
581
590
  perRateGroupWarned.add(taxContext);
582
591
  taxLogger.warn("per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)");
583
592
  }
@@ -601,7 +610,7 @@ function createOrderBuilder(options) {
601
610
  (sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum),
602
611
  orderDiscountDisplay
603
612
  );
604
- const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount;
613
+ const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount - charges.reduce((sum, charge) => sum + charge.amountMinor, 0);
605
614
  const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
606
615
  const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
607
616
  const conversions = converted.reduce((count, index) => count + [
@@ -635,7 +644,9 @@ function createOrderBuilder(options) {
635
644
  taxMinor,
636
645
  totalMinor,
637
646
  lines: displayLines,
638
- orderDiscountMinor: orderDiscountDisplay
647
+ orderDiscountMinor: orderDiscountDisplay,
648
+ ...fees.length ? { fees: fees.map(({ id: id2, name, amountMinor }) => ({ id: id2, name, amountMinor })) } : {},
649
+ ...shipping.length ? { shipping: shipping.map(({ id: id2, name, amountMinor }) => ({ id: id2, name, amountMinor })) } : {}
639
650
  };
640
651
  const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
641
652
  const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
@@ -644,6 +655,8 @@ function createOrderBuilder(options) {
644
655
  id: orderId,
645
656
  status: "draft",
646
657
  lineItems: lines,
658
+ ...fees.length ? { fees: [...fees] } : {},
659
+ ...shipping.length ? { shipping: [...shipping] } : {},
647
660
  discounts: recalcedOrderDiscounts,
648
661
  payments: [...payments],
649
662
  customer,
@@ -666,6 +679,42 @@ function createOrderBuilder(options) {
666
679
  function emit() {
667
680
  subject.next(buildOrder());
668
681
  }
682
+ function recalculateCharge(id2, input) {
683
+ if (!Number.isInteger(input.amountMinor)) throw new RangeError("Amount must be integer minor units");
684
+ if (input.amountMinor < 0) throw new RangeError("Amount must be >= 0");
685
+ if (!input.name.trim()) throw new RangeError("Name must not be empty");
686
+ const taxStatus = input.taxStatus ?? "taxable";
687
+ const code = taxContext.getTaxRateCode?.(input.taxClass);
688
+ const line = recalculateLine({
689
+ id: id2,
690
+ productId: id2,
691
+ name: input.name,
692
+ sku: "",
693
+ unitPriceMinor: input.amountMinor,
694
+ quantity: 1,
695
+ taxLines: [{
696
+ ...code !== void 0 ? { code } : {},
697
+ ratePpm: taxStatus === "none" ? 0 : taxContext.getTaxRatePpm(input.taxClass),
698
+ taxMicros: "0"
699
+ }],
700
+ discounts: [],
701
+ discountMinor: 0,
702
+ orderDiscountMinor: 0,
703
+ netMinor: 0,
704
+ taxMicros: "0",
705
+ taxInclusive: taxContext.pricesIncludeTax
706
+ });
707
+ return {
708
+ id: id2,
709
+ name: input.name,
710
+ amountMinor: input.amountMinor,
711
+ taxClass: input.taxClass,
712
+ taxStatus,
713
+ taxLines: line.taxLines,
714
+ netMinor: line.netMinor,
715
+ taxMicros: Number(line.taxMicros)
716
+ };
717
+ }
669
718
  function addLine(input) {
670
719
  const { productId, variantId, unitPrice } = input;
671
720
  const quantity = input.quantity ?? 1;
@@ -673,10 +722,10 @@ function createOrderBuilder(options) {
673
722
  if (!Number.isInteger(unitPrice.amount)) throw new RangeError("Price must be integer minor units");
674
723
  if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError("Quantity must be an integer >= 1");
675
724
  const code = input.taxRates ? void 0 : taxContext.getTaxRateCode?.(input.taxClass);
676
- const taxRates = input.taxRates ?? [{ ...code !== void 0 ? { code } : {}, ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
725
+ const taxRates = input.taxStatus === "none" ? [{ ...code !== void 0 ? { code } : {}, ratePpm: 0 }] : input.taxRates ?? [{ ...code !== void 0 ? { code } : {}, ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
677
726
  const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
678
727
  const existing = lineItems.find(
679
- (li) => li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm)
728
+ (li) => !input.custom && !li.custom && li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm)
680
729
  );
681
730
  if (existing) {
682
731
  lineItems = lineItems.map(
@@ -689,6 +738,8 @@ function createOrderBuilder(options) {
689
738
  const line = {
690
739
  id: lineId,
691
740
  productId,
741
+ ...input.custom ? { custom: true } : {},
742
+ ...input.taxStatus === "none" ? { taxStatus: "none" } : {},
692
743
  variantId,
693
744
  name: input.name,
694
745
  sku: input.sku ?? "",
@@ -716,6 +767,43 @@ function createOrderBuilder(options) {
716
767
  return {
717
768
  order$: subject.asObservable(),
718
769
  addLine,
770
+ addFee(input) {
771
+ const fee = recalculateCharge(uid(), input);
772
+ fees = [...fees, fee];
773
+ emit();
774
+ return fee.id;
775
+ },
776
+ updateFee(id2, patch) {
777
+ const fee = fees.find((entry) => entry.id === id2);
778
+ if (!fee) throw new Error(`Unknown fee ${id2}`);
779
+ const updated = recalculateCharge(id2, { ...fee, ...patch });
780
+ fees = fees.map((entry) => entry.id === id2 ? updated : entry);
781
+ emit();
782
+ },
783
+ removeFee(id2) {
784
+ if (!fees.some((entry) => entry.id === id2)) throw new Error(`Unknown fee ${id2}`);
785
+ fees = fees.filter((entry) => entry.id !== id2);
786
+ emit();
787
+ },
788
+ addShipping(input) {
789
+ const charge = { ...recalculateCharge(uid(), input), methodId: input.methodId };
790
+ shipping = [...shipping, charge];
791
+ emit();
792
+ return charge.id;
793
+ },
794
+ updateShipping(id2, patch) {
795
+ const charge = shipping.find((entry) => entry.id === id2);
796
+ if (!charge) throw new Error(`Unknown shipping ${id2}`);
797
+ const input = { ...charge, ...patch };
798
+ const updated = { ...recalculateCharge(id2, input), methodId: input.methodId };
799
+ shipping = shipping.map((entry) => entry.id === id2 ? updated : entry);
800
+ emit();
801
+ },
802
+ removeShipping(id2) {
803
+ if (!shipping.some((entry) => entry.id === id2)) throw new Error(`Unknown shipping ${id2}`);
804
+ shipping = shipping.filter((entry) => entry.id !== id2);
805
+ emit();
806
+ },
719
807
  addProduct(doc, traits, opts) {
720
808
  const productId = traits.getId(doc);
721
809
  if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
@@ -811,6 +899,8 @@ function createOrderBuilder(options) {
811
899
  },
812
900
  clear() {
813
901
  lineItems = [];
902
+ fees = [];
903
+ shipping = [];
814
904
  orderDiscounts = [];
815
905
  payments = [];
816
906
  customer = null;
@@ -988,6 +1078,8 @@ function restoreOrderDraft(saved, options) {
988
1078
  for (const line of saved.lineItems) {
989
1079
  const lineId = builder.addLine({
990
1080
  productId: line.productId,
1081
+ custom: line.custom,
1082
+ taxStatus: line.taxStatus,
991
1083
  variantId: line.variantId,
992
1084
  name: line.name,
993
1085
  sku: line.sku,
@@ -1005,6 +1097,8 @@ function restoreOrderDraft(saved, options) {
1005
1097
  });
1006
1098
  }
1007
1099
  }
1100
+ for (const fee of saved.fees ?? []) builder.addFee(fee);
1101
+ for (const charge of saved.shipping ?? []) builder.addShipping(charge);
1008
1102
  for (const discount of saved.discounts) {
1009
1103
  builder.applyOrderDiscount({
1010
1104
  type: discount.type,
@@ -1105,7 +1199,11 @@ function useParkedSales(drafts) {
1105
1199
 
1106
1200
  // src/receipt/build-receipt-data.ts
1107
1201
  function buildReceiptData(order, config) {
1108
- const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels, order.taxRounding).map(
1202
+ const taxedLines = [...order.lineItems, ...[...order.fees ?? [], ...order.shipping ?? []].map((charge) => ({
1203
+ ...charge,
1204
+ taxInclusive: order.pricesIncludeTax
1205
+ }))];
1206
+ const taxLines = taxLinesByRate(taxedLines, order.taxMinor, config.taxLabels, order.taxRounding).map(
1109
1207
  ({ label: label2, code, ratePpm, amountMinor }) => ({ label: label2, code, ratePpm, amountMinor })
1110
1208
  );
1111
1209
  let taxSoFar = order.lineItems.reduce(
@@ -1145,6 +1243,8 @@ function buildReceiptData(order, config) {
1145
1243
  label: label(d),
1146
1244
  amountMinor: d.amountMinor
1147
1245
  })),
1246
+ fees: (order.display.fees ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
1247
+ shipping: (order.display.shipping ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
1148
1248
  orderDiscountMinor: order.display.orderDiscountMinor,
1149
1249
  totals: {
1150
1250
  taxInclusive: order.display.taxInclusive,
@@ -1321,6 +1421,9 @@ function referenceReason(value) {
1321
1421
  return value.includes("\0") ? "nul" : null;
1322
1422
  }
1323
1423
  function finalizeOrder(order, options = {}) {
1424
+ if (order.fees?.length || order.shipping?.length || order.lineItems.some((line) => line.custom)) {
1425
+ throw new Error("finalize: fees, shipping and custom lines need order.create version 5, which this release does not send yet");
1426
+ }
1324
1427
  if (!order.lineItems.length) throw new Error("finalize: no lines");
1325
1428
  if (order.discountMinor < 0 || order.lineItems.some((line) => line.discountMinor < 0) || order.discounts.some((d) => d.amountMinor < 0)) {
1326
1429
  throw new Error("finalize: negative discount");
@@ -3628,10 +3731,10 @@ function useSale(settings, opts) {
3628
3731
  const subscription = rendered.order$.subscribe(setOrder);
3629
3732
  return () => subscription.unsubscribe();
3630
3733
  }, [rendered]);
3631
- const idle3 = stage.kind === "cart" && !order.lineItems.length;
3734
+ const idle3 = stage.kind === "cart" && !order.lineItems.length && !order.fees?.length && !order.shipping?.length;
3632
3735
  useLayoutEffect(() => {
3633
3736
  const live = builderNow.current.getSnapshot();
3634
- const liveIdle = stageNow.current.kind === "cart" && !live.lineItems.length && !inFlight.current && !pending.current;
3737
+ const liveIdle = stageNow.current.kind === "cart" && !live.lineItems.length && !live.fees?.length && !live.shipping?.length && !inFlight.current && !pending.current;
3635
3738
  if (liveIdle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) resetSale(live.customer ?? null);
3636
3739
  });
3637
3740
  useEffect4(() => () => {
@@ -3754,6 +3857,85 @@ function useSale(settings, opts) {
3754
3857
  setQuantity(lineId, quantity) {
3755
3858
  if (!locked()) builderNow.current.updateQuantity(lineId, quantity);
3756
3859
  },
3860
+ /** ADR-075: adds a fee; returns its id or the builder's refusal. */
3861
+ addFee(input) {
3862
+ if (locked()) return SALE_SAVING;
3863
+ try {
3864
+ return { id: builderNow.current.addFee(input) };
3865
+ } catch (error2) {
3866
+ if (error2 instanceof Error) return error2.message;
3867
+ throw error2;
3868
+ }
3869
+ },
3870
+ /** ADR-075: updates a fee; returns a refusal or null. */
3871
+ updateFee(id2, patch) {
3872
+ if (locked()) return SALE_SAVING;
3873
+ try {
3874
+ builderNow.current.updateFee(id2, patch);
3875
+ return null;
3876
+ } catch (error2) {
3877
+ if (error2 instanceof Error) return error2.message;
3878
+ throw error2;
3879
+ }
3880
+ },
3881
+ /** ADR-075: removes a fee; returns a refusal or null. */
3882
+ removeFee(id2) {
3883
+ if (locked()) return SALE_SAVING;
3884
+ try {
3885
+ builderNow.current.removeFee(id2);
3886
+ return null;
3887
+ } catch (error2) {
3888
+ if (error2 instanceof Error) return error2.message;
3889
+ throw error2;
3890
+ }
3891
+ },
3892
+ /** ADR-075: adds shipping; returns its id or the builder's refusal. */
3893
+ addShipping(input) {
3894
+ if (locked()) return SALE_SAVING;
3895
+ try {
3896
+ return { id: builderNow.current.addShipping(input) };
3897
+ } catch (error2) {
3898
+ if (error2 instanceof Error) return error2.message;
3899
+ throw error2;
3900
+ }
3901
+ },
3902
+ /** ADR-075: updates shipping; returns a refusal or null. */
3903
+ updateShipping(id2, patch) {
3904
+ if (locked()) return SALE_SAVING;
3905
+ try {
3906
+ builderNow.current.updateShipping(id2, patch);
3907
+ return null;
3908
+ } catch (error2) {
3909
+ if (error2 instanceof Error) return error2.message;
3910
+ throw error2;
3911
+ }
3912
+ },
3913
+ /** ADR-075: removes shipping; returns a refusal or null. */
3914
+ removeShipping(id2) {
3915
+ if (locked()) return SALE_SAVING;
3916
+ try {
3917
+ builderNow.current.removeShipping(id2);
3918
+ return null;
3919
+ } catch (error2) {
3920
+ if (error2 instanceof Error) return error2.message;
3921
+ throw error2;
3922
+ }
3923
+ },
3924
+ /** ADR-075: adds a distinct custom product line; returns its id or the builder's refusal. */
3925
+ addCustomLine(input) {
3926
+ if (locked()) return SALE_SAVING;
3927
+ try {
3928
+ return { id: builderNow.current.addLine({
3929
+ ...input,
3930
+ custom: true,
3931
+ productId: `custom:${uuidv7()}`,
3932
+ unitPrice: { amount: input.priceMinor, currency: madeWith.current.currency.toUpperCase() }
3933
+ }) };
3934
+ } catch (error2) {
3935
+ if (error2 instanceof Error) return error2.message;
3936
+ throw error2;
3937
+ }
3938
+ },
3757
3939
  /** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
3758
3940
  setUnitPrice(lineId, amountMinor) {
3759
3941
  if (locked()) return SALE_SAVING;
@@ -3921,7 +4103,7 @@ function useSale(settings, opts) {
3921
4103
  if (stageNow.current.kind !== "cart") return "Finish or cancel the payment before parking the sale";
3922
4104
  const builder = builderNow.current;
3923
4105
  const snapshot = builder.getSnapshot();
3924
- if (!snapshot.lineItems.length) return "There is nothing to park";
4106
+ if (!snapshot.lineItems.length && !snapshot.fees?.length && !snapshot.shipping?.length) return "There is nothing to park";
3925
4107
  await writeOrderDraft(opts.drafts, snapshot);
3926
4108
  if (builderNow.current !== builder || builder.getSnapshot() !== snapshot) return "The sale changed while it was being parked; park it again";
3927
4109
  resetSale(null);
@@ -3933,7 +4115,7 @@ function useSale(settings, opts) {
3933
4115
  if (!opts.drafts) throw new Error("useSale: resume() needs the drafts option");
3934
4116
  const live = builderNow.current;
3935
4117
  const liveSnapshot = live.getSnapshot();
3936
- if (stageNow.current.kind !== "cart" || liveSnapshot.lineItems.length) return "Park or clear the current sale first";
4118
+ if (stageNow.current.kind !== "cart" || liveSnapshot.lineItems.length || liveSnapshot.fees?.length || liveSnapshot.shipping?.length) return "Park or clear the current sale first";
3937
4119
  const doc = await opts.drafts.findOne(draftId).exec();
3938
4120
  if (!doc) return "That parked sale is no longer there";
3939
4121
  const saved = JSON.parse(doc.toJSON().data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/pos",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
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.2.0",
30
+ "@tallyui/core": "^3.2.1",
31
31
  "react": ">=18",
32
32
  "rxdb": "~17.5.0",
33
33
  "rxjs": ">=7"
34
34
  },
35
35
  "devDependencies": {
36
- "@tallyui/core": "3.2.0",
36
+ "@tallyui/core": "3.2.1",
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.2.0",
42
- "@tallyui/connector-woocommerce": "3.2.0",
43
- "@tallyui/database": "3.2.0"
41
+ "@tallyui/connector-medusa": "3.2.1",
42
+ "@tallyui/connector-woocommerce": "3.2.1",
43
+ "@tallyui/database": "3.2.1"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsup",
package/src/index.ts CHANGED
@@ -37,6 +37,10 @@ export type {
37
37
  LineItem,
38
38
  LineTaxLine,
39
39
  AddLineInput,
40
+ ChargeLine,
41
+ ChargeInput,
42
+ FeeLine,
43
+ ShippingLine,
40
44
  Discount,
41
45
  AppliedDiscount,
42
46
  DisplayTotals,
@@ -13,6 +13,10 @@ export type {
13
13
  LineItem,
14
14
  LineTaxLine,
15
15
  AddLineInput,
16
+ ChargeLine,
17
+ ChargeInput,
18
+ FeeLine,
19
+ ShippingLine,
16
20
  Discount,
17
21
  AppliedDiscount,
18
22
  DisplayTotals,
@@ -12,6 +12,7 @@ import type {
12
12
  Payment,
13
13
  CustomerSummary,
14
14
  AddLineInput,
15
+ ChargeInput, ChargeLine, FeeLine, ShippingLine,
15
16
  } from './types';
16
17
 
17
18
  /** Tax contexts already warned that per_rate_group_items fell back to per_order for inclusive lines (#287). */
@@ -27,7 +28,7 @@ function roundHalfAway(n: number): number {
27
28
  }
28
29
 
29
30
  /** The settlement totals of recalculated lines; the display figures derive from them (ADR-063). */
30
- function sumLines(lines: LineItem[], rounding?: TaxRounding): { subtotalMinor: number; taxMinor: number; totalMinor: number } {
31
+ function sumLines(lines: Pick<LineItem, 'netMinor' | 'taxMicros' | 'taxInclusive' | 'taxLines'>[], rounding?: TaxRounding): { subtotalMinor: number; taxMinor: number; totalMinor: number } {
31
32
  // per_line_items, per_rate_group_items (#287): total = Σ rounded nets + tax (order-level-tax-calculation-strategy.js:44-49, order.entity.js:88-89).
32
33
  const rounded = roundedTaxByRate(lines, rounding);
33
34
  if (rounded) return { subtotalMinor: rounded.baseMinor, taxMinor: rounded.taxMinor, totalMinor: Math.max(0, rounded.baseMinor + rounded.taxMinor) };
@@ -70,6 +71,12 @@ export interface OrderBuilder {
70
71
  order$: Observable<Order>;
71
72
  addProduct(doc: any, traits: ProductTraits, options?: { variantId?: string; quantity?: number }): string;
72
73
  addLine(input: AddLineInput): string;
74
+ addFee(input: ChargeInput): string;
75
+ updateFee(id: string, patch: Partial<ChargeInput>): void;
76
+ removeFee(id: string): void;
77
+ addShipping(input: ChargeInput & { methodId?: string }): string;
78
+ updateShipping(id: string, patch: Partial<ChargeInput & { methodId?: string }>): void;
79
+ removeShipping(id: string): void;
73
80
  updateQuantity(lineId: string, quantity: number): void;
74
81
  /** 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
82
  setUnitPrice(lineId: string, amountMinor: number): void;
@@ -91,6 +98,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
91
98
  const orderId = options.id ?? uid();
92
99
 
93
100
  let lineItems: LineItem[] = [];
101
+ let fees: FeeLine[] = [];
102
+ let shipping: ShippingLine[] = [];
94
103
  let orderDiscounts: AppliedDiscount[] = [];
95
104
  let payments: Payment[] = [];
96
105
  let customer: CustomerSummary | null = null;
@@ -168,8 +177,12 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
168
177
  const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
169
178
  const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
170
179
 
171
- const { subtotalMinor, taxMinor, totalMinor } = sumLines(lines, taxContext.rounding);
172
- if (taxContext.rounding?.granularity === 'per_rate_group_items' && lines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
180
+ const { subtotalMinor } = sumLines(lines, taxContext.rounding);
181
+ const charges = [...fees, ...shipping];
182
+ const taxedLines = [...lines, ...charges.map((charge) => ({ ...charge, taxInclusive: taxContext.pricesIncludeTax,
183
+ taxMicros: charge.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n).toString() }))];
184
+ const { taxMinor, totalMinor } = sumLines(taxedLines, taxContext.rounding);
185
+ if (taxContext.rounding?.granularity === 'per_rate_group_items' && taxedLines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
173
186
  perRateGroupWarned.add(taxContext);
174
187
  taxLogger.warn('per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)');
175
188
  }
@@ -197,7 +210,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
197
210
  const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
198
211
  const displayDiscount = displayLines.reduce(
199
212
  (sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum), orderDiscountDisplay);
200
- const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount;
213
+ const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount
214
+ - charges.reduce((sum, charge) => sum + charge.amountMinor, 0);
201
215
  const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
202
216
  const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
203
217
  const conversions = converted.reduce((count, index) => count + [lines[index].netMinor, lines[index].orderDiscountMinor,
@@ -230,6 +244,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
230
244
  const display = {
231
245
  taxInclusive, subtotalMinor: displaySubtotal, discountMinor: displayDiscount, taxMinor, totalMinor,
232
246
  lines: displayLines, orderDiscountMinor: orderDiscountDisplay,
247
+ ...(fees.length ? { fees: fees.map(({ id, name, amountMinor }) => ({ id, name, amountMinor })) } : {}),
248
+ ...(shipping.length ? { shipping: shipping.map(({ id, name, amountMinor }) => ({ id, name, amountMinor })) } : {}),
233
249
  };
234
250
  const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
235
251
  const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
@@ -239,6 +255,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
239
255
  id: orderId,
240
256
  status: 'draft',
241
257
  lineItems: lines,
258
+ ...(fees.length ? { fees: [...fees] } : {}),
259
+ ...(shipping.length ? { shipping: [...shipping] } : {}),
242
260
  discounts: recalcedOrderDiscounts,
243
261
  payments: [...payments],
244
262
  customer,
@@ -263,6 +281,23 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
263
281
  subject.next(buildOrder());
264
282
  }
265
283
 
284
+ function recalculateCharge(id: string, input: ChargeInput): ChargeLine {
285
+ if (!Number.isInteger(input.amountMinor)) throw new RangeError('Amount must be integer minor units');
286
+ if (input.amountMinor < 0) throw new RangeError('Amount must be >= 0');
287
+ if (!input.name.trim()) throw new RangeError('Name must not be empty');
288
+ const taxStatus = input.taxStatus ?? 'taxable';
289
+ const code = taxContext.getTaxRateCode?.(input.taxClass);
290
+ const line = recalculateLine({
291
+ id, productId: id, name: input.name, sku: '', unitPriceMinor: input.amountMinor, quantity: 1,
292
+ taxLines: [{ ...(code !== undefined ? { code } : {}),
293
+ ratePpm: taxStatus === 'none' ? 0 : taxContext.getTaxRatePpm(input.taxClass), taxMicros: '0' }],
294
+ discounts: [], discountMinor: 0, orderDiscountMinor: 0, netMinor: 0, taxMicros: '0',
295
+ taxInclusive: taxContext.pricesIncludeTax,
296
+ });
297
+ return { id, name: input.name, amountMinor: input.amountMinor, taxClass: input.taxClass, taxStatus,
298
+ taxLines: line.taxLines, netMinor: line.netMinor, taxMicros: Number(line.taxMicros) };
299
+ }
300
+
266
301
  function addLine(input: AddLineInput): string {
267
302
  const { productId, variantId, unitPrice } = input;
268
303
  const quantity = input.quantity ?? 1;
@@ -271,11 +306,12 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
271
306
  if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError('Quantity must be an integer >= 1');
272
307
  // A rate from the tax class carries the backend's name for it when one is mapped (#287, #288).
273
308
  const code = input.taxRates ? undefined : taxContext.getTaxRateCode?.(input.taxClass);
274
- const taxRates = input.taxRates ?? [{ ...(code !== undefined ? { code } : {}), ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
309
+ const taxRates = input.taxStatus === 'none' ? [{ ...(code !== undefined ? { code } : {}), ratePpm: 0 }]
310
+ : input.taxRates ?? [{ ...(code !== undefined ? { code } : {}), ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
275
311
  const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
276
312
 
277
313
  const existing = lineItems.find(
278
- (li) => li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive
314
+ (li) => !input.custom && !li.custom && li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive
279
315
  && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length
280
316
  && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm),
281
317
  );
@@ -292,6 +328,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
292
328
  const line: LineItem = {
293
329
  id: lineId,
294
330
  productId,
331
+ ...(input.custom ? { custom: true as const } : {}),
332
+ ...(input.taxStatus === 'none' ? { taxStatus: 'none' as const } : {}),
295
333
  variantId,
296
334
  name: input.name,
297
335
  sku: input.sku ?? '',
@@ -324,6 +362,36 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
324
362
  order$: subject.asObservable(),
325
363
  addLine,
326
364
 
365
+ addFee(input) {
366
+ const fee = recalculateCharge(uid(), input);
367
+ fees = [...fees, fee]; emit(); return fee.id;
368
+ },
369
+ updateFee(id, patch) {
370
+ const fee = fees.find((entry) => entry.id === id);
371
+ if (!fee) throw new Error(`Unknown fee ${id}`);
372
+ const updated = recalculateCharge(id, { ...fee, ...patch });
373
+ fees = fees.map((entry) => entry.id === id ? updated : entry); emit();
374
+ },
375
+ removeFee(id) {
376
+ if (!fees.some((entry) => entry.id === id)) throw new Error(`Unknown fee ${id}`);
377
+ fees = fees.filter((entry) => entry.id !== id); emit();
378
+ },
379
+ addShipping(input) {
380
+ const charge = { ...recalculateCharge(uid(), input), methodId: input.methodId };
381
+ shipping = [...shipping, charge]; emit(); return charge.id;
382
+ },
383
+ updateShipping(id, patch) {
384
+ const charge = shipping.find((entry) => entry.id === id);
385
+ if (!charge) throw new Error(`Unknown shipping ${id}`);
386
+ const input = { ...charge, ...patch };
387
+ const updated = { ...recalculateCharge(id, input), methodId: input.methodId };
388
+ shipping = shipping.map((entry) => entry.id === id ? updated : entry); emit();
389
+ },
390
+ removeShipping(id) {
391
+ if (!shipping.some((entry) => entry.id === id)) throw new Error(`Unknown shipping ${id}`);
392
+ shipping = shipping.filter((entry) => entry.id !== id); emit();
393
+ },
394
+
327
395
  addProduct(doc, traits, opts) {
328
396
  const productId = traits.getId(doc);
329
397
  if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
@@ -432,6 +500,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
432
500
 
433
501
  clear() {
434
502
  lineItems = [];
503
+ fees = [];
504
+ shipping = [];
435
505
  orderDiscounts = [];
436
506
  payments = [];
437
507
  customer = null;
@@ -65,6 +65,8 @@ export function restoreOrderDraft(saved: Order, options: { currency: string; tax
65
65
  for (const line of saved.lineItems) {
66
66
  const lineId = builder.addLine({
67
67
  productId: line.productId,
68
+ custom: line.custom,
69
+ taxStatus: line.taxStatus,
68
70
  variantId: line.variantId,
69
71
  name: line.name,
70
72
  sku: line.sku,
@@ -83,6 +85,9 @@ export function restoreOrderDraft(saved: Order, options: { currency: string; tax
83
85
  }
84
86
  }
85
87
 
88
+ for (const fee of saved.fees ?? []) builder.addFee(fee);
89
+ for (const charge of saved.shipping ?? []) builder.addShipping(charge);
90
+
86
91
  // Restore order-level discounts
87
92
  for (const discount of saved.discounts) {
88
93
  builder.applyOrderDiscount({
@@ -4,6 +4,8 @@ export interface Order {
4
4
  id: string;
5
5
  status: 'draft' | 'parked' | 'saved' | 'completed';
6
6
  lineItems: LineItem[];
7
+ fees?: FeeLine[];
8
+ shipping?: ShippingLine[];
7
9
  discounts: AppliedDiscount[];
8
10
  payments: Payment[];
9
11
  customer: CustomerSummary | null;
@@ -34,11 +36,13 @@ export type SentOrder = Omit<Order, 'customer'> & { customer: (Omit<CustomerSumm
34
36
  */
35
37
  export interface DisplayTotals {
36
38
  taxInclusive: boolean; // = order.pricesIncludeTax
37
- subtotalMinor: number; // before discounts: total − tax + discount when exclusive, total + discount when inclusive
39
+ subtotalMinor: number; // product lines before discounts, excluding fees and shipping (ADR-075)
38
40
  discountMinor: number; // Σ lines' discount rows + orderDiscountMinor, in the display mode; >= 0
39
41
  taxMinor: number; // = order.taxMinor (added when exclusive, included when inclusive)
40
42
  totalMinor: number; // = order.totalMinor
41
43
  lines: DisplayLine[]; // in lineItems order; Σ amountMinor = subtotalMinor
44
+ fees?: Array<{ id: string; name: string; amountMinor: number }>;
45
+ shipping?: Array<{ id: string; name: string; amountMinor: number }>;
42
46
  orderDiscountMinor: number; // the order discounts as one row, not allocated: Σ each line's share, converted on its own
43
47
  }
44
48
 
@@ -51,6 +55,8 @@ export interface DisplayLine {
51
55
 
52
56
  export interface LineItem {
53
57
  id: string;
58
+ custom?: true;
59
+ taxStatus?: 'none';
54
60
  productId: string;
55
61
  variantId?: string;
56
62
  name: string;
@@ -69,6 +75,20 @@ export interface LineItem {
69
75
  priceTaxModeConverted?: 'inclusive-to-exclusive' | 'exclusive-to-inclusive';
70
76
  }
71
77
 
78
+ export interface ChargeLine {
79
+ id: string;
80
+ name: string;
81
+ amountMinor: number; // integer >= 0, in the order's tax mode (ADR-075)
82
+ taxClass?: string;
83
+ taxStatus: 'taxable' | 'none';
84
+ taxLines: LineItem['taxLines'];
85
+ netMinor: number;
86
+ taxMicros: number;
87
+ }
88
+ export interface FeeLine extends ChargeLine {}
89
+ export interface ShippingLine extends ChargeLine { methodId?: string }
90
+ export interface ChargeInput { name: string; amountMinor: number; taxClass?: string; taxStatus?: 'taxable' | 'none' }
91
+
72
92
  export interface LineTaxLine {
73
93
  code?: string;
74
94
  ratePpm: number;
@@ -113,6 +133,8 @@ export interface PaymentMethod {
113
133
 
114
134
  export interface AddLineInput {
115
135
  productId: string;
136
+ custom?: true;
137
+ taxStatus?: 'taxable' | 'none';
116
138
  variantId?: string;
117
139
  name: string;
118
140
  sku?: string;
@@ -94,6 +94,9 @@ export function referenceReason(value: string | undefined): 'long' | 'nul' | nul
94
94
  * customer email or id the shape check would refuse is left out, so `toOrderCreateEnvelope` sends it unchanged.
95
95
  */
96
96
  export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosOrder {
97
+ if (order.fees?.length || order.shipping?.length || order.lineItems.some((line) => line.custom)) {
98
+ throw new Error('finalize: fees, shipping and custom lines need order.create version 5, which this release does not send yet');
99
+ }
97
100
  if (!order.lineItems.length) throw new Error('finalize: no lines');
98
101
  // Defence in depth: the builder already clamps every discount to >= 0, so this should never fire.
99
102
  if (order.discountMinor < 0
@@ -5,7 +5,10 @@ import type { ReceiptConfig, ReceiptData } from './types';
5
5
  export function buildReceiptData(order: SentOrder, config: ReceiptConfig): ReceiptData {
6
6
  // Drops the shared helper's `netMinor` (the Z report's own use): the receipt's tax summary
7
7
  // never showed it, and an exact-shape test elsewhere in this package pins that.
8
- const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels, order.taxRounding).map(
8
+ const taxedLines = [...order.lineItems, ...[...(order.fees ?? []), ...(order.shipping ?? [])].map((charge) => ({
9
+ ...charge, taxInclusive: order.pricesIncludeTax,
10
+ }))];
11
+ const taxLines = taxLinesByRate(taxedLines, order.taxMinor, config.taxLabels, order.taxRounding).map(
9
12
  ({ label, code, ratePpm, amountMinor }) => ({ label, code, ratePpm, amountMinor }),
10
13
  );
11
14
 
@@ -48,6 +51,8 @@ export function buildReceiptData(order: SentOrder, config: ReceiptConfig): Recei
48
51
  label: label(d),
49
52
  amountMinor: d.amountMinor,
50
53
  })),
54
+ fees: (order.display.fees ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
55
+ shipping: (order.display.shipping ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
51
56
  orderDiscountMinor: order.display.orderDiscountMinor,
52
57
  totals: {
53
58
  taxInclusive: order.display.taxInclusive,
@@ -21,6 +21,8 @@ export interface ReceiptData {
21
21
  register?: string;
22
22
  };
23
23
  lineItems: ReceiptLineItem[];
24
+ fees: Array<{ name: string; amountMinor: number }>;
25
+ shipping: Array<{ name: string; amountMinor: number }>;
24
26
  discounts: { label: string; amountMinor: number }[];
25
27
  orderDiscountMinor: number; // the order discounts as one row, in the display mode; + Σ displayDiscounts = totals.discountMinor
26
28
  totals: {
@@ -1,8 +1,8 @@
1
1
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import type { RxCollection } from 'rxdb';
3
3
  import type { ProductTraits, ServerCapabilities, StoreSettings } from '@tallyui/core';
4
- import { createOrderBuilder, writeOrderDraft, restoreOrderDraft, type OrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
5
- import { finalizeOrder, type PosOrder } from '../pos-order';
4
+ import { createOrderBuilder, writeOrderDraft, restoreOrderDraft, type ChargeInput, type OrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
5
+ import { finalizeOrder, uuidv7, type PosOrder } from '../pos-order';
6
6
  import { MESSAGE_NAME_MAX, referenceError, referenceReason, withSentForm } from '../pos-order/finalize';
7
7
  import { CUSTOMER_REFUSALS, customerRefusal, cutText, PAYLOAD_STRING_MAX } from '../pos-order/command';
8
8
  import { useTax } from '../tax';
@@ -137,12 +137,12 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
137
137
  }, [rendered]);
138
138
  // New tax settings or currency wait until the sale is idle (an empty cart), then start a new sale keeping its customer:
139
139
  // a sale in progress (lines, tender or receipt) finishes on the settings it started with (a money rule).
140
- const idle = stage.kind === 'cart' && !order.lineItems.length;
140
+ const idle = stage.kind === 'cart' && !order.lineItems.length && !order.fees?.length && !order.shipping?.length;
141
141
  // A layout effect, on live idleness (#301): it runs inside the settings' commit, before any tap can be handled, and
142
142
  // the rendered `idle` would miss a line added since this render (it would then be dropped with the old builder).
143
143
  useLayoutEffect(() => {
144
144
  const live = builderNow.current.getSnapshot();
145
- const liveIdle = stageNow.current.kind === 'cart' && !live.lineItems.length && !inFlight.current && !pending.current;
145
+ const liveIdle = stageNow.current.kind === 'cart' && !live.lineItems.length && !live.fees?.length && !live.shipping?.length && !inFlight.current && !pending.current;
146
146
  if (liveIdle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) resetSale(live.customer ?? null);
147
147
  });
148
148
  // A screen that unmounts (medusapos: Sign out) mid-save must still leave a trace of the loss.
@@ -275,6 +275,50 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
275
275
  }
276
276
  },
277
277
  setQuantity(lineId: string, quantity: number) { if (!locked()) builderNow.current.updateQuantity(lineId, quantity); },
278
+ /** ADR-075: adds a fee; returns its id or the builder's refusal. */
279
+ addFee(input: ChargeInput): { id: string } | string {
280
+ if (locked()) return SALE_SAVING;
281
+ try { return { id: builderNow.current.addFee(input) }; }
282
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
283
+ },
284
+ /** ADR-075: updates a fee; returns a refusal or null. */
285
+ updateFee(id: string, patch: Partial<ChargeInput>): string | null {
286
+ if (locked()) return SALE_SAVING;
287
+ try { builderNow.current.updateFee(id, patch); return null; }
288
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
289
+ },
290
+ /** ADR-075: removes a fee; returns a refusal or null. */
291
+ removeFee(id: string): string | null {
292
+ if (locked()) return SALE_SAVING;
293
+ try { builderNow.current.removeFee(id); return null; }
294
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
295
+ },
296
+ /** ADR-075: adds shipping; returns its id or the builder's refusal. */
297
+ addShipping(input: ChargeInput & { methodId?: string }): { id: string } | string {
298
+ if (locked()) return SALE_SAVING;
299
+ try { return { id: builderNow.current.addShipping(input) }; }
300
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
301
+ },
302
+ /** ADR-075: updates shipping; returns a refusal or null. */
303
+ updateShipping(id: string, patch: Partial<ChargeInput & { methodId?: string }>): string | null {
304
+ if (locked()) return SALE_SAVING;
305
+ try { builderNow.current.updateShipping(id, patch); return null; }
306
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
307
+ },
308
+ /** ADR-075: removes shipping; returns a refusal or null. */
309
+ removeShipping(id: string): string | null {
310
+ if (locked()) return SALE_SAVING;
311
+ try { builderNow.current.removeShipping(id); return null; }
312
+ catch (error) { if (error instanceof Error) return error.message; throw error; }
313
+ },
314
+ /** ADR-075: adds a distinct custom product line; returns its id or the builder's refusal. */
315
+ addCustomLine(input: { name: string; priceMinor: number; quantity?: number; taxClass?: string; taxStatus?: 'taxable' | 'none'; sku?: string }): { id: string } | string {
316
+ if (locked()) return SALE_SAVING;
317
+ try {
318
+ return { id: builderNow.current.addLine({ ...input, custom: true, productId: `custom:${uuidv7()}`,
319
+ unitPrice: { amount: input.priceMinor, currency: madeWith.current.currency.toUpperCase() } }) };
320
+ } catch (error) { if (error instanceof Error) return error.message; throw error; }
321
+ },
278
322
  /** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
279
323
  setUnitPrice(lineId: string, amountMinor: number): string | null {
280
324
  if (locked()) return SALE_SAVING;
@@ -435,7 +479,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
435
479
  if (stageNow.current.kind !== 'cart') return 'Finish or cancel the payment before parking the sale';
436
480
  const builder = builderNow.current;
437
481
  const snapshot = builder.getSnapshot();
438
- if (!snapshot.lineItems.length) return 'There is nothing to park';
482
+ if (!snapshot.lineItems.length && !snapshot.fees?.length && !snapshot.shipping?.length) return 'There is nothing to park';
439
483
  await writeOrderDraft(opts.drafts, snapshot);
440
484
  if (builderNow.current !== builder || builder.getSnapshot() !== snapshot) return 'The sale changed while it was being parked; park it again';
441
485
  resetSale(null);
@@ -447,7 +491,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
447
491
  if (!opts.drafts) throw new Error('useSale: resume() needs the drafts option');
448
492
  const live = builderNow.current;
449
493
  const liveSnapshot = live.getSnapshot();
450
- if (stageNow.current.kind !== 'cart' || liveSnapshot.lineItems.length) return 'Park or clear the current sale first';
494
+ if (stageNow.current.kind !== 'cart' || liveSnapshot.lineItems.length || liveSnapshot.fees?.length || liveSnapshot.shipping?.length) return 'Park or clear the current sale first';
451
495
  const doc = await opts.drafts.findOne(draftId).exec();
452
496
  if (!doc) return 'That parked sale is no longer there';
453
497
  const saved: Order = JSON.parse(doc.toJSON().data);