@tallyui/pos 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -109,6 +109,84 @@ function useCurrencyCode() {
109
109
  return ctx.currencyCode;
110
110
  }
111
111
 
112
+ // src/order/woocommerce-tax.ts
113
+ import { minorUnitDigits, woocommerceTax } from "@tallyui/core";
114
+ function woocommerceLine(amountMinor, rates, inclusive, currency, roundAtSubtotal, pricesIncludeTax = inclusive) {
115
+ const dp = minorUnitDigits(currency), factor = 10 ** dp;
116
+ const precision = woocommerceTax.getRoundingPrecision(dp);
117
+ const input = {
118
+ amount: amountMinor / factor,
119
+ rates: rates.map((rate) => ({ ...rate, order: rate.priority })),
120
+ amountIncludesTax: inclusive,
121
+ dp,
122
+ perRatePrecision: precision
123
+ };
124
+ let result = woocommerceTax.calculateTaxes(input);
125
+ const net = woocommerceTax.roundHalfUp(input.amount - (inclusive ? result.total : 0), precision);
126
+ if (inclusive && rates.length) result = woocommerceTax.calculateTaxes({ ...input, amount: net, amountIncludesTax: false });
127
+ const taxLines = result.taxes.map((tax2) => {
128
+ const rate = rates.find((rate2) => rate2.id === tax2.id);
129
+ return {
130
+ code: rate.code,
131
+ rateId: rate.id,
132
+ compound: rate.compound,
133
+ ratePpm: Math.round(Number(rate.rate) * 1e4),
134
+ taxMicros: String(Math.round(tax2.total * factor * 1e6))
135
+ };
136
+ });
137
+ const tax = result.taxes.reduce((sum, rate) => sum + (roundAtSubtotal ? rate.total : woocommerceTax.roundTaxTotal(rate.total, dp, pricesIncludeTax)), 0);
138
+ return {
139
+ netMinor: Math.round(woocommerceTax.roundHalfUp(net, dp) * factor),
140
+ netMicros: String(Math.round(net * factor * 1e6)),
141
+ totalMinor: Math.round(woocommerceTax.roundHalfUp(net + tax, dp) * factor),
142
+ taxLines,
143
+ taxMicros: taxLines.reduce((sum, rate) => sum + BigInt(rate.taxMicros), 0n).toString()
144
+ };
145
+ }
146
+ function woocommerceTotals(lines, fees, shipping, currency, roundAtSubtotal, inclusive) {
147
+ const dp = minorUnitDigits(currency), factor = 10 ** dp;
148
+ const net = (line) => Number(line.netMicros) / (factor * 1e6);
149
+ const productNet = lines.reduce((sum, line) => sum + (roundAtSubtotal ? net(line) : woocommerceTax.roundHalfUp(net(line), dp)), 0);
150
+ const amount = productNet + fees.reduce((sum, line) => sum + net(line), 0) + shipping.reduce((sum, line) => sum + woocommerceTax.roundHalfUp(net(line), dp), 0);
151
+ const taxes = (items) => woocommerceTax.roundHalfUp(items.reduce((sum, line) => line.taxLines.reduce((sum2, tax2) => sum2 + (roundAtSubtotal ? Number(tax2.taxMicros) / (factor * 1e6) : woocommerceTax.roundTaxTotal(Number(tax2.taxMicros) / (factor * 1e6), dp, inclusive)), sum), 0), woocommerceTax.getRoundingPrecision(dp));
152
+ const tax = woocommerceTax.roundHalfUp(taxes([...lines, ...fees]) + taxes(shipping), 6);
153
+ return {
154
+ subtotalMinor: Math.round(woocommerceTax.roundHalfUp(productNet, dp) * factor),
155
+ taxMinor: Math.round((roundAtSubtotal ? woocommerceTax.roundHalfUp(tax, dp) : woocommerceTax.roundTaxTotal(tax, dp, inclusive)) * factor),
156
+ totalMinor: Math.round(woocommerceTax.roundHalfUp(woocommerceTax.roundHalfUp(amount + tax, 6), dp) * factor)
157
+ };
158
+ }
159
+ function woocommerceTaxByRate(lines, roundAtSubtotal, taxLabels, orderTaxMinor) {
160
+ const groups = /* @__PURE__ */ new Map();
161
+ for (const line of lines) for (const tax of line.taxLines) {
162
+ const key = JSON.stringify([tax.rateId, tax.code, tax.ratePpm]);
163
+ const row = groups.get(key) ?? {
164
+ code: tax.code,
165
+ ratePpm: tax.ratePpm,
166
+ label: taxLabels?.[tax.ratePpm] ?? `Tax ${tax.ratePpm / 1e4}%`,
167
+ netMinor: 0,
168
+ amountMinor: 0,
169
+ amount: 0,
170
+ inclusive: line.taxInclusive
171
+ };
172
+ const amount = Number(tax.taxMicros) / 1e6;
173
+ row.amount += roundAtSubtotal ? amount : woocommerceTax.roundTaxTotal(amount, 0, line.taxInclusive);
174
+ row.netMinor += line.netMinor;
175
+ groups.set(key, row);
176
+ }
177
+ const rows = [...groups.values()].map(({ amount, inclusive, ...row }) => ({
178
+ ...row,
179
+ amountMinor: woocommerceTax.roundTaxTotal(woocommerceTax.roundHalfUp(amount, 6), 0, inclusive)
180
+ }));
181
+ if (orderTaxMinor !== void 0 && rows.length) {
182
+ const residue = orderTaxMinor - rows.reduce((sum, row) => sum + row.amountMinor, 0);
183
+ const bound = Math.floor(rows.length / 2) + 1;
184
+ if (Math.abs(residue) > bound) throw new Error(`woocommerce tax by rate: residue ${residue} exceeds its bound ${bound} (ADR-076)`);
185
+ rows[rows.length - 1].amountMinor += residue;
186
+ }
187
+ return rows;
188
+ }
189
+
112
190
  // src/tax/exact.ts
113
191
  var MICROS_PER_MINOR = 1000000n;
114
192
  var DEFAULT_TAX_ROUNDING = { granularity: "per_order", mode: "half_away_from_zero" };
@@ -211,6 +289,7 @@ function roundedTaxByRate(lines, rounding) {
211
289
  return { baseMinor: Number(baseMinor), taxMinor: rows.reduce((sum, row) => sum + row.amountMinor, 0), rates: rows };
212
290
  }
