@tallyui/pos 3.2.1 → 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.
@@ -20,9 +20,12 @@ import type { PosOrder } from './types';
20
20
  * nothing else. Its migration sets it on every older row. It is one-way like version 5.
21
21
  * Version 7 adds `saleId`, the sale order's id (ADR-072; never sent), and changes nothing else. Its migration is the
22
22
  * identity: older orders have no `saleId`. It is one-way like version 6.
23
+ * Version 8 adds fees, shipping, custom lines (ADR-075) and the woocommerce tax rounding (ADR-076), and changes nothing else.
24
+ * It also stores the WooCommerce 6dp net (`netMicros`) under the woocommerce rounding (ADR-076 amendment).
25
+ * Its migration is the identity. It is one-way like version 7.
23
26
  */
24
27
  export const posOrderSchema: RxJsonSchema<PosOrder> = {
25
- version: 7, primaryKey: 'id', type: 'object', additionalProperties: false,
28
+ version: 8, primaryKey: 'id', type: 'object', additionalProperties: false,
26
29
  properties: {
27
30
  id: { type: 'string', maxLength: 36 },
28
31
  saleId: { type: 'string', maxLength: 36 },
@@ -39,7 +42,13 @@ export const posOrderSchema: RxJsonSchema<PosOrder> = {
39
42
  type: 'object', properties: {
40
43
  id: { type: 'string', maxLength: 36 }, productId: { type: 'string' }, variantId: { type: 'string' },
41
44
  name: { type: 'string' }, sku: { type: 'string' }, quantity: { type: 'integer' },
45
+ taxStatus: { type: 'string', enum: ['taxable', 'none'] },
46
+ custom: { type: 'object', additionalProperties: false, properties: {
47
+ name: { type: 'string', minLength: 1, maxLength: 255 }, sku: { type: 'string', maxLength: 64 },
48
+ taxClass: { type: 'string', maxLength: 64 }, taxStatus: { type: 'string', enum: ['taxable', 'none'] },
49
+ }, required: ['name', 'taxStatus'] },
42
50
  unitPriceMinor: { type: 'integer' }, discountMinor: { type: 'integer' }, netMinor: { type: 'integer' },
51
+ netMicros: { type: 'string', pattern: '^-?[0-9]{1,24}$', maxLength: 25 },
43
52
  taxLines: { type: 'array', items: {
44
53
  type: 'object', properties: { code: { type: 'string' }, ratePpm: { type: 'integer' }, taxMicros: { type: 'string' } },
45
54
  required: ['ratePpm', 'taxMicros'],
@@ -47,6 +56,26 @@ export const posOrderSchema: RxJsonSchema<PosOrder> = {
47
56
  },
48
57
  required: ['id', 'productId', 'name', 'sku', 'quantity', 'unitPriceMinor', 'discountMinor', 'netMinor', 'taxLines'],
49
58
  } },
59
+ fees: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
60
+ id: { type: 'string', maxLength: 36 }, name: { type: 'string', minLength: 1, maxLength: 255 },
61
+ amountMinor: { type: 'integer', minimum: 0 }, taxClass: { type: 'string', maxLength: 64 },
62
+ taxStatus: { type: 'string', enum: ['taxable', 'none'] }, netMinor: { type: 'integer' }, taxMicros: { type: 'string' },
63
+ netMicros: { type: 'string', pattern: '^-?[0-9]{1,24}$', maxLength: 25 }, totalMinor: { type: 'integer' },
64
+ taxLines: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
65
+ code: { type: 'string', maxLength: 255 }, ratePpm: { type: 'integer' }, taxMicros: { type: 'string' },
66
+ rateId: { type: 'integer' }, compound: { type: 'boolean' },
67
+ }, required: ['ratePpm', 'taxMicros'] } },
68
+ }, required: ['id', 'name', 'amountMinor', 'taxStatus', 'taxLines', 'netMinor', 'taxMicros'] } },
69
+ shipping: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
70
+ id: { type: 'string', maxLength: 36 }, name: { type: 'string', minLength: 1, maxLength: 255 },
71
+ methodId: { type: 'string', maxLength: 64 }, amountMinor: { type: 'integer', minimum: 0 }, taxClass: { type: 'string', maxLength: 64 },
72
+ taxStatus: { type: 'string', enum: ['taxable', 'none'] }, netMinor: { type: 'integer' }, taxMicros: { type: 'string' },
73
+ netMicros: { type: 'string', pattern: '^-?[0-9]{1,24}$', maxLength: 25 }, totalMinor: { type: 'integer' },
74
+ taxLines: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
75
+ code: { type: 'string', maxLength: 255 }, ratePpm: { type: 'integer' }, taxMicros: { type: 'string' },
76
+ rateId: { type: 'integer' }, compound: { type: 'boolean' },
77
+ }, required: ['ratePpm', 'taxMicros'] } },
78
+ }, required: ['id', 'name', 'amountMinor', 'taxStatus', 'taxLines', 'netMinor', 'taxMicros'] } },
50
79
  payments: { type: 'array', items: {
51
80
  type: 'object', properties: {
52
81
  id: { type: 'string', maxLength: 36 }, method: { type: 'string', enum: ['cash', 'external'] },
@@ -70,16 +99,23 @@ export const posOrderSchema: RxJsonSchema<PosOrder> = {
70
99
  }, required: ['since', 'reason', 'isolated'] },
71
100
  lateSessionId: { type: 'string' },
72
101
  taxRounding: { type: 'object', additionalProperties: false, properties: {
73
- granularity: { type: 'string', enum: ['per_order', 'per_line_items', 'per_rate_group_items', 'custom'] },
102
+ granularity: { type: 'string', enum: ['per_order', 'per_line_items', 'per_rate_group_items', 'custom', 'woocommerce'] },
103
+ roundAtSubtotal: { type: 'boolean' },
74
104
  mode: { type: 'string', enum: ['half_away_from_zero', 'half_up'] },
75
105
  }, required: ['granularity'] },
76
- sentVersion: { type: 'integer', minimum: 1, maximum: 4 },
77
- downgradedFrom: { type: 'integer', minimum: 1, maximum: 4 },
106
+ sentVersion: { type: 'integer', minimum: 1, maximum: 5 },
107
+ downgradedFrom: { type: 'integer', minimum: 1, maximum: 5 },
78
108
  // The nested objects are closed too: loosening a schema later is free, tightening one costs a migration.
79
109
  display: { type: 'object', additionalProperties: false, properties: {
80
110
  currency: { type: 'string' }, exponent: { type: 'integer' }, taxInclusive: { type: 'boolean' },
81
111
  subtotalMinor: { type: 'integer' }, discountMinor: { type: 'integer' }, taxMinor: { type: 'integer' },
82
112
  totalMinor: { type: 'integer' }, orderDiscountMinor: { type: 'integer' },
113
+ fees: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
114
+ id: { type: 'string', maxLength: 36 }, name: { type: 'string', minLength: 1, maxLength: 255 }, amountMinor: { type: 'integer' },
115
+ }, required: ['id', 'name', 'amountMinor'] } },
116
+ shipping: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
117
+ id: { type: 'string', maxLength: 36 }, name: { type: 'string', minLength: 1, maxLength: 255 }, amountMinor: { type: 'integer' },
118
+ }, required: ['id', 'name', 'amountMinor'] } },
83
119
  lines: { type: 'array', items: {
84
120
  type: 'object', additionalProperties: false, properties: {
85
121
  lineId: { type: 'string' }, amountMinor: { type: 'integer' },
@@ -129,5 +165,5 @@ export function posOrderCollection(): { schema: RxJsonSchema<PosOrder>; migratio
129
165
  // it means no older sale is ever re-rounded (#287).
130
166
  const recordRounding = (doc: PosOrder) => { doc.taxRounding ??= { ...DEFAULT_TAX_ROUNDING }; return doc; };
131
167
  return { schema: posOrderSchema,
132
- migrationStrategies: { 1: identity, 2: identity, 3: identity, 4: identity, 5: recordSent, 6: recordRounding, 7: identity } };
168
+ migrationStrategies: { 1: identity, 2: identity, 3: identity, 4: identity, 5: recordSent, 6: recordRounding, 7: identity, 8: identity } };
133
169
  }
@@ -1,5 +1,5 @@
1
- import type { CommandError, CommandServerRefs, CommandWarning, PaymentMethodKind, TaxRounding } from '@tallyui/core';
2
- import type { DisplayTotals } from '../order/types';
1
+ import type { CommandError, CommandServerRefs, CommandWarning, OrderCreateLine, PaymentMethodKind, TaxRounding } from '@tallyui/core';
2
+ import type { DisplayTotals, FeeLine, ShippingLine } from '../order/types';
3
3
 
4
4
  export type PosOrderSyncStatus = 'pending' | 'applied' | 'rejected';
5
5
 
@@ -19,6 +19,8 @@ export interface PosOrderLine {
19
19
  id: string;
20
20
  productId: string;
21
21
  variantId?: string;
22
+ custom?: OrderCreateLine['custom'];
23
+ taxStatus?: 'taxable' | 'none';
22
24
  name: string;
23
25
  sku: string;
24
26
  quantity: number;
@@ -26,6 +28,7 @@ export interface PosOrderLine {
26
28
  /** Line discounts plus the allocated order-discount share, in the line's own tax mode (ADR-062). */
27
29
  discountMinor: number;
28
30
  netMinor: number;
31
+ netMicros?: string;
29
32
  taxLines: Array<{ code?: string; ratePpm: number; taxMicros: string }>;
30
33
  /** This line's own tax mode; set only when it was converted from the store's (ADR-038 amendment). */
31
34
  taxInclusive?: boolean;
@@ -48,6 +51,8 @@ export interface PosOrder {
48
51
  currency: string;
49
52
  pricesIncludeTax: boolean;
50
53
  lines: PosOrderLine[];
54
+ fees?: FeeLine[];
55
+ shipping?: ShippingLine[];
51
56
  subtotalMinor: number;
52
57
  discountMinor: number;
53
58
  taxMinor: number;
@@ -71,9 +76,9 @@ export interface PosOrder {
71
76
  * The order.create version every attempt under this `commandId` goes out at: the outbox records it before the first
72
77
  * send, and lowers it only on a downgrade (ADR-065 amendment). Absent means not sent yet (or requeued).
73
78
  */
74
- sentVersion?: 1 | 2 | 3 | 4;
79
+ sentVersion?: 1 | 2 | 3 | 4 | 5;
75
80
  /** The version first tried, before the downgrade (the order's audit). */
76
- downgradedFrom?: 1 | 2 | 3 | 4;
81
+ downgradedFrom?: 1 | 2 | 3 | 4 | 5;
77
82
  /** ADR-065: the receipt's display figures, in integer minor units of `currency` at `exponent`. */
78
83
  display?: DisplayTotals & { currency: string; exponent: number };
79
84
  /** ADR-065: tax by rate, named as `taxLinesByRate` names them (`amountMinor` is the tax). */
@@ -5,7 +5,8 @@ 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 taxedLines = [...order.lineItems, ...[...(order.fees ?? []), ...(order.shipping ?? [])].map((charge) => ({
8
+ const taxedLines = [...order.lineItems.map((line) => order.taxRounding?.granularity === 'woocommerce' && line.netMicros !== undefined
9
+ ? { ...line, taxInclusive: order.pricesIncludeTax } : line), ...[...(order.fees ?? []), ...(order.shipping ?? [])].map((charge) => ({
9
10
  ...charge, taxInclusive: order.pricesIncludeTax,
10
11
  }))];
11
12
  const taxLines = taxLinesByRate(taxedLines, order.taxMinor, config.taxLabels, order.taxRounding).map(
@@ -18,6 +19,7 @@ export function buildReceiptData(order: SentOrder, config: ReceiptConfig): Recei
18
19
  let taxSoFar = order.lineItems.reduce(
19
20
  (sum, li) => li.taxInclusive || li.priceTaxModeConverted ? sum : sum + BigInt(li.taxMicros), 0n);
20
21
  const lineTotals = order.lineItems.map((li) => {
22
+ if (order.taxRounding?.granularity === 'woocommerce' && li.netMicros !== undefined) return order.pricesIncludeTax ? li.totalMinor! : li.netMinor;
21
23
  if (!li.priceTaxModeConverted) return li.netMinor;
22
24
  const share = roundMicrosToMinor(taxSoFar + BigInt(li.taxMicros)) - roundMicrosToMinor(taxSoFar);
23
25
  taxSoFar += BigInt(li.taxMicros);
@@ -172,7 +172,7 @@ function envelope<R extends Values>(row: R, context: ClosureContext, xreport = f
172
172
  return {
173
173
  closure: {
174
174
  has_sales:
175
- row.period_sales_total != null || row.period_refunds_total != null || Number(breakdowns.transaction_count) > 0,
175
+ Number(breakdowns.transaction_count ?? 0) > 0 || Number(row.period_sales_total || 0) !== 0 || Number(row.period_refunds_total || 0) !== 0,
176
176
  has_perpetual: row.perpetual_sales_total != null || row.perpetual_refunds_total != null,
177
177
  ...Object.fromEntries(
178
178
  ['payment_methods', 'tax_rates', 'movements'].map((key) => [`has_${key}`, Object.keys((breakdowns[key] as Values) ?? {}).length > 0]),
@@ -481,8 +481,8 @@ export async function writeClosure({
481
481
  for (const order of bound) {
482
482
  // Grouped by the rule applied: a `custom` sale used the default figures, so it joins the default's group.
483
483
  const recorded = order.taxRounding ?? DEFAULT_TAX_ROUNDING;
484
- const { granularity, mode } = recorded.granularity === 'custom' ? DEFAULT_TAX_ROUNDING : recorded;
485
- roundings.add(`${granularity} ${mode}`);
484
+ const rounding = recorded.granularity === 'custom' ? DEFAULT_TAX_ROUNDING : recorded;
485
+ roundings.add(rounding.granularity === 'woocommerce' ? `woocommerce ${rounding.roundAtSubtotal}` : `${rounding.granularity} ${rounding.mode}`);
486
486
  const lines = order.lines.map((line) => ({ ...line, taxInclusive: line.taxInclusive ?? order.pricesIncludeTax }));
487
487
  for (const { ratePpm, netMinor, amountMinor } of taxLinesByRate(lines, order.taxMinor, undefined, order.taxRounding)) {
488
488
  const existing = taxRates.get(String(ratePpm));
package/src/sale/cart.ts CHANGED
@@ -19,5 +19,6 @@ export function addEntryToCart<Doc>(builder: OrderBuilder, entry: CatalogueEntry
19
19
  productId: traits.getId(product), variantId: variant.id, name,
20
20
  sku: variant.sku ?? '', imageUrl: traits.getImageUrl?.(product), unitPrice,
21
21
  taxClass: traits.getTaxClass?.(product, variant.id),
22
+ ...(traits.getTaxStatus?.(product, variant.id) === 'none' ? { taxStatus: 'none' as const } : {}),
22
23
  });
23
24
  }
@@ -14,5 +14,9 @@ export function withPricingContext(context: SyncContext, settings: StoreSettings
14
14
  */
15
15
  export function taxProviderProps(settings: StoreSettings): Omit<TaxProviderProps, 'children'> {
16
16
  const rounding = settings.taxRounding?.granularity === 'custom' ? undefined : settings.taxRounding;
17
- return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...(rounding && { rounding }), ...(settings.taxRateCodes && { rateCodes: settings.taxRateCodes }) };
17
+ return { ratesPpm: settings.taxRatesPpm, pricesIncludeTax: settings.pricesIncludeTax, ...(rounding && { rounding }), ...(settings.taxRateCodes && { rateCodes: settings.taxRateCodes }),
18
+ ...(settings.taxRates && { taxRates: settings.taxRates }),
19
+ ...(settings.taxRoundAtSubtotal !== undefined && { taxRoundAtSubtotal: settings.taxRoundAtSubtotal }),
20
+ ...(settings.shippingTaxClass !== undefined && { shippingTaxClass: settings.shippingTaxClass }),
21
+ ...(settings.taxClassSlugs && { taxClassSlugs: settings.taxClassSlugs }) };
18
22
  }
package/src/tax/exact.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { TaxRounding } from '@tallyui/core';
2
+ import { woocommerceTaxByRate } from '../order/woocommerce-tax';
2
3
 
3
4
  /** Micro-minor-units per minor unit. */
4
5
  export const MICROS_PER_MINOR = 1_000_000n;
@@ -122,7 +123,7 @@ export interface RateTaxLine {
122
123
  amountMinor: number;
123
124
  }
124
125
 
125
- type TaxedLine = { netMinor: number; discountMinor?: number; taxInclusive: boolean; taxLines: readonly { code?: string; ratePpm: number; taxMicros: string }[] };
126
+ type TaxedLine = { netMinor: number; netMicros?: string; discountMinor?: number; taxInclusive: boolean; taxLines: readonly { code?: string; ratePpm: number; taxMicros: string }[] };
126
127
 
127
128
  /**
128
129
  * A `per_line_items` or `per_rate_group_items` store's figures (#287) as @vendure/core 3.7.3 computes them; undefined
@@ -186,6 +187,7 @@ export function taxLinesByRate(
186
187
  taxLabels?: Record<number, string>,
187
188
  rounding?: TaxRounding,
188
189
  ): RateTaxLine[] {
190
+ if (rounding?.granularity === 'woocommerce' && lines.every((line) => line.netMicros !== undefined)) return woocommerceTaxByRate(lines, rounding.roundAtSubtotal, taxLabels, orderTaxMinor);
189
191
  const label = (ratePpm: number) => taxLabels?.[ratePpm] ?? `Tax ${ratePpm / 10000}%`;
190
192
  const rounded = roundedTaxByRate(lines, rounding);
191
193
  if (rounded) return rounded.rates.map(({ code, ratePpm, netMinor, amountMinor }) => ({ label: label(ratePpm), code, ratePpm, netMinor, amountMinor }));
package/src/tax/index.ts CHANGED
@@ -2,4 +2,4 @@ export { MICROS_PER_MINOR, ratePpmFromPercent, taxMicros, roundMicrosToMinor, co
2
2
  export type { TaxLineInput, OrderTaxTotals, RateTaxLine } from './exact';
3
3
  export { TaxProvider, useTax, taxLogger } from './tax-provider';
4
4
  export type { TaxProviderProps } from './tax-provider';
5
- export type { TaxRateMap, TaxContext } from './types';
5
+ export type { TaxRateMap, TaxContext, WooRate } from './types';
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, useMemo } from 'react';
2
2
  import type { ReactNode } from 'react';
3
- import type { TaxRounding } from '@tallyui/core';
3
+ import { woocommerceTax, type StoreSettings, type TaxRounding } from '@tallyui/core';
4
4
  import type { TaxContext } from './types';
5
5
  import { createLogger } from '../logging';
6
6
 
@@ -8,7 +8,8 @@ export const taxLogger = createLogger('tax');
8
8
 
9
9
  const TaxCtx = createContext<TaxContext | null>(null);
10
10
 
11
- export interface TaxProviderProps {
11
+ export interface TaxProviderProps extends Pick<StoreSettings, 'taxRoundAtSubtotal' | 'shippingTaxClass' | 'taxClassSlugs'> {
12
+ taxRates?: StoreSettings['taxRates'];
12
13
  ratesPpm: Record<string, number>;
13
14
  pricesIncludeTax: boolean;
14
15
  /** `ServerCapabilities.taxRounding` (#287); a change restarts an idle sale under it. */
@@ -18,7 +19,7 @@ export interface TaxProviderProps {
18
19
  children: ReactNode;
19
20
  }
20
21
 
21
- export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, children }: TaxProviderProps) {
22
+ export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, taxRates, children }: TaxProviderProps) {
22
23
  const value = useMemo<TaxContext>(
23
24
  () => {
24
25
  if (Object.values(ratesPpm).some((rate) => !Number.isSafeInteger(rate) || rate < 0)) {
@@ -26,6 +27,8 @@ export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, c
26
27
  }
27
28
  const unknown = new Set<string>();
28
29
  return {
30
+ ...(taxRates ? { getTaxRates: (taxClass?: string, options?: { shipping?: boolean }) =>
31
+ (taxRates[woocommerceTax.normalizeTaxClass(taxClass)] ?? []).filter((rate) => !options?.shipping || rate.shipping) } : {}),
29
32
  getTaxRatePpm: (taxClass) => {
30
33
  if (taxClass !== undefined && Object.hasOwn(ratesPpm, taxClass)) return ratesPpm[taxClass];
31
34
  // A class the store's rates don't key is taxed at the default rate; warn once per class and rate map.
@@ -44,7 +47,7 @@ export function TaxProvider({ ratesPpm, pricesIncludeTax, rounding, rateCodes, c
44
47
  ...(rounding ? { rounding } : {}),
45
48
  };
46
49
  },
47
- [ratesPpm, pricesIncludeTax, rounding, rateCodes],
50
+ [ratesPpm, pricesIncludeTax, rounding, rateCodes, taxRates],
48
51
  );
49
52
 
50
53
  return <TaxCtx.Provider value={value}>{children}</TaxCtx.Provider>;
package/src/tax/types.ts CHANGED
@@ -1,4 +1,6 @@
1
- import type { TaxRounding } from '@tallyui/core';
1
+ import type { StoreSettings, TaxRounding } from '@tallyui/core';
2
+
3
+ export type WooRate = NonNullable<StoreSettings['taxRates']>[string][number];
2
4
 
3
5
  /** Tax rates as integer parts per million (19% = 190000). */
4
6
  export type TaxRateMap = {
@@ -7,6 +9,7 @@ export type TaxRateMap = {
7
9
  };
8
10
 
9
11
  export interface TaxContext {
12
+ getTaxRates?(taxClass?: string, options?: { shipping?: boolean }): readonly WooRate[];
10
13
  /** Tax rate for a tax class as integer parts per million (19% = 190000); the default class when omitted or unknown. */
11
14
  getTaxRatePpm(taxClass?: string): number;
12
15
  /** The backend's name for that class's rate, when mapped: a `per_rate_group_items` store groups by name and value (#287). */