213
291
  function taxLinesByRate(lines, orderTaxMinor, taxLabels, rounding) {
292
+ if (rounding?.granularity === "woocommerce" && lines.every((line) => line.netMicros !== void 0)) return woocommerceTaxByRate(lines, rounding.roundAtSubtotal, taxLabels, orderTaxMinor);
214
293
  const label = (ratePpm) => taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 1e4}%`;
215
294
  const rounded = roundedTaxByRate(lines, rounding);
216
295
  if (rounded) return rounded.rates.map(({ code, ratePpm, netMinor, amountMinor }) => ({ label: label(ratePpm), code, ratePpm, netMinor, amountMinor }));
@@ -244,10 +323,11 @@ function taxLinesByRate(lines, orderTaxMinor, taxLabels, rounding) {
244
323
 
245
324
  // src/tax/tax-provider.tsx
246
325
  import { createContext as createContext2, useContext as useContext2, useMemo as useMemo2 } from "react";
326
+ import { woocommerceTax as woocommerceTax2 } from "@tallyui/core";
247
327
  import { jsx as jsx2 } from "react/jsx-runtime";
248
328
  var taxLogger = createLogger("tax");
249
329
  var TaxCtx = createContext2(null);
250
- function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, children }) {
330
+ function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, taxRates, children }) {
251
331
  const value = useMemo2(
252
332
  () => {
253
333
  if (Object.values(ratesPpm).some((rate) => !Number.isSafeInteger(rate) || rate < 0)) {
@@ -255,6 +335,7 @@ function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, children
255
335
  }
256
336
  const unknown = /* @__PURE__ */ new Set();
257
337
  return {
338
+ ...taxRates ? { getTaxRates: (taxClass, options) => (taxRates[woocommerceTax2.normalizeTaxClass(taxClass)] ?? []).filter((rate) => !options?.shipping || rate.shipping) } : {},
258
339
  getTaxRatePpm: (taxClass) => {
259
340
  if (taxClass !== void 0 && Object.hasOwn(ratesPpm, taxClass)) return ratesPpm[taxClass];
260
341
  if (taxClass !== void 0 && !unknown.has(taxClass)) {
@@ -272,7 +353,7 @@ function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, children
272
353
  ...rounding ? { rounding } : {}
273
354
  };
274
355
  },
275
- [ratesPpm, pricesIncludeTax, rounding, rateCodes]
356
+ [ratesPpm, pricesIncludeTax, rounding, rateCodes, taxRates]
276
357
  );
277
358
  return /* @__PURE__ */ jsx2(TaxCtx.Provider, { value, children });
278
359
  }
@@ -319,7 +400,16 @@ function withPricingContext(context, settings) {
319
400
  }
320
401
  function taxProviderProps(settings) {
321
402
  const rounding = settings.taxRounding?.granularity === "custom" ? void 0 : settings.taxRounding;
322
- return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...rounding && { rounding }, ...settings.taxRateCodes && { rateCodes: settings.taxRateCodes } };
403
+ return {
404
+ ratesPpm: settings.taxRatesPpm,
405
+ pricesIncludeTax: settings.pricesIncludeTax,
406
+ ...rounding && { rounding },
407
+ ...settings.taxRateCodes && { rateCodes: settings.taxRateCodes },
408
+ ...settings.taxRates && { taxRates: settings.taxRates },
409
+ ...settings.taxRoundAtSubtotal !== void 0 && { taxRoundAtSubtotal: settings.taxRoundAtSubtotal },
410
+ ...settings.shippingTaxClass !== void 0 && { shippingTaxClass: settings.shippingTaxClass },
411
+ ...settings.taxClassSlugs && { taxClassSlugs: settings.taxClassSlugs }
412
+ };
323
413
  }
324
414
 
325
415
  // src/store-settings/use-store-settings.ts
@@ -522,7 +612,16 @@ function createOrderBuilder(options) {
522
612
  const { taxContext } = options;
523
613
  const currency = options.currency.toUpperCase();
524
614
  const orderId = options.id ?? uid();
615
+ const woo = taxContext.rounding?.granularity === "woocommerce" && taxContext.getTaxRates ? taxContext.rounding : void 0;
616
+ const wooLine = (amount, taxClass, inclusive, untaxed = false, shipping2 = false) => woocommerceLine(amount, untaxed ? [] : taxContext.getTaxRates(taxClass, { shipping: shipping2 }), inclusive, currency, woo.roundAtSubtotal, taxContext.pricesIncludeTax);
617
+ const displayAmount = (line, amount) => {
618
+ if (!woo || line.taxInclusive === taxContext.pricesIncludeTax) return toDisplayMode(line, amount, taxContext.pricesIncludeTax);
619
+ const item = wooLine(amount, line.taxClass, line.taxInclusive, line.taxStatus === "none");
620
+ return taxContext.pricesIncludeTax ? item.totalMinor : item.netMinor;
621
+ };
525
622
  let lineItems = [];
623
+ let fees = [];
624
+ let shipping = [];
526
625
  let orderDiscounts = [];
527
626
  let payments = [];
528
627
  let customer = null;
@@ -540,6 +639,13 @@ function createOrderBuilder(options) {
540
639
  const lineDiscountMinor = Math.min(grossMinor, recalcedDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
541
640
  const discountMinor = lineDiscountMinor + orderDiscountMinor;
542
641
  const netMinor = grossMinor - discountMinor;
642
+ if (woo) return {
643
+ ...line,
644
+ discounts: recalcedDiscounts,
645
+ discountMinor,
646
+ orderDiscountMinor,
647
+ ...wooLine(netMinor, line.taxClass, line.taxInclusive, line.taxStatus === "none")
648
+ };
543
649
  const combinedRate = line.taxLines.reduce((sum, tax) => sum + tax.ratePpm, 0);
544
650
  const inclusiveTax = line.taxInclusive ? taxMicros(netMinor, combinedRate, true) : 0n;
545
651
  let allocatedTax = 0n;
@@ -566,7 +672,7 @@ function createOrderBuilder(options) {
566
672
  return Math.max(0, Math.min(discount.value, base));
567
673
  }
568
674
  function buildOrder() {
569
- const lineAmounts = lineItems.map((li) => li.netMinor);
675
+ const lineAmounts = lineItems.map((li) => woo ? li.unitPriceMinor * li.quantity - li.discountMinor : li.netMinor);
570
676
  const base = lineAmounts.reduce((sum, amount) => sum + Math.max(0, amount), 0);
571
677
  let remaining = base;
572
678
  const recalcedOrderDiscounts = orderDiscounts.map((d) => {
@@ -576,8 +682,16 @@ function createOrderBuilder(options) {
576
682
  });
577
683
  const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
578
684
  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)) {
685
+ const wooTotals = woo ? woocommerceTotals(lines, fees, shipping, currency, woo.roundAtSubtotal, taxContext.pricesIncludeTax) : void 0;
686
+ const { subtotalMinor } = wooTotals ?? sumLines(lines, taxContext.rounding);
687
+ const charges = [...fees, ...shipping];
688
+ const taxedLines = [...lines, ...charges.map((charge) => ({
689
+ ...charge,
690
+ taxInclusive: taxContext.pricesIncludeTax,
691
+ taxMicros: charge.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n).toString()
692
+ }))];
693
+ const { taxMinor, totalMinor } = wooTotals ?? sumLines(taxedLines, taxContext.rounding);
694
+ if (taxContext.rounding?.granularity === "per_rate_group_items" && taxedLines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
581
695
  perRateGroupWarned.add(taxContext);
582
696
  taxLogger.warn("per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)");
583
697
  }
@@ -588,11 +702,11 @@ function createOrderBuilder(options) {
588
702
  const discounts = returned ? [] : li.discounts.map((d) => ({
589
703
  discountId: d.id,
590
704
  ...d.label !== void 0 ? { label: d.label } : {},
591
- amountMinor: toDisplayMode(li, d.amountMinor, taxInclusive)
705
+ amountMinor: displayAmount(li, d.amountMinor)
592
706
  }));
593
- const shareMinor = toDisplayMode(li, li.orderDiscountMinor, taxInclusive);
594
- const remainingMinor = toDisplayMode(li, li.netMinor, taxInclusive);
595
- const amountMinor = li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity : discounts.reduce((sum, d) => sum + d.amountMinor, remainingMinor + shareMinor);
707
+ const shareMinor = displayAmount(li, li.orderDiscountMinor);
708
+ const remainingMinor = woo ? taxInclusive ? li.totalMinor : li.netMinor : toDisplayMode(li, li.netMinor, taxInclusive);
709
+ const amountMinor = !woo && li.taxInclusive === taxInclusive && !returned ? li.unitPriceMinor * li.quantity : discounts.reduce((sum, d) => sum + d.amountMinor, remainingMinor + shareMinor);
596
710
  return { line: { lineId: li.id, amountMinor, discounts }, shareMinor, remainingMinor, returned };
597
711
  });
598
712
  const displayLines = figures.map((f) => f.line);
@@ -601,8 +715,17 @@ function createOrderBuilder(options) {
601
715
  (sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum),
602
716
  orderDiscountDisplay
603
717
  );
604
- const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount;
605
- const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
718
+ const chargeRows = (items) => items.map(({ id: id2, name, amountMinor, netMinor, totalMinor: totalMinor2 }) => ({ id: id2, name, amountMinor: woo ? taxInclusive ? totalMinor2 : netMinor : amountMinor }));
719
+ const displayFees = chargeRows(fees), displayShipping = chargeRows(shipping);
720
+ let displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount - [...displayFees, ...displayShipping].reduce((sum, charge) => sum + charge.amountMinor, 0);
721
+ let residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
722
+ if (woo) {
723
+ const charge = displayShipping.at(-1) ?? displayFees.at(-1);
724
+ const last = charge ?? displayLines.at(-1);
725
+ if (last) last.amountMinor += residue;
726
+ if (charge) displaySubtotal -= residue;
727
+ residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
728
+ }
606
729
  const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
607
730
  const conversions = converted.reduce((count, index) => count + [
608
731
  lines[index].netMinor,
@@ -622,7 +745,7 @@ function createOrderBuilder(options) {
622
745
  if (displayDiscount < 0) throw new Error(`Display discount ${displayDiscount} is negative (ADR-063)`);
623
746
  for (const { line, shareMinor, returned } of figures) {
624
747
  if (returned && line.discounts.length > 0) throw new Error(`Return line ${line.lineId} shows discount rows (ADR-063)`);
625
- if (returned) continue;
748
+ if (returned || woo) continue;
626
749
  const rows = line.discounts.reduce((sum, d) => sum + d.amountMinor, 0);
627
750
  if (shareMinor < 0 || line.discounts.some((d) => d.amountMinor < 0) || line.amountMinor < rows + shareMinor) {
628
751
  throw new Error(`Display line ${line.lineId} shows ${line.amountMinor}, less than its rows ${rows} and share ${shareMinor} (ADR-063)`);
@@ -635,7 +758,9 @@ function createOrderBuilder(options) {
635
758
  taxMinor,
636
759
  totalMinor,
637
760
  lines: displayLines,
638
- orderDiscountMinor: orderDiscountDisplay
761
+ orderDiscountMinor: orderDiscountDisplay,
762
+ ...fees.length ? { fees: displayFees } : {},
763
+ ...shipping.length ? { shipping: displayShipping } : {}
639
764
  };
640
765
  const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
641
766
  const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
@@ -644,6 +769,8 @@ function createOrderBuilder(options) {
644
769
  id: orderId,
645
770
  status: "draft",
646
771
  lineItems: lines,
772
+ ...fees.length ? { fees: [...fees] } : {},
773
+ ...shipping.length ? { shipping: [...shipping] } : {},
647
774
  discounts: recalcedOrderDiscounts,
648
775
  payments: [...payments],
649
776
  customer,
@@ -666,17 +793,64 @@ function createOrderBuilder(options) {
666
793
  function emit() {
667
794
  subject.next(buildOrder());
668
795
  }
796
+ function recalculateCharge(id2, input, shipping2 = false) {
797
+ if (!Number.isInteger(input.amountMinor)) throw new RangeError("Amount must be integer minor units");
798
+ if (input.amountMinor < 0) throw new RangeError("Amount must be >= 0");
799
+ if (!input.name.trim()) throw new RangeError("Name must not be empty");
800
+ const taxStatus = input.taxStatus ?? "taxable";
801
+ if (woo) {
802
+ const line2 = wooLine(input.amountMinor, input.taxClass, taxContext.pricesIncludeTax, taxStatus === "none", shipping2);
803
+ return {
804
+ id: id2,
805
+ name: input.name,
806
+ amountMinor: input.amountMinor,
807
+ taxClass: input.taxClass,
808
+ taxStatus,
809
+ ...line2
810
+ };
811
+ }
812
+ const code = taxContext.getTaxRateCode?.(input.taxClass);
813
+ const line = recalculateLine({
814
+ id: id2,
815
+ productId: id2,
816
+ name: input.name,
817
+ sku: "",
818
+ unitPriceMinor: input.amountMinor,
819
+ quantity: 1,
820
+ taxLines: [{
821
+ ...code !== void 0 ? { code } : {},
822
+ ratePpm: taxStatus === "none" ? 0 : taxContext.getTaxRatePpm(input.taxClass),
823
+ taxMicros: "0"
824
+ }],
825
+ discounts: [],
826
+ discountMinor: 0,
827
+ orderDiscountMinor: 0,
828
+ netMinor: 0,
829
+ taxMicros: "0",
830
+ taxInclusive: taxContext.pricesIncludeTax
831
+ });
832
+ return {
833
+ id: id2,
834
+ name: input.name,
835
+ amountMinor: input.amountMinor,
836
+ taxClass: input.taxClass,
837
+ taxStatus,
838
+ taxLines: line.taxLines,
839
+ netMinor: line.netMinor,
840
+ taxMicros: line.taxMicros
841
+ };
842
+ }
669
843
  function addLine(input) {
670
844
  const { productId, variantId, unitPrice } = input;
671
845
  const quantity = input.quantity ?? 1;
672
846
  if (unitPrice.currency !== currency) throw new RangeError("Line currency must match " + currency);
673
847
  if (!Number.isInteger(unitPrice.amount)) throw new RangeError("Price must be integer minor units");
674
848
  if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError("Quantity must be an integer >= 1");
675
- const code = input.taxRates ? void 0 : taxContext.getTaxRateCode?.(input.taxClass);
676
- const taxRates = input.taxRates ?? [{ ...code !== void 0 ? { code } : {}, ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
849
+ const code = woo || input.taxRates ? void 0 : taxContext.getTaxRateCode?.(input.taxClass);
850
+ const taxRates = woo ? wooLine(unitPrice.amount * quantity, input.taxClass, unitPrice.taxInclusive ?? taxContext.pricesIncludeTax, input.taxStatus === "none").taxLines : input.taxStatus === "none" ? [{ ...code !== void 0 ? { code } : {}, ratePpm: 0 }] : input.taxRates ?? [{ ...code !== void 0 ? { code } : {}, ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
677
851
  const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
678
852
  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)
853
+ (li) => !input.custom && !li.custom && li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive && (!woo || li.taxClass === input.taxClass && li.taxStatus === "none" === (input.taxStatus === "none")) && 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
854
  );
681
855
  if (existing) {
682
856
  lineItems = lineItems.map(
@@ -688,7 +862,10 @@ function createOrderBuilder(options) {
688
862
  const lineId = uid();
689
863
  const line = {
690
864
  id: lineId,
865
+ ...(input.custom ? input.taxClass !== void 0 : woo) ? { taxClass: input.taxClass } : {},
691
866
  productId,
867
+ ...input.custom ? { custom: true } : {},
868
+ ...input.taxStatus === "none" ? { taxStatus: "none" } : {},
692
869
  variantId,
693
870
  name: input.name,
694
871
  sku: input.sku ?? "",
@@ -716,6 +893,43 @@ function createOrderBuilder(options) {
716
893
  return {
717
894
  order$: subject.asObservable(),
718
895
  addLine,
896
+ addFee(input) {
897
+ const fee = recalculateCharge(uid(), input);
898
+ fees = [...fees, fee];
899
+ emit();
900
+ return fee.id;
901
+ },
902
+ updateFee(id2, patch) {
903
+ const fee = fees.find((entry) => entry.id === id2);
904
+ if (!fee) throw new Error(`Unknown fee ${id2}`);
905
+ const updated = recalculateCharge(id2, { ...fee, ...patch });
906
+ fees = fees.map((entry) => entry.id === id2 ? updated : entry);
907
+ emit();
908
+ },
909
+ removeFee(id2) {
910
+ if (!fees.some((entry) => entry.id === id2)) throw new Error(`Unknown fee ${id2}`);
911
+ fees = fees.filter((entry) => entry.id !== id2);
912
+ emit();
913
+ },
914
+ addShipping(input) {
915
+ const charge = { ...recalculateCharge(uid(), input, true), methodId: input.methodId };
916
+ shipping = [...shipping, charge];
917
+ emit();
918
+ return charge.id;
919
+ },
920
+ updateShipping(id2, patch) {
921
+ const charge = shipping.find((entry) => entry.id === id2);
922
+ if (!charge) throw new Error(`Unknown shipping ${id2}`);
923
+ const input = { ...charge, ...patch };
924
+ const updated = { ...recalculateCharge(id2, input, true), methodId: input.methodId };
925
+ shipping = shipping.map((entry) => entry.id === id2 ? updated : entry);
926
+ emit();
927
+ },
928
+ removeShipping(id2) {
929
+ if (!shipping.some((entry) => entry.id === id2)) throw new Error(`Unknown shipping ${id2}`);
930
+ shipping = shipping.filter((entry) => entry.id !== id2);
931
+ emit();
932
+ },
719
933
  addProduct(doc, traits, opts) {
720
934
  const productId = traits.getId(doc);
721
935
  if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
@@ -732,7 +946,8 @@ function createOrderBuilder(options) {
732
946
  imageUrl: traits.getImageUrl(doc),
733
947
  unitPrice,
734
948
  quantity: opts?.quantity,
735
- taxClass: traits.getTaxClass?.(doc, opts?.variantId)
949
+ taxClass: traits.getTaxClass?.(doc, opts?.variantId),
950
+ ...traits.getTaxStatus?.(doc, opts?.variantId) === "none" ? { taxStatus: "none" } : {}
736
951
  });
737
952
  },
738
953
  updateQuantity(lineId, quantity) {
@@ -811,6 +1026,8 @@ function createOrderBuilder(options) {
811
1026
  },
812
1027
  clear() {
813
1028
  lineItems = [];
1029
+ fees = [];
1030
+ shipping = [];
814
1031
  orderDiscounts = [];
815
1032
  payments = [];
816
1033
  customer = null;
@@ -877,20 +1094,23 @@ function netDiscountMinor(line, pricesIncludeTax) {
877
1094
  return roundMicrosToMinor(BigInt(line.discountMinor) * MICROS_PER_MINOR - taxBefore + taxAfter);
878
1095
  }
879
1096
  function contentVersion(order) {
1097
+ if (order.fees?.length || order.shipping?.length || order.lines.some((line) => line.custom)) return 5;
880
1098
  return order.display && order.taxByRate ? 3 : order.lines.reduce((sum, line) => sum + line.discountMinor, 0) > 0 ? 2 : 1;
881
1099
  }
882
1100
  function toOrderCreateEnvelope(order, deviceId, attempt2 = 1, options) {
883
1101
  const grossMinor = order.lines.reduce((sum, line) => sum + line.discountMinor, 0);
884
1102
  const content = contentVersion(order);
885
1103
  const cap = options?.maxVersion === void 0 ? order.sentVersion : Math.min(options.maxVersion, order.sentVersion ?? options.maxVersion);
1104
+ if (content === 5 && cap !== void 0 && cap < 5) throw new UnsupportedOrderVersionError(5, cap);
886
1105
  if (grossMinor > 0 && cap !== void 0 && cap < 2) throw new UnsupportedOrderVersionError(2, cap);
887
1106
  const version = content === 3 && cap !== void 0 && cap >= 4 ? 4 : Math.min(content, cap ?? content);
888
- const lineDiscounts = order.lines.map((line) => version === 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
1107
+ const lineDiscounts = order.lines.map((line) => version >= 4 ? netDiscountMinor(line, order.pricesIncludeTax) : line.discountMinor);
889
1108
  const discountMinor = lineDiscounts.reduce((sum, discount) => sum + discount, 0);
890
1109
  const email = order.customer?.email;
891
1110
  const id2 = order.customer?.id;
892
1111
  const customerId = typeof id2 === "string" && id2.length > 0 && id2.length <= 64 ? id2 : void 0;
893
1112
  const sessionId = order.sessionId ?? order.lateSessionId;
1113
+ const { fees: _fees, shipping: _shipping, ...storedDisplay } = { ...order.display };
894
1114
  return {
895
1115
  id: order.commandId,
896
1116
  type: "order.create",
@@ -905,13 +1125,30 @@ function toOrderCreateEnvelope(order, deviceId, attempt2 = 1, options) {
905
1125
  pricesIncludeTax: order.pricesIncludeTax,
906
1126
  lines: order.lines.map((line, i) => ({
907
1127
  clientLineId: line.id,
908
- variantId: line.variantId ?? line.productId,
1128
+ ...line.custom ? { custom: { ...line.custom } } : { variantId: line.variantId ?? line.productId },
909
1129
  title: line.name,
910
1130
  quantity: line.quantity,
911
1131
  unitPriceMinor: line.unitPriceMinor,
912
1132
  ...line.taxInclusive !== void 0 ? { taxInclusive: line.taxInclusive } : {},
913
1133
  ...lineDiscounts[i] > 0 ? { discountMinor: lineDiscounts[i] } : {}
914
1134
  })),
1135
+ ...version === 5 && order.fees?.length ? { fees: order.fees.map((fee) => ({
1136
+ clientFeeId: fee.id,
1137
+ name: fee.name,
1138
+ amountMinor: fee.amountMinor,
1139
+ taxStatus: fee.taxStatus,
1140
+ ...fee.taxClass !== void 0 ? { taxClass: fee.taxClass } : {},
1141
+ taxMinor: roundMicrosToMinor(BigInt(fee.taxMicros))
1142
+ })) } : {},
1143
+ ...version === 5 && order.shipping?.length ? { shipping: order.shipping.map((charge) => ({
1144
+ clientShippingId: charge.id,
1145
+ name: charge.name,
1146
+ ...charge.methodId !== void 0 ? { methodId: charge.methodId } : {},
1147
+ amountMinor: charge.amountMinor,
1148
+ taxStatus: charge.taxStatus,
1149
+ ...charge.taxClass !== void 0 ? { taxClass: charge.taxClass } : {},
1150
+ taxMinor: roundMicrosToMinor(BigInt(charge.taxMicros))
1151
+ })) } : {},
915
1152
  payments: order.payments.map((payment) => ({
916
1153
  clientPaymentId: payment.id,
917
1154
  method: payment.method,
@@ -925,11 +1162,16 @@ function toOrderCreateEnvelope(order, deviceId, attempt2 = 1, options) {
925
1162
  taxMinor: order.taxMinor,
926
1163
  totalMinor: order.totalMinor,
927
1164
  ...version >= 3 ? {
928
- display: { ...order.display, lines: order.display.lines.map(({ lineId, amountMinor, discounts }) => ({
929
- clientLineId: lineId,
930
- amountMinor,
931
- discounts
932
- })) },
1165
+ display: {
1166
+ ...storedDisplay,
1167
+ lines: order.display.lines.map(({ lineId, amountMinor, discounts }) => ({
1168
+ clientLineId: lineId,
1169
+ amountMinor,
1170
+ discounts
1171
+ })),
1172
+ ...version === 5 && order.display.fees ? { fees: order.display.fees.map(({ id: id3, amountMinor }) => ({ clientFeeId: id3, amountMinor })) } : {},
1173
+ ...version === 5 && order.display.shipping ? { shipping: order.display.shipping.map(({ id: id3, amountMinor }) => ({ clientShippingId: id3, amountMinor })) } : {}
1174
+ },
933
1175
  taxByRate: order.taxByRate.map(({ ratePpm, code, netMinor, amountMinor, grossMinor: grossMinor2 }) => ({
934
1176
  ratePpm,
935
1177
  ...code !== void 0 ? { code } : {},
@@ -988,6 +1230,9 @@ function restoreOrderDraft(saved, options) {
988
1230
  for (const line of saved.lineItems) {
989
1231
  const lineId = builder.addLine({
990
1232
  productId: line.productId,
1233
+ custom: line.custom,
1234
+ taxStatus: line.taxStatus,
1235
+ ...taxContext.rounding?.granularity === "woocommerce" ? { taxClass: line.taxClass } : {},
991
1236
  variantId: line.variantId,
992
1237
  name: line.name,
993
1238
  sku: line.sku,
@@ -1005,6 +1250,8 @@ function restoreOrderDraft(saved, options) {
1005
1250
  });
1006
1251
  }
1007
1252
  }
1253
+ for (const fee of saved.fees ?? []) builder.addFee(fee);
1254
+ for (const charge of saved.shipping ?? []) builder.addShipping(charge);
1008
1255
  for (const discount of saved.discounts) {
1009
1256
  builder.applyOrderDiscount({
1010
1257
  type: discount.type,
@@ -1105,7 +1352,11 @@ function useParkedSales(drafts) {
1105
1352
 
1106
1353
  // src/receipt/build-receipt-data.ts
1107
1354
  function buildReceiptData(order, config) {
1108
- const taxLines = taxLinesByRate(order.lineItems, order.taxMinor, config.taxLabels, order.taxRounding).map(
1355
+ const taxedLines = [...order.lineItems.map((line) => order.taxRounding?.granularity === "woocommerce" && line.netMicros !== void 0 ? { ...line, taxInclusive: order.pricesIncludeTax } : line), ...[...order.fees ?? [], ...order.shipping ?? []].map((charge) => ({
1356
+ ...charge,
1357
+ taxInclusive: order.pricesIncludeTax
1358
+ }))];
1359
+ const taxLines = taxLinesByRate(taxedLines, order.taxMinor, config.taxLabels, order.taxRounding).map(
1109
1360
  ({ label: label2, code, ratePpm, amountMinor }) => ({ label: label2, code, ratePpm, amountMinor })
1110
1361
  );
1111
1362
  let taxSoFar = order.lineItems.reduce(
@@ -1113,6 +1364,7 @@ function buildReceiptData(order, config) {
1113
1364
  0n
1114
1365
  );
1115
1366
  const lineTotals = order.lineItems.map((li) => {
1367
+ if (order.taxRounding?.granularity === "woocommerce" && li.netMicros !== void 0) return order.pricesIncludeTax ? li.totalMinor : li.netMinor;
1116
1368
  if (!li.priceTaxModeConverted) return li.netMinor;
1117
1369
  const share = roundMicrosToMinor(taxSoFar + BigInt(li.taxMicros)) - roundMicrosToMinor(taxSoFar);
1118
1370
  taxSoFar += BigInt(li.taxMicros);
@@ -1145,6 +1397,8 @@ function buildReceiptData(order, config) {
1145
1397
  label: label(d),
1146
1398
  amountMinor: d.amountMinor
1147
1399
  })),
1400
+ fees: (order.display.fees ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
1401
+ shipping: (order.display.shipping ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
1148
1402
  orderDiscountMinor: order.display.orderDiscountMinor,
1149
1403
  totals: {
1150
1404
  taxInclusive: order.display.taxInclusive,
@@ -1254,7 +1508,7 @@ function uuidv7(now, randomBytes) {
1254
1508
  }
1255
1509
 
1256
1510
  // src/pos-order/finalize.ts
1257
- import { minorUnitDigits } from "@tallyui/core";
1511
+ import { minorUnitDigits as minorUnitDigits2 } from "@tallyui/core";
1258
1512
 
1259
1513
  // src/outbox/logger.ts
1260
1514
  var outboxLogger = createLogger("outbox");
@@ -1269,8 +1523,12 @@ function withSentForm(order, posOrder) {
1269
1523
  ...order,
1270
1524
  customer,
1271
1525
  lineItems: order.lineItems.map((line, i) => ({ ...line, name: posOrder.lines[i].name })),
1526
+ ...order.fees ? { fees: order.fees.map((fee, i) => ({ ...fee, name: posOrder.fees[i].name })) } : {},
1527
+ ...order.shipping ? { shipping: order.shipping.map((charge, i) => ({ ...charge, name: posOrder.shipping[i].name })) } : {},
1272
1528
  display: posOrder.display ? {
1273
1529
  ...order.display,
1530
+ ...order.display.fees ? { fees: order.display.fees.map((row, i) => ({ ...row, name: posOrder.display.fees[i].name })) } : {},
1531
+ ...order.display.shipping ? { shipping: order.display.shipping.map((row, i) => ({ ...row, name: posOrder.display.shipping[i].name })) } : {},
1274
1532
  lines: order.display.lines.map((line, i) => ({
1275
1533
  ...line,
1276
1534
  discounts: line.discounts.map((discount, j) => ({ ...discount, label: posOrder.display.lines[i].discounts[j].label }))
@@ -1283,7 +1541,15 @@ function freezeSentForm(order) {
1283
1541
  const lines = order.lines.map((line) => {
1284
1542
  const name = cutText(line.name, PAYLOAD_STRING_MAX);
1285
1543
  changed ||= name !== line.name;
1286
- return { ...line, name };
1544
+ const custom = line.custom && { ...line.custom, name: cutText(line.custom.name, PAYLOAD_STRING_MAX) };
1545
+ changed ||= custom !== void 0 && custom.name !== line.custom.name;
1546
+ return { ...line, name, ...custom ? { custom } : {} };
1547
+ });
1548
+ const charges = { fees: order.fees, shipping: order.shipping };
1549
+ for (const field of ["fees", "shipping"]) if (charges[field]) charges[field] = charges[field].map((charge) => {
1550
+ const name = cutText(charge.name, PAYLOAD_STRING_MAX);
1551
+ changed ||= name !== charge.name;
1552
+ return { ...charge, name };
1287
1553
  });
1288
1554
  const display = order.display && { ...order.display, lines: order.display.lines.map((line) => ({
1289
1555
  ...line,
@@ -1294,6 +1560,11 @@ function freezeSentForm(order) {
1294
1560
  return { ...discount, label };
1295
1561
  })
1296
1562
  })) };
1563
+ for (const field of ["fees", "shipping"]) if (display?.[field]) display[field] = display[field].map((row) => {
1564
+ const name = cutText(row.name, PAYLOAD_STRING_MAX);
1565
+ changed ||= name !== row.name;
1566
+ return { ...row, name };
1567
+ });
1297
1568
  const payments = order.payments.map((payment) => {
1298
1569
  if (payment.reference === void 0) return payment;
1299
1570
  const reference = cutText(payment.reference, PAYLOAD_STRING_MAX);
@@ -1309,7 +1580,16 @@ function freezeSentForm(order) {
1309
1580
  if (!localWarnings.some((warning) => warning.code === "customer_omitted" && warning.field === field)) localWarnings.push({ code: "customer_omitted", field });
1310
1581
  }
1311
1582
  }
1312
- return changed ? { ...order, lines, payments, customer, ...display ? { display } : {}, ...localWarnings.length ? { localWarnings } : {} } : order;
1583
+ return changed ? {
1584
+ ...order,
1585
+ lines,
1586
+ payments,
1587
+ customer,
1588
+ ...charges.fees ? { fees: charges.fees } : {},
1589
+ ...charges.shipping ? { shipping: charges.shipping } : {},
1590
+ ...display ? { display } : {},
1591
+ ...localWarnings.length ? { localWarnings } : {}
1592
+ } : order;
1313
1593
  }
1314
1594
  function referenceError(label, value) {
1315
1595
  const reason = referenceReason(value);
@@ -1321,6 +1601,9 @@ function referenceReason(value) {
1321
1601
  return value.includes("\0") ? "nul" : null;
1322
1602
  }
1323
1603
  function finalizeOrder(order, options = {}) {
1604
+ if ((order.fees?.length || order.shipping?.length || order.lineItems.some((line) => line.custom)) && (options.capabilities?.orderCreate ?? 1) < 5) {
1605
+ throw new Error("finalize: fees, shipping and custom lines need the store to accept order.create version 5; update the store's TallyUI plugin");
1606
+ }
1324
1607
  if (!order.lineItems.length) throw new Error("finalize: no lines");
1325
1608
  if (order.discountMinor < 0 || order.lineItems.some((line) => line.discountMinor < 0) || order.discounts.some((d) => d.amountMinor < 0)) {
1326
1609
  throw new Error("finalize: negative discount");
@@ -1362,15 +1645,25 @@ function finalizeOrder(order, options = {}) {
1362
1645
  id: newId(),
1363
1646
  productId: line.productId,
1364
1647
  ...line.variantId !== void 0 ? { variantId: line.variantId } : {},
1648
+ ...line.custom ? { custom: {
1649
+ name: line.name,
1650
+ ...line.sku ? { sku: line.sku } : {},
1651
+ ...line.taxClass !== void 0 ? { taxClass: line.taxClass } : {},
1652
+ taxStatus: line.taxStatus ?? "taxable"
1653
+ } } : {},
1654
+ ...line.taxStatus !== void 0 ? { taxStatus: line.taxStatus } : {},
1365
1655
  name: line.name,
1366
1656
  sku: line.sku,
1367
1657
  quantity: line.quantity,
1368
1658
  unitPriceMinor: line.unitPriceMinor,
1369
1659
  discountMinor: line.discountMinor,
1370
1660
  netMinor: line.netMinor,
1661
+ ...order.taxRounding?.granularity === "woocommerce" ? { netMicros: line.netMicros } : {},
1371
1662
  taxLines: line.taxLines.map((tax) => ({ ...tax })),
1372
1663
  ...line.priceTaxModeConverted ? { taxInclusive: line.taxInclusive } : {}
1373
1664
  }));
1665
+ const fees = order.fees?.map((charge) => ({ ...charge, id: newId(), taxLines: charge.taxLines.map((tax) => ({ ...tax })) }));
1666
+ const shipping = order.shipping?.map((charge) => ({ ...charge, id: newId(), taxLines: charge.taxLines.map((tax) => ({ ...tax })) }));
1374
1667
  const payments = order.payments.map((payment) => ({
1375
1668
  id: newId(),
1376
1669
  method: payment.method,
@@ -1408,8 +1701,10 @@ function finalizeOrder(order, options = {}) {
1408
1701
  }
1409
1702
  display = {
1410
1703
  currency: order.currency,
1411
- exponent: minorUnitDigits(order.currency),
1704
+ exponent: minorUnitDigits2(order.currency),
1412
1705
  ...order.display,
1706
+ ...fees?.length ? { fees: order.display.fees.map((row, i) => ({ ...row, id: fees[i].id })) } : {},
1707
+ ...shipping?.length ? { shipping: order.display.shipping.map((row, i) => ({ ...row, id: shipping[i].id })) } : {},
1413
1708
  lines: order.display.lines.map((line, i) => ({
1414
1709
  lineId: lines[i].id,
1415
1710
  amountMinor: line.amountMinor,
@@ -1420,7 +1715,8 @@ function finalizeOrder(order, options = {}) {
1420
1715
  }))
1421
1716
  }))
1422
1717
  };
1423
- taxByRate = taxLinesByRate(order.lineItems, order.taxMinor, void 0, order.taxRounding).map(({ ratePpm, code, netMinor, amountMinor }) => ({
1718
+ const taxedLines = [...order.lineItems, ...[...order.fees ?? [], ...order.shipping ?? []].map((charge) => ({ ...charge, taxInclusive: order.pricesIncludeTax }))];
1719
+ taxByRate = taxLinesByRate(taxedLines, order.taxMinor, void 0, order.taxRounding).map(({ ratePpm, code, netMinor, amountMinor }) => ({
1424
1720
  ratePpm,
1425
1721
  ...code !== void 0 ? { code } : {},
1426
1722
  netMinor,
@@ -1435,7 +1731,7 @@ function finalizeOrder(order, options = {}) {
1435
1731
  }
1436
1732
  }
1437
1733
  const rounding = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
1438
- const taxRounding = rounding.granularity === "custom" ? { granularity: "custom" } : { granularity: rounding.granularity, mode: rounding.mode };
1734
+ const taxRounding = rounding.granularity === "custom" ? { granularity: "custom" } : rounding.granularity === "woocommerce" ? { granularity: "woocommerce", roundAtSubtotal: rounding.roundAtSubtotal } : { granularity: rounding.granularity, mode: rounding.mode };
1439
1735
  const now = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
1440
1736
  return freezeSentForm({
1441
1737
  id: id2,
@@ -1448,6 +1744,8 @@ function finalizeOrder(order, options = {}) {
1448
1744
  pricesIncludeTax: order.pricesIncludeTax,
1449
1745
  lines,
1450
1746
  payments,
1747
+ ...fees?.length ? { fees } : {},
1748
+ ...shipping?.length ? { shipping } : {},
1451
1749
  subtotalMinor: order.subtotalMinor,
1452
1750
  discountMinor: order.discountMinor,
1453
1751
  taxMinor: order.taxMinor,
@@ -1470,7 +1768,7 @@ function finalizeOrder(order, options = {}) {
1470
1768
  import { addRxPlugin } from "rxdb";
1471
1769
  import { RxDBMigrationSchemaPlugin } from "rxdb/plugins/migration-schema";
1472
1770
  var posOrderSchema = {
1473
- version: 7,
1771
+ version: 8,
1474
1772
  primaryKey: "id",
1475
1773
  type: "object",
1476
1774
  additionalProperties: false,
@@ -1500,9 +1798,17 @@ var posOrderSchema = {
1500
1798
  name: { type: "string" },
1501
1799
  sku: { type: "string" },
1502
1800
  quantity: { type: "integer" },
1801
+ taxStatus: { type: "string", enum: ["taxable", "none"] },
1802
+ custom: { type: "object", additionalProperties: false, properties: {
1803
+ name: { type: "string", minLength: 1, maxLength: 255 },
1804
+ sku: { type: "string", maxLength: 64 },
1805
+ taxClass: { type: "string", maxLength: 64 },
1806
+ taxStatus: { type: "string", enum: ["taxable", "none"] }
1807
+ }, required: ["name", "taxStatus"] },
1503
1808
  unitPriceMinor: { type: "integer" },
1504
1809
  discountMinor: { type: "integer" },
1505
1810
  netMinor: { type: "integer" },
1811
+ netMicros: { type: "string", pattern: "^-?[0-9]{1,24}$", maxLength: 25 },
1506
1812
  taxLines: { type: "array", items: {
1507
1813
  type: "object",
1508
1814
  properties: { code: { type: "string" }, ratePpm: { type: "integer" }, taxMicros: { type: "string" } },
@@ -1511,6 +1817,43 @@ var posOrderSchema = {
1511
1817
  },
1512
1818
  required: ["id", "productId", "name", "sku", "quantity", "unitPriceMinor", "discountMinor", "netMinor", "taxLines"]
1513
1819
  } },
1820
+ fees: { type: "array", items: { type: "object", additionalProperties: false, properties: {
1821
+ id: { type: "string", maxLength: 36 },
1822
+ name: { type: "string", minLength: 1, maxLength: 255 },
1823
+ amountMinor: { type: "integer", minimum: 0 },
1824
+ taxClass: { type: "string", maxLength: 64 },
1825
+ taxStatus: { type: "string", enum: ["taxable", "none"] },
1826
+ netMinor: { type: "integer" },
1827
+ taxMicros: { type: "string" },
1828
+ netMicros: { type: "string", pattern: "^-?[0-9]{1,24}$", maxLength: 25 },
1829
+ totalMinor: { type: "integer" },
1830
+ taxLines: { type: "array", items: { type: "object", additionalProperties: false, properties: {
1831
+ code: { type: "string", maxLength: 255 },
1832
+ ratePpm: { type: "integer" },
1833
+ taxMicros: { type: "string" },
1834
+ rateId: { type: "integer" },
1835
+ compound: { type: "boolean" }
1836
+ }, required: ["ratePpm", "taxMicros"] } }
1837
+ }, required: ["id", "name", "amountMinor", "taxStatus", "taxLines", "netMinor", "taxMicros"] } },
1838
+ shipping: { type: "array", items: { type: "object", additionalProperties: false, properties: {
1839
+ id: { type: "string", maxLength: 36 },
1840
+ name: { type: "string", minLength: 1, maxLength: 255 },
1841
+ methodId: { type: "string", maxLength: 64 },
1842
+ amountMinor: { type: "integer", minimum: 0 },
1843
+ taxClass: { type: "string", maxLength: 64 },
1844
+ taxStatus: { type: "string", enum: ["taxable", "none"] },
1845
+ netMinor: { type: "integer" },
1846
+ taxMicros: { type: "string" },
1847
+ netMicros: { type: "string", pattern: "^-?[0-9]{1,24}$", maxLength: 25 },
1848
+ totalMinor: { type: "integer" },
1849
+ taxLines: { type: "array", items: { type: "object", additionalProperties: false, properties: {
1850
+ code: { type: "string", maxLength: 255 },
1851
+ ratePpm: { type: "integer" },
1852
+ taxMicros: { type: "string" },
1853
+ rateId: { type: "integer" },
1854
+ compound: { type: "boolean" }
1855
+ }, required: ["ratePpm", "taxMicros"] } }
1856
+ }, required: ["id", "name", "amountMinor", "taxStatus", "taxLines", "netMinor", "taxMicros"] } },
1514
1857
  payments: { type: "array", items: {
1515
1858
  type: "object",
1516
1859
  properties: {
@@ -1548,11 +1891,12 @@ var posOrderSchema = {
1548
1891
  }, required: ["since", "reason", "isolated"] },
1549
1892
  lateSessionId: { type: "string" },
1550
1893
  taxRounding: { type: "object", additionalProperties: false, properties: {
1551
- granularity: { type: "string", enum: ["per_order", "per_line_items", "per_rate_group_items", "custom"] },
1894
+ granularity: { type: "string", enum: ["per_order", "per_line_items", "per_rate_group_items", "custom", "woocommerce"] },
1895
+ roundAtSubtotal: { type: "boolean" },
1552
1896
  mode: { type: "string", enum: ["half_away_from_zero", "half_up"] }
1553
1897
  }, required: ["granularity"] },
1554
- sentVersion: { type: "integer", minimum: 1, maximum: 4 },
1555
- downgradedFrom: { type: "integer", minimum: 1, maximum: 4 },
1898
+ sentVersion: { type: "integer", minimum: 1, maximum: 5 },
1899
+ downgradedFrom: { type: "integer", minimum: 1, maximum: 5 },
1556
1900
  // The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
1557
1901
  display: { type: "object", additionalProperties: false, properties: {
1558
1902
  currency: { type: "string" },
@@ -1563,6 +1907,16 @@ var posOrderSchema = {
1563
1907
  taxMinor: { type: "integer" },
1564
1908
  totalMinor: { type: "integer" },
1565
1909
  orderDiscountMinor: { type: "integer" },
1910
+ fees: { type: "array", items: { type: "object", additionalProperties: false, properties: {
1911
+ id: { type: "string", maxLength: 36 },
1912
+ name: { type: "string", minLength: 1, maxLength: 255 },
1913
+ amountMinor: { type: "integer" }
1914
+ }, required: ["id", "name", "amountMinor"] } },
1915
+ shipping: { type: "array", items: { type: "object", additionalProperties: false, properties: {
1916
+ id: { type: "string", maxLength: 36 },
1917
+ name: { type: "string", minLength: 1, maxLength: 255 },
1918
+ amountMinor: { type: "integer" }
1919
+ }, required: ["id", "name", "amountMinor"] } },
1566
1920
  lines: { type: "array", items: {
1567
1921
  type: "object",
1568
1922
  additionalProperties: false,
@@ -1625,7 +1979,7 @@ function posOrderCollection() {
1625
1979
  };
1626
1980
  return {
1627
1981
  schema: posOrderSchema,
1628
- migrationStrategies: { 1: identity2, 2: identity2, 3: identity2, 4: identity2, 5: recordSent, 6: recordRounding, 7: identity2 }
1982
+ migrationStrategies: { 1: identity2, 2: identity2, 3: identity2, 4: identity2, 5: recordSent, 6: recordRounding, 7: identity2, 8: identity2 }
1629
1983
  };
1630
1984
  }
1631
1985
 
@@ -2525,8 +2879,8 @@ async function writeClosure({
2525
2879
  const roundings = /* @__PURE__ */ new Set();
2526
2880
  for (const order of bound) {
2527
2881
  const recorded = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
2528
- const { granularity, mode } = recorded.granularity === "custom" ? DEFAULT_TAX_ROUNDING : recorded;
2529
- roundings.add(`${granularity} ${mode}`);
2882
+ const rounding = recorded.granularity === "custom" ? DEFAULT_TAX_ROUNDING : recorded;
2883
+ roundings.add(rounding.granularity === "woocommerce" ? `woocommerce ${rounding.roundAtSubtotal}` : `${rounding.granularity} ${rounding.mode}`);
2530
2884
  const lines = order.lines.map((line) => ({ ...line, taxInclusive: line.taxInclusive ?? order.pricesIncludeTax }));
2531
2885
  for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor, void 0, order.taxRounding)) {
2532
2886
  const existing2 = taxRates.get(String(ratePpm));
@@ -2895,7 +3249,7 @@ function envelope(row, context, xreport = false) {
2895
3249
  });
2896
3250
  return {
2897
3251
  closure: {
2898
- has_sales: row.period_sales_total != null || row.period_refunds_total != null || Number(breakdowns.transaction_count) > 0,
3252
+ has_sales: Number(breakdowns.transaction_count ?? 0) > 0 || Number(row.period_sales_total || 0) !== 0 || Number(row.period_refunds_total || 0) !== 0,
2899
3253
  has_perpetual: row.perpetual_sales_total != null || row.perpetual_refunds_total != null,
2900
3254
  ...Object.fromEntries(
2901
3255
  ["payment_methods", "tax_rates", "movements"].map((key) => [`has_${key}`, Object.keys(breakdowns[key] ?? {}).length > 0])
@@ -3556,7 +3910,8 @@ function addEntryToCart(builder, entry, traits, currency) {
3556
3910
  sku: variant.sku ?? "",
3557
3911
  imageUrl: traits.getImageUrl?.(product),
3558
3912
  unitPrice,
3559
- taxClass: traits.getTaxClass?.(product, variant.id)
3913
+ taxClass: traits.getTaxClass?.(product, variant.id),
3914
+ ...traits.getTaxStatus?.(product, variant.id) === "none" ? { taxStatus: "none" } : {}
3560
3915
  });
3561
3916
  }
3562
3917
 
@@ -3628,10 +3983,10 @@ function useSale(settings, opts) {
3628
3983
  const subscription = rendered.order$.subscribe(setOrder);
3629
3984
  return () => subscription.unsubscribe();
3630
3985
  }, [rendered]);
3631
- const idle3 = stage.kind === "cart" && !order.lineItems.length;
3986
+ const idle3 = stage.kind === "cart" && !order.lineItems.length && !order.fees?.length && !order.shipping?.length;
3632
3987
  useLayoutEffect(() => {
3633
3988
  const live = builderNow.current.getSnapshot();
3634
- const liveIdle = stageNow.current.kind === "cart" && !live.lineItems.length && !inFlight.current && !pending.current;
3989
+ const liveIdle = stageNow.current.kind === "cart" && !live.lineItems.length && !live.fees?.length && !live.shipping?.length && !inFlight.current && !pending.current;
3635
3990
  if (liveIdle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) resetSale(live.customer ?? null);
3636
3991
  });
3637
3992
  useEffect4(() => () => {
@@ -3754,6 +4109,85 @@ function useSale(settings, opts) {
3754
4109
  setQuantity(lineId, quantity) {
3755
4110
  if (!locked()) builderNow.current.updateQuantity(lineId, quantity);
3756
4111
  },
4112
+ /** ADR-075: adds a fee; returns its id or the builder's refusal. */
4113
+ addFee(input) {
4114
+ if (locked()) return SALE_SAVING;
4115
+ try {
4116
+ return { id: builderNow.current.addFee(input) };
4117
+ } catch (error2) {
4118
+ if (error2 instanceof Error) return error2.message;
4119
+ throw error2;
4120
+ }
4121
+ },
4122
+ /** ADR-075: updates a fee; returns a refusal or null. */
4123
+ updateFee(id2, patch) {
4124
+ if (locked()) return SALE_SAVING;
4125
+ try {
4126
+ builderNow.current.updateFee(id2, patch);
4127
+ return null;
4128
+ } catch (error2) {
4129
+ if (error2 instanceof Error) return error2.message;
4130
+ throw error2;
4131
+ }
4132
+ },
4133
+ /** ADR-075: removes a fee; returns a refusal or null. */
4134
+ removeFee(id2) {
4135
+ if (locked()) return SALE_SAVING;
4136
+ try {
4137
+ builderNow.current.removeFee(id2);
4138
+ return null;
4139
+ } catch (error2) {
4140
+ if (error2 instanceof Error) return error2.message;
4141
+ throw error2;
4142
+ }
4143
+ },
4144
+ /** ADR-075: adds shipping; returns its id or the builder's refusal. */
4145
+ addShipping(input) {
4146
+ if (locked()) return SALE_SAVING;
4147
+ try {
4148
+ return { id: builderNow.current.addShipping(input) };
4149
+ } catch (error2) {
4150
+ if (error2 instanceof Error) return error2.message;
4151
+ throw error2;
4152
+ }
4153
+ },
4154
+ /** ADR-075: updates shipping; returns a refusal or null. */
4155
+ updateShipping(id2, patch) {
4156
+ if (locked()) return SALE_SAVING;
4157
+ try {
4158
+ builderNow.current.updateShipping(id2, patch);
4159
+ return null;
4160
+ } catch (error2) {
4161
+ if (error2 instanceof Error) return error2.message;
4162
+ throw error2;
4163
+ }
4164
+ },
4165
+ /** ADR-075: removes shipping; returns a refusal or null. */
4166
+ removeShipping(id2) {
4167
+ if (locked()) return SALE_SAVING;
4168
+ try {
4169
+ builderNow.current.removeShipping(id2);
4170
+ return null;
4171
+ } catch (error2) {
4172
+ if (error2 instanceof Error) return error2.message;
4173
+ throw error2;
4174
+ }
4175
+ },
4176
+ /** ADR-075: adds a distinct custom product line; returns its id or the builder's refusal. */
4177
+ addCustomLine(input) {
4178
+ if (locked()) return SALE_SAVING;
4179
+ try {
4180
+ return { id: builderNow.current.addLine({
4181
+ ...input,
4182
+ custom: true,
4183
+ productId: `custom:${uuidv7()}`,
4184
+ unitPrice: { amount: input.priceMinor, currency: madeWith.current.currency.toUpperCase() }
4185
+ }) };
4186
+ } catch (error2) {
4187
+ if (error2 instanceof Error) return error2.message;
4188
+ throw error2;
4189
+ }
4190
+ },
3757
4191
  /** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
3758
4192
  setUnitPrice(lineId, amountMinor) {
3759
4193
  if (locked()) return SALE_SAVING;
@@ -3921,7 +4355,7 @@ function useSale(settings, opts) {
3921
4355
  if (stageNow.current.kind !== "cart") return "Finish or cancel the payment before parking the sale";
3922
4356
  const builder = builderNow.current;
3923
4357
  const snapshot = builder.getSnapshot();
3924
- if (!snapshot.lineItems.length) return "There is nothing to park";
4358
+ if (!snapshot.lineItems.length && !snapshot.fees?.length && !snapshot.shipping?.length) return "There is nothing to park";
3925
4359
  await writeOrderDraft(opts.drafts, snapshot);
3926
4360
  if (builderNow.current !== builder || builder.getSnapshot() !== snapshot) return "The sale changed while it was being parked; park it again";
3927
4361
  resetSale(null);
@@ -3933,7 +4367,7 @@ function useSale(settings, opts) {
3933
4367
  if (!opts.drafts) throw new Error("useSale: resume() needs the drafts option");
3934
4368
  const live = builderNow.current;
3935
4369
  const liveSnapshot = live.getSnapshot();
3936
- if (stageNow.current.kind !== "cart" || liveSnapshot.lineItems.length) return "Park or clear the current sale first";
4370
+ if (stageNow.current.kind !== "cart" || liveSnapshot.lineItems.length || liveSnapshot.fees?.length || liveSnapshot.shipping?.length) return "Park or clear the current sale first";
3937
4371
  const doc = await opts.drafts.findOne(draftId).exec();
3938
4372
  if (!doc) return "That parked sale is no longer there";
3939
4373
  const saved = JSON.parse(doc.toJSON().data);
@@ -4267,6 +4701,7 @@ function createOrderOutbox(options) {
4267
4701
  const serverMax = firstSend && validMax(await Promise.resolve().then(() => options.getMaxOrderCreateVersion?.()).catch((cause) => {
4268
4702
  outboxLogger.warn("Failed to read capabilities", { cause });
4269
4703
  })) || 3;
4704
+ const localOrders = /* @__PURE__ */ new Map();
4270
4705
  const batch = await Promise.all(orders.map(async (order) => {
4271
4706
  const frozen = freezeSentForm(order);
4272
4707
  const attempt2 = (attempts.get(order.commandId) ?? 0) + 1;
@@ -4276,14 +4711,15 @@ function createOrderOutbox(options) {
4276
4711
  envelope2 = toOrderCreateEnvelope(frozen, deviceId, attempt2, { maxVersion: order.sentVersion ?? serverMax });
4277
4712
  } catch (cause) {
4278
4713
  if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
4279
- envelope2 = toOrderCreateEnvelope(frozen, deviceId, attempt2, { maxVersion: 3 });
4714
+ envelope2 = toOrderCreateEnvelope(frozen, deviceId, attempt2, { maxVersion: cause.needed === 5 ? 5 : 3 });
4280
4715
  }
4281
4716
  if (frozen !== order || order.sentVersion === void 0) {
4282
4717
  await (await collection.findOne(order.id).exec())?.incrementalModify((data) => ({ ...freezeSentForm(data), sentVersion: data.sentVersion ?? envelope2.version }));
4283
4718
  }
4719
+ localOrders.set(envelope2.id, frozen);
4284
4720
  return envelope2;
4285
4721
  }));
4286
- let outcome = await transport.send(batch);
4722
+ let outcome = await transport.send(batch, { local: { orders: localOrders } });
4287
4723
  backendNotFound.record(outcome, now());
4288
4724
  if (outcome.kind !== "retry" || outcome.reason !== "network") {
4289
4725
  const at2 = now();