@tallyui/pos 3.7.1 → 3.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/pos",
3
- "version": "3.7.1",
3
+ "version": "3.9.0",
4
4
  "type": "module",
5
5
  "description": "Shared POS business logic for Tally UI — tax, orders, payments, discounts, receipts",
6
6
  "main": "./dist/index.js",
@@ -10,7 +10,8 @@
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js",
13
- "source": "./src/index.ts"
13
+ "source": "./src/index.ts",
14
+ "default": "./dist/index.js"
14
15
  }
15
16
  },
16
17
  "files": [
@@ -27,20 +28,20 @@
27
28
  "directory": "packages/pos"
28
29
  },
29
30
  "peerDependencies": {
30
- "@tallyui/core": "^3.7.1",
31
+ "@tallyui/core": "^3.9.0",
31
32
  "react": ">=18",
32
33
  "rxdb": "~17.5.0",
33
34
  "rxjs": ">=7"
34
35
  },
35
36
  "devDependencies": {
36
- "@tallyui/core": "3.7.1",
37
+ "@tallyui/core": "3.9.0",
37
38
  "@types/react": ">=18",
38
39
  "react": "^19.2.4",
39
40
  "rxdb": "17.5.0",
40
41
  "rxjs": "7.8.2",
41
- "@tallyui/connector-medusa": "3.7.1",
42
- "@tallyui/connector-woocommerce": "3.7.1",
43
- "@tallyui/database": "3.7.1"
42
+ "@tallyui/connector-medusa": "3.9.0",
43
+ "@tallyui/connector-woocommerce": "3.9.0",
44
+ "@tallyui/database": "3.9.0"
44
45
  },
45
46
  "scripts": {
46
47
  "build": "tsup",
package/src/index.ts CHANGED
@@ -29,10 +29,11 @@ export { createOrderBuilder, createOrderManager, allocateOrderDiscount, taxFigur
29
29
  export { orderDraftSchema, writeOrderDraft, restoreOrderDraft, parkedOrderSummaries$ } from './order';
30
30
  export { useParkedSales } from './order';
31
31
  export type { BasketLine } from './order';
32
- export type { OrderBuilder, OrderBuilderOptions } from './order';
32
+ export type { OrderBuilder, OrderBuilderOptions, OrderCouponContext } from './order';
33
33
  export type { OrderManager, OrderManagerOptions, ParkedOrderSummary } from './order';
34
34
  export type {
35
35
  Order,
36
+ OrderCoupon,
36
37
  SentOrder,
37
38
  LineItem,
38
39
  LineTaxLine,
@@ -69,6 +70,7 @@ export type { RegisterOutboxOptions, RegisterOutbox, UseRegisterOutboxOptions, U
69
70
  export { createBackendNotFound } from './outbox/backend-not-found';
70
71
  export type { BackendNotFound } from './outbox/backend-not-found';
71
72
  export type { HttpTransportOptions, OrderOutboxOptions, OrderOutbox, TransportOutcome, CommandTransport, OrderTransportContext, OutboxState, UseOrderOutboxOptions, UseOrderOutboxResult } from './outbox';
73
+ export { sendOrderRefund, type OrderRefundOutcome, posRefundSchema, posRefundCollection, submitOrderRefund, RefundAnswerPendingError, type PosRefund, type PosRefundStatus, type PosRefundCollection } from './refund';
72
74
  export { tenderReducer, initTenderState, initialTenderState, appliedMinor, changeMinor, quickTenderedAmounts, evenSplitShareMinor, activePlan, planLegs, MAX_TENDER_MINOR, tenderVoidSchema, tenderVoidCollection } from './tender';
73
75
  export type { PaymentTransport, TenderView, TenderLineId, TenderPlan, SplitTab, TenderState, TenderKey, TenderAction, PlanLeg, TenderVoid } from './tender';
74
76
 
@@ -78,11 +80,11 @@ export type { MovementType, LedgerRow, Movement } from './register';
78
80
  export { registerSessionSchema, registerSessionCollection, cashMovementSchema, closureSchema } from './register';
79
81
  export { addRegisterSessionCollection, RegisterSessionOpenClosedError, registerSessionsLogger, REGISTER_SESSION_MIGRATION_CLOSE_WAIT_MS } from './register';
80
82
  export type { RegisterSession, CashMovement, Closure } from './register';
81
- export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands } from './register';
83
+ export { registerCommandSchema, registerCommandCollection, registerCommandsLogger, sessionOpenCommand, sessionTransitionCommand, movementCommand, closureCommand, reconcileRegisterCommands, adoptRegisterResults, takeOverSession, abandonSession } from './register';
82
84
  export type { RegisterCommand, RegisterCommandCollection } from './register';
83
85
  export { mintUuid, readRegister, ensureRegister, observeRegister$, getBoundRegisterId, readBoundRegister, bindRegister, unbindRegister, nextSaleCounter, mintClosureNumber, advancePerpetual, RegisterIdInvalidError } from './register';
84
86
  export type { RegisterHost, RegisterCounters, RegisterBucket, RegisterStore, RegisterDocument } from './register';
85
- export { RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector, requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure } from './register';
87
+ export { RegisterSessionRequiredError, RegisterSessionClosedError, RegisterNeedsUpgradeError, RegisterSessionConflictError, RegisterTakeOverError, isKnownSessionStatus, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, openSessionSelector, requireOpenSession, stampSession, openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, writeClosure } from './register';
86
88
  export type { RegisterSessionCollection, CashMovementCollection, ClosureCollection } from './register';
87
89
  export { deriveSettled, exportCsv, labelKeys, clampClosureScope, selectClosureRows } from './register';
88
90
  export type { Correction, RecordedFigures, ClosureScope } from './register';
@@ -2,13 +2,14 @@ export { createOrderBuilder } from './order-builder';
2
2
  export { taxFiguresForBasket } from './tax-figures';
3
3
  export type { BasketLine } from './tax-figures';
4
4
  export { allocateOrderDiscount } from './allocate-order-discount';
5
- export type { OrderBuilder, OrderBuilderOptions } from './order-builder';
5
+ export type { OrderBuilder, OrderBuilderOptions, OrderCouponContext } from './order-builder';
6
6
  export { createOrderManager } from './order-manager';
7
7
  export { orderDraftSchema, writeOrderDraft, restoreOrderDraft, parkedOrderSummaries$ } from './order-drafts';
8
8
  export { useParkedSales } from './use-parked-sales';
9
9
  export type { OrderManager, OrderManagerOptions, ParkedOrderSummary } from './order-manager';
10
10
  export type {
11
11
  Order,
12
+ OrderCoupon,
12
13
  SentOrder,
13
14
  LineItem,
14
15
  LineTaxLine,
@@ -1,10 +1,10 @@
1
1
  import { BehaviorSubject, type Observable } from 'rxjs';
2
- import { resolvePrice, woocommerceTax, type ProductTraits, type TaxRounding } from '@tallyui/core';
2
+ import { resolvePrice, woocommerceTax, type woocommerceCoupons, type ProductTraits, type TaxRounding } from '@tallyui/core';
3
3
  import type { TaxContext } from '../tax/types';
4
4
  import { taxMicros, roundMicrosToMinor, roundedTaxByRate } from '../tax/exact';
5
5
  import { taxLogger } from '../tax/tax-provider';
6
6
  import { allocateOrderDiscount } from './allocate-order-discount';
7
- import { woocommerceLine, woocommerceTotals } from './woocommerce-tax';
7
+ import { woocommerceLine, woocommerceTotals, woocommerceCouponReplay } from './woocommerce-tax';
8
8
  import type {
9
9
  Order,
10
10
  LineItem,
@@ -62,10 +62,17 @@ export function assertDisplayResidue(residue: number, convertedLines: number, co
62
62
  if (Math.abs(residue) > bound) throw new Error(`Display residue ${residue} exceeds its bound of ${bound} (ADR-063)`);
63
63
  }
64
64
 
65
+ export interface OrderCouponContext {
66
+ configs: ReadonlyMap<string, woocommerceCoupons.CouponDiscountConfig>;
67
+ productCategories: ReadonlyMap<number, { id: number }[]>;
68
+ calcDiscountsSequentially: boolean;
69
+ }
70
+
65
71
  export interface OrderBuilderOptions {
66
72
  currency: string;
67
73
  taxContext: TaxContext;
68
74
  id?: string;
75
+ couponContext?: OrderCouponContext;
69
76
  }
70
77
 
71
78
  export interface OrderBuilder {
@@ -89,12 +96,14 @@ export interface OrderBuilder {
89
96
  removePayment(paymentId: string): void;
90
97
  setCustomer(customer: CustomerSummary | null): void;
91
98
  setNote(note: string): void;
99
+ /** Replaces the coupon codes with their trimmed, lower-case, deduplicated list. */
100
+ setCoupons(codes: readonly string[]): void;
92
101
  clear(): void;
93
102
  getSnapshot(): Order;
94
103
  }
95
104
 
96
105
  export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
97
- const { taxContext } = options;
106
+ const { taxContext, couponContext } = options;
98
107
  const currency = options.currency.toUpperCase();
99
108
  const orderId = options.id ?? uid();
100
109
  const woo = taxContext.rounding?.granularity === 'woocommerce' && taxContext.getTaxRates ? taxContext.rounding : undefined;
@@ -113,6 +122,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
113
122
  let payments: Payment[] = [];
114
123
  let customer: CustomerSummary | null = null;
115
124
  let note = '';
125
+ let couponCodes: string[] = [];
116
126
  const now = new Date().toISOString();
117
127
 
118
128
  const subject = new BehaviorSubject<Order>(buildOrder());
@@ -186,7 +196,10 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
186
196
  return { ...d, amountMinor };
187
197
  });
188
198
  const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
189
- const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
199
+ const displaySourceLines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
200
+ const replay = woo && couponCodes.length ? woocommerceCouponReplay(displaySourceLines, couponCodes, couponContext!,
201
+ taxContext.getTaxRates!, currency, woo.roundAtSubtotal, taxContext.pricesIncludeTax) : undefined;
202
+ const lines = replay?.lines ?? displaySourceLines;
190
203
 
191
204
  if (woo) shipping = shipping.map((charge) => ({ ...charge, ...recalculateCharge(charge.id, charge, true) }));
192
205
  const wooTotals = woo ? woocommerceTotals(lines, fees, shipping, currency, woo.roundAtSubtotal, taxContext.pricesIncludeTax) : undefined;
@@ -206,7 +219,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
206
219
  // from the settlement total, so every sum is exact; the rounding residue goes to the converted lines' amounts,
207
220
  // never to a discount or an unconverted line.
208
221
  const taxInclusive = taxContext.pricesIncludeTax;
209
- const figures = lines.map((li) => {
222
+ const figures = displaySourceLines.map((li) => {
210
223
  // A negative-priced (return) line shows what settlement charges for it (recalculateLine caps it at 0 today),
211
224
  // with no rows: its capped "discounts" are the negative gross cancelled, not money off.
212
225
  const returned = li.unitPriceMinor < 0;
@@ -221,8 +234,11 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
221
234
  });
222
235
  const displayLines = figures.map((f) => f.line);
223
236
  const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
237
+ const displayCoupons = replay?.coupons.map(({ code, discountMinor, discountTaxMinor }) =>
238
+ ({ code, amountMinor: discountMinor + (taxInclusive ? discountTaxMinor : 0) }));
224
239
  const displayDiscount = displayLines.reduce(
225
- (sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum), orderDiscountDisplay);
240
+ (sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum), orderDiscountDisplay)
241
+ + (displayCoupons?.reduce((sum, row) => sum + row.amountMinor, 0) ?? 0);
226
242
  const chargeRows = (items: ChargeLine[]) => items.map(({ id, name, amountMinor, netMinor, totalMinor }) =>
227
243
  ({ id, name, amountMinor: woo ? taxInclusive ? totalMinor! : netMinor : amountMinor }));
228
244
  const displayFees = chargeRows(fees), displayShipping = chargeRows(shipping);
@@ -237,9 +253,9 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
237
253
  if (charge) displaySubtotal -= residue;
238
254
  residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
239
255
  }
240
- const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
241
- const conversions = converted.reduce((count, index) => count + [lines[index].netMinor, lines[index].orderDiscountMinor,
242
- ...lines[index].discounts.map((d) => d.amountMinor)].filter((x) => x !== 0).length, 0);
256
+ const converted = displaySourceLines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
257
+ const conversions = converted.reduce((count, index) => count + [displaySourceLines[index].netMinor, displaySourceLines[index].orderDiscountMinor,
258
+ ...displaySourceLines[index].discounts.map((d) => d.amountMinor)].filter((x) => x !== 0).length, 0);
243
259
  assertDisplayResidue(residue, converted.length, conversions);
244
260
  // Largest converted remaining first (ties to the earlier line). A negative residue takes a line down to its rows
245
261
  // and share at most, then moves on: six 2-cent inclusive lines can owe −3 with no line above 2. The spill is a
@@ -268,6 +284,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
268
284
  const display = {
269
285
  taxInclusive, subtotalMinor: displaySubtotal, discountMinor: displayDiscount, taxMinor, totalMinor,
270
286
  lines: displayLines, orderDiscountMinor: orderDiscountDisplay,
287
+ ...(displayCoupons ? { coupons: displayCoupons } : {}),
271
288
  ...(fees.length ? { fees: displayFees } : {}),
272
289
  ...(shipping.length ? { shipping: displayShipping } : {}),
273
290
  };
@@ -279,6 +296,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
279
296
  id: orderId,
280
297
  status: 'draft',
281
298
  lineItems: lines,
299
+ ...(replay ? { coupons: replay.coupons } : {}),
282
300
  ...(fees.length ? { fees: [...fees] } : {}),
283
301
  ...(shipping.length ? { shipping: [...shipping] } : {}),
284
302
  discounts: recalcedOrderDiscounts,
@@ -540,6 +558,19 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
540
558
  emit();
541
559
  },
542
560
 
561
+ setCoupons(codes) {
562
+ const normalized = [...new Set(codes.map((code) => code.trim().toLowerCase()))];
563
+ if (normalized.length && !couponContext) throw new Error('Coupon context is required');
564
+ for (const code of normalized) {
565
+ const config = couponContext!.configs.get(code);
566
+ if (!config) throw new RangeError(`Unknown coupon ${code}`);
567
+ // Lines do not carry their regular price yet, so sale lines cannot be identified.
568
+ if (config.exclude_sale_items) throw new RangeError(`Coupon ${code} excludes sale items`);
569
+ }
570
+ couponCodes = normalized;
571
+ emit();
572
+ },
573
+
543
574
  clear() {
544
575
  lineItems = [];
545
576
  fees = [];
@@ -548,6 +579,7 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
548
579
  payments = [];
549
580
  customer = null;
550
581
  note = '';
582
+ couponCodes = [];
551
583
  emit();
552
584
  },
553
585
 
@@ -33,9 +33,11 @@ export interface ParkedOrderSummary {
33
33
  }
34
34
 
35
35
  export async function writeOrderDraft(drafts: RxCollection, snapshot: Order): Promise<string> {
36
+ // Coupons are not persisted until ADR-077 step (d2).
37
+ const { coupons: _coupons, ...draft } = snapshot;
36
38
  await drafts.upsert({
37
39
  id: snapshot.id,
38
- data: JSON.stringify(snapshot),
40
+ data: JSON.stringify(draft),
39
41
  customerName: snapshot.customer?.name ?? '',
40
42
  itemCount: snapshot.lineItems.length,
41
43
  total: snapshot.totalMinor,
@@ -1,5 +1,11 @@
1
1
  import type { Money, TaxRounding } from '@tallyui/core';
2
2
 
3
+ export interface OrderCoupon {
4
+ code: string; // lower-case code
5
+ discountMinor: number; // WooCommerce coupon discount, ex tax, in integer minor units
6
+ discountTaxMinor: number; // its tax, in integer minor units
7
+ }
8
+
3
9
  export interface Order {
4
10
  id: string;
5
11
  status: 'draft' | 'parked' | 'saved' | 'completed';
@@ -7,6 +13,8 @@ export interface Order {
7
13
  fees?: FeeLine[];
8
14
  shipping?: ShippingLine[];
9
15
  discounts: AppliedDiscount[];
16
+ /** Present only after coupon replay; discountMinor excludes coupons, which are carried here. */
17
+ coupons?: OrderCoupon[];
10
18
  payments: Payment[];
11
19
  customer: CustomerSummary | null;
12
20
  note: string;
@@ -37,12 +45,14 @@ export type SentOrder = Omit<Order, 'customer'> & { customer: (Omit<CustomerSumm
37
45
  export interface DisplayTotals {
38
46
  taxInclusive: boolean; // = order.pricesIncludeTax
39
47
  subtotalMinor: number; // product lines before discounts, excluding fees and shipping (ADR-075)
40
- discountMinor: number; // Σ lines' discount rows + orderDiscountMinor, in the display mode; >= 0
48
+ discountMinor: number; // Σ lines' discount rows + orderDiscountMinor + coupon rows, in the display mode; >= 0
41
49
  taxMinor: number; // = order.taxMinor (added when exclusive, included when inclusive)
42
50
  totalMinor: number; // = order.totalMinor
43
51
  lines: DisplayLine[]; // in lineItems order; Σ amountMinor = subtotalMinor
44
52
  fees?: Array<{ id: string; name: string; amountMinor: number }>;
45
53
  shipping?: Array<{ id: string; name: string; amountMinor: number }>;
54
+ /** Present exactly when Order.coupons is. */
55
+ coupons?: Array<{ code: string; amountMinor: number }>;
46
56
  orderDiscountMinor: number; // the order discounts as one row, not allocated: Σ each line's share, converted on its own
47
57
  }
48
58
 
@@ -1,7 +1,8 @@
1
- import { minorUnitDigits, woocommerceTax } from '@tallyui/core';
2
- import type { WooRate } from '../tax/types';
1
+ import { minorUnitDigits, woocommerceTax, woocommerceCoupons } from '@tallyui/core';
2
+ import type { TaxContext, WooRate } from '../tax/types';
3
3
  import type { RateTaxLine } from '../tax/exact';
4
- import type { ChargeLine, LineItem, LineTaxLine } from './types';
4
+ import type { ChargeLine, LineItem, LineTaxLine, OrderCoupon } from './types';
5
+ import type { OrderCouponContext } from './order-builder';
5
6
 
6
7
  /** ADR-076: all WooCommerce float arithmetic stays here; only integer money leaves this module. */
7
8
  export function woocommerceLine(amountMinor: number, rates: readonly WooRate[], inclusive: boolean, currency: string,
@@ -19,13 +20,69 @@ export function woocommerceLine(amountMinor: number, rates: readonly WooRate[],
19
20
  return { code: rate.code, rateId: rate.id, compound: rate.compound, ratePpm: Math.round(Number(rate.rate) * 10000),
20
21
  taxMicros: String(Math.round(tax.total * factor * 1e6)) };
21
22
  });
22
- const tax = result.taxes.reduce((sum, rate) => sum + (roundAtSubtotal ? rate.total
23
+ return lineAmounts(net, result.taxes, taxLines, dp, roundAtSubtotal, pricesIncludeTax);
24
+ }
25
+
26
+ function lineAmounts(net: number, taxes: { total: number }[], taxLines: LineTaxLine[], dp: number,
27
+ roundAtSubtotal: boolean, pricesIncludeTax: boolean) {
28
+ const factor = 10 ** dp;
29
+ const tax = taxes.reduce((sum, rate) => sum + (roundAtSubtotal ? rate.total
23
30
  : woocommerceTax.roundTaxTotal(rate.total, dp, pricesIncludeTax)), 0);
24
31
  return { netMinor: Math.round(woocommerceTax.roundHalfUp(net, dp) * factor), netMicros: String(Math.round(net * factor * 1e6)),
25
32
  totalMinor: Math.round(woocommerceTax.roundHalfUp(net + tax, dp) * factor), taxLines,
26
33
  taxMicros: taxLines.reduce((sum, rate) => sum + BigInt(rate.taxMicros), 0n).toString() };
27
34
  }
28
35
 
36
+ /** Replays coupons on the manually discounted net lines; only integer money leaves this boundary (ADR-076). */
37
+ export function woocommerceCouponReplay(lines: readonly LineItem[], codes: readonly string[], context: OrderCouponContext,
38
+ getTaxRates: NonNullable<TaxContext['getTaxRates']>, currency: string, roundAtSubtotal: boolean,
39
+ pricesIncludeTax: boolean): { lines: LineItem[]; coupons: OrderCoupon[] } {
40
+ const dp = minorUnitDigits(currency), factor = 10 ** dp;
41
+ const inputLines = lines.filter((line) => line.unitPriceMinor >= 0);
42
+ const lineItems: woocommerceCoupons.LineItemInput[] = inputLines.map((line) => {
43
+ const productId = Number(line.productId), variationId = Number(line.variantId);
44
+ const net = (Number(line.netMicros) / (factor * 1e6)).toFixed(6);
45
+ const taxes = line.taxLines.map((tax) => {
46
+ const amount = (Number(tax.taxMicros) / (factor * 1e6)).toFixed(6);
47
+ return { id: tax.rateId, subtotal: amount, total: amount };
48
+ });
49
+ const tax = taxes.reduce((sum, row) => sum + Number(row.total), 0).toFixed(6);
50
+ return {
51
+ product_id: !line.custom && Number.isSafeInteger(productId) && productId >= 0 ? productId : 0,
52
+ ...(Number.isSafeInteger(variationId) && variationId >= 0 ? { variation_id: variationId } : {}),
53
+ quantity: line.quantity, tax_class: line.taxClass ?? '',
54
+ ...(line.taxStatus === 'none' ? { tax_status: 'none' } : {}),
55
+ subtotal: net, total: net, taxes, subtotal_tax: tax, total_tax: tax,
56
+ };
57
+ });
58
+ const taxRates = new Map<string, woocommerceCoupons.RecalculateInput['taxRates'][number]>();
59
+ for (const line of inputLines) for (const rate of getTaxRates(line.taxClass)) {
60
+ const taxClass = woocommerceTax.normalizeTaxClass(line.taxClass);
61
+ taxRates.set(JSON.stringify([rate.id, taxClass]), { id: rate.id, rate: rate.rate, compound: rate.compound,
62
+ order: rate.priority, priority: rate.priority, class: taxClass });
63
+ }
64
+ const result = woocommerceCoupons.recalculateCoupons({
65
+ lineItems, couponLines: codes.map((code) => ({ code })),
66
+ couponConfigs: new Map(codes.map((code) => [code, context.configs.get(code)!])),
67
+ pricesIncludeTax, calcDiscountsSequentially: context.calcDiscountsSequentially,
68
+ productCategories: new Map(context.productCategories), taxRates: [...taxRates.values()],
69
+ taxRoundAtSubtotal: roundAtSubtotal, dp,
70
+ });
71
+ let index = 0;
72
+ return {
73
+ lines: lines.map((line) => {
74
+ if (line.unitPriceMinor < 0) return line;
75
+ const item = result.lineItems[index++];
76
+ const taxes = line.taxLines.map((tax) => ({ total: Number(item.taxes?.find((row) => row.id === tax.rateId)?.total ?? '0') }));
77
+ const taxLines = line.taxLines.map((tax, index) => ({ ...tax, taxMicros: String(Math.round(taxes[index].total * factor * 1e6)) }));
78
+ return { ...line, ...lineAmounts(Number(item.total), taxes, taxLines, dp, roundAtSubtotal, pricesIncludeTax) };
79
+ }),
80
+ coupons: result.couponLines.map((coupon, index) => ({ code: codes[index],
81
+ discountMinor: Math.round(woocommerceTax.roundHalfUp(Number(coupon.discount), dp) * factor),
82
+ discountTaxMinor: Math.round(woocommerceTax.roundHalfUp(Number(coupon.discount_tax), dp) * factor) })),
83
+ };
84
+ }
85
+
29
86
  /** calculateOrderTotals: products round conditionally, fees stay raw, shipping always rounds per item. */
30
87
  export function woocommerceTotals(lines: readonly LineItem[], fees: readonly ChargeLine[], shipping: readonly ChargeLine[],
31
88
  currency: string, roundAtSubtotal: boolean, inclusive: boolean) {
@@ -2,6 +2,7 @@ import type { CommandResult, RegisterCommandEnvelope } from '@tallyui/core';
2
2
  import type { RxCollection } from 'rxdb';
3
3
  import { BehaviorSubject, type Observable, type Subscription } from 'rxjs';
4
4
  import { registerCommandsLogger, type RegisterCommand } from '../register/register-commands';
5
+ import type { RegisterSessionCollection } from '../register/session-store';
5
6
  import { countFresh, readFresh } from '../rxdb';
6
7
  import { createBackendNotFound, type BackendNotFound } from './backend-not-found';
7
8
  import { STUCK_AFTER_MS } from './order-outbox';
@@ -12,6 +13,9 @@ export const NON_BLOCKING_REJECTIONS: ReadonlySet<string> = new Set(['register_s
12
13
 
13
14
  export interface RegisterOutboxOptions {
14
15
  collection: RxCollection<RegisterCommand>;
16
+ /** The till's `register_sessions`. With it, a refused open (`register_session_already_open`) stops blocking its register
17
+ * once its session is `abandoned` (ADR-078 decision 9). Without it, that refusal always blocks. */
18
+ sessions?: RegisterSessionCollection;
15
19
  transport: CommandTransport<RegisterCommandEnvelope>;
16
20
  deviceId: string;
17
21
  /** Checked at the start of every run; false sends nothing. */
@@ -42,7 +46,7 @@ function plainCommand({ key, registerId, seq, commandId, type, version, payload,
42
46
  }
43
47
 
44
48
  export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOutbox {
45
- const { collection, transport, deviceId } = options;
49
+ const { collection, sessions, transport, deviceId } = options;
46
50
  const size = options.batchSize;
47
51
  const batchSize = Math.max(1, Math.min(typeof size === 'number' && Number.isFinite(size) ? Math.floor(size) : 10, 10));
48
52
  const initialBackoff = options.initialBackoffMs ?? 1000;
@@ -62,6 +66,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
62
66
  let running: Promise<void> | undefined;
63
67
  let timer: ReturnType<typeof setTimeout> | undefined;
64
68
  let subscription: Subscription | undefined;
69
+ let sessionsSubscription: Subscription | undefined;
65
70
  let stopped = false;
66
71
  let insertedDuringRun = false;
67
72
  let stoppedDuringRun = false;
@@ -92,7 +97,15 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
92
97
  for (const { commandId } of commands) clocks.set(commandId, { ...(clocks.get(commandId) ?? { since: at, firstFailedAt: at }), seq: ++failureSeq, reason });
93
98
  }
94
99
 
100
+ function isNonBlocking(command: RegisterCommand, abandoned: ReadonlySet<string>): boolean {
101
+ return NON_BLOCKING_REJECTIONS.has(command.error?.code ?? '') ||
102
+ (command.error?.code === 'register_session_already_open' &&
103
+ typeof command.payload.sessionId === 'string' && abandoned.has(command.payload.sessionId));
104
+ }
105
+
95
106
  async function updateState(patch: Partial<OutboxState> = {}) {
107
+ const abandoned = new Set(sessions && clocks.size
108
+ ? (await readFresh(sessions, { selector: { status: 'abandoned' } })).map((session) => session.id) : []);
96
109
  const pending = await countFresh(collection, { syncStatus: 'pending' });
97
110
  // Only commands in a register's sendable prefix (pending, before its first rejected command) keep a clock.
98
111
  const sendable = new Set<string>();
@@ -100,7 +113,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
100
113
  if (clocks.size) for (const command of await readFresh(collection, { selector: { syncStatus: { $in: ['pending', 'rejected'] } },
101
114
  sort: [{ seq: 'asc' }, { key: 'asc' }] })) {
102
115
  if (command.syncStatus === 'rejected') {
103
- if (!NON_BLOCKING_REJECTIONS.has(command.error?.code ?? '')) blocked.add(command.registerId);
116
+ if (!isNonBlocking(command, abandoned)) blocked.add(command.registerId);
104
117
  } else if (!blocked.has(command.registerId)) sendable.add(command.commandId);
105
118
  }
106
119
  for (const id of clocks.keys()) if (!sendable.has(id)) clocks.delete(id);
@@ -123,6 +136,8 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
123
136
  if (options.isEnabled?.() === false) return;
124
137
  const pending = await readFresh(collection, { selector: { syncStatus: 'pending' } });
125
138
  const registerIds = [...new Set(pending.map((command) => command.registerId))].sort();
139
+ const abandoned = new Set(sessions
140
+ ? (await readFresh(sessions, { selector: { status: 'abandoned' } })).map((session) => session.id) : []);
126
141
  let sent = false;
127
142
  for (const registerId of registerIds) {
128
143
  const ledger = await readFresh(collection, {
@@ -131,7 +146,7 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
131
146
  });
132
147
  const commands: RegisterCommand[] = [];
133
148
  for (const command of ledger) {
134
- if (command.syncStatus === 'rejected' && NON_BLOCKING_REJECTIONS.has(command.error?.code ?? '')) continue;
149
+ if (command.syncStatus === 'rejected' && isNonBlocking(command, abandoned)) continue;
135
150
  if (command.syncStatus === 'rejected') break;
136
151
  commands.push(command);
137
152
  if (command.type === 'register.session.open' || commands.length >= batchSize) break;
@@ -241,6 +256,12 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
241
256
  flush().catch(() => {});
242
257
  }
243
258
  });
259
+ if (sessions && !sessionsSubscription) sessionsSubscription = sessions.$.subscribe((event) => {
260
+ if (event.documentData?.status === 'abandoned') {
261
+ insertedDuringRun = true;
262
+ flush().catch(() => {});
263
+ }
264
+ });
244
265
  if (!stopped) flush().catch(() => {});
245
266
  },
246
267
  stop() {
@@ -249,6 +270,8 @@ export function createRegisterOutbox(options: RegisterOutboxOptions): RegisterOu
249
270
  timer = undefined;
250
271
  subscription?.unsubscribe();
251
272
  subscription = undefined;
273
+ sessionsSubscription?.unsubscribe();
274
+ sessionsSubscription = undefined;
252
275
  missingSubscription?.unsubscribe(); missingSubscription = undefined;
253
276
  state$.next({ ...state$.value, nextAttemptAt: undefined });
254
277
  },
@@ -1,6 +1,7 @@
1
1
  import { describe, expectTypeOf, it } from 'vitest';
2
2
  import type { AnyCommandEnvelope, CommandEnvelope, OrderCreateEnvelope, OrderCreatePayload, RegisterCommandEnvelope } from '@tallyui/core';
3
3
  import type { CommandTransport } from './types';
4
+ import { sendOrderRefund } from '../refund';
4
5
 
5
6
  describe('CommandTransport', () => {
6
7
  it('defaults to order.create envelopes', () => {
@@ -14,6 +15,10 @@ describe('CommandTransport', () => {
14
15
  expectTypeOf<CommandTransport<AnyCommandEnvelope>>().toMatchTypeOf<CommandTransport<OrderCreateEnvelope>>();
15
16
  });
16
17
 
18
+ it('the wide transport is accepted where sendOrderRefund wants the refund transport', () => {
19
+ expectTypeOf<CommandTransport<AnyCommandEnvelope>>().toMatchTypeOf<Parameters<typeof sendOrderRefund>[0]>();
20
+ });
21
+
17
22
  it('the wide transport accepts register command envelopes', () => {
18
23
  const transport: CommandTransport<AnyCommandEnvelope> = { send: async () => ({ kind: 'results', results: [] }) };
19
24
  const batch: RegisterCommandEnvelope[] = [];
@@ -1,6 +1,7 @@
1
1
  import type { RegisterCommandEnvelope } from '@tallyui/core';
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import type { RegisterCommandCollection } from '../register/register-commands';
4
+ import type { RegisterSessionCollection } from '../register/session-store';
4
5
  import { createRegisterOutbox, type RegisterOutboxOptions } from './register-outbox';
5
6
  import type { CommandTransport, OutboxState } from './types';
6
7
 
@@ -9,6 +10,8 @@ const idle: OutboxState = { pending: 0, sending: false };
9
10
  export interface UseRegisterOutboxOptions {
10
11
  /** `register_commands` (see `registerCommandCollection()`); `null` while it opens or when there's no store. A new collection (a store switch) restarts the outbox. */
11
12
  commands: RegisterCommandCollection | null;
13
+ /** The till's `register_sessions`; abandoned sessions unblock refused opens. A new collection restarts the outbox. */
14
+ sessions?: RegisterSessionCollection | null;
12
15
  /** Builds the transport; called when the outbox is created: on each new collection or device id (twice under React StrictMode's double effect, which stops the first outbox). */
13
16
  transport(): CommandTransport<RegisterCommandEnvelope>;
14
17
  /** The device id sent on every command. A change restarts the outbox. */
@@ -27,7 +30,7 @@ export interface UseRegisterOutboxResult {
27
30
  }
28
31
 
29
32
  export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegisterOutboxResult {
30
- const { commands, deviceId } = options;
33
+ const { commands, sessions, deviceId } = options;
31
34
  const latest = useRef(options);
32
35
  latest.current = options;
33
36
  const current = useRef<{ commands: RegisterCommandCollection; deviceId: string; outbox: ReturnType<typeof createRegisterOutbox> } | null>(null);
@@ -37,6 +40,7 @@ export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegiste
37
40
  setState(idle);
38
41
  if (!commands) return;
39
42
  const outbox = createRegisterOutbox({ collection: commands, deviceId, transport: latest.current.transport(),
43
+ sessions: sessions ?? undefined,
40
44
  isEnabled: latest.current.isEnabled ? () => latest.current.isEnabled?.() ?? true : undefined,
41
45
  onResult: latest.current.onResult ? async (command, result) => { await latest.current.onResult?.(command, result); } : undefined,
42
46
  backendNotFound: latest.current.backendNotFound });
@@ -44,7 +48,7 @@ export function useRegisterOutbox(options: UseRegisterOutboxOptions): UseRegiste
44
48
  const status = outbox.state$.subscribe(setState);
45
49
  outbox.start();
46
50
  return () => { outbox.stop(); status.unsubscribe(); current.current = null; setState(idle); };
47
- }, [commands, deviceId]);
51
+ }, [commands, sessions, deviceId]);
48
52
 
49
53
  const opened = current.current;
50
54
  return { state: opened?.commands === commands && opened.deviceId === deviceId ? state : idle,
@@ -0,0 +1,3 @@
1
+ export { sendOrderRefund, type OrderRefundOutcome } from './send-order-refund';
2
+ export { posRefundSchema, posRefundCollection, type PosRefund, type PosRefundStatus, type PosRefundCollection } from './pos-refund';
3
+ export { submitOrderRefund, RefundAnswerPendingError } from './submit-order-refund';
@@ -0,0 +1,35 @@
1
+ import type { CommandError, OrderRefundEnvelope, OrderRefundResult } from '@tallyui/core';
2
+ import type { RxCollection, RxJsonSchema } from 'rxdb';
3
+
4
+ export type PosRefundStatus = 'pending' | 'applied' | 'rejected' | 'unsent';
5
+ export interface PosRefund {
6
+ id: string; commandId: string; orderId: string; sessionId: string; registerId: string;
7
+ envelope: OrderRefundEnvelope;
8
+ status: PosRefundStatus;
9
+ result?: OrderRefundResult;
10
+ duplicate?: boolean;
11
+ error?: CommandError;
12
+ createdAt: string; updatedAt: string;
13
+ }
14
+ export type PosRefundCollection = RxCollection<PosRefund>;
15
+
16
+ export const posRefundSchema: RxJsonSchema<PosRefund> = {
17
+ title: 'POS refunds', version: 0, primaryKey: 'id', type: 'object', additionalProperties: false,
18
+ properties: {
19
+ id: { type: 'string', maxLength: 64 }, commandId: { type: 'string', maxLength: 64 },
20
+ orderId: { type: 'string', maxLength: 64 }, sessionId: { type: 'string', maxLength: 64 },
21
+ registerId: { type: 'string', maxLength: 64 },
22
+ envelope: { type: 'object', additionalProperties: true },
23
+ status: { type: 'string', enum: ['pending', 'applied', 'rejected', 'unsent'], maxLength: 10 },
24
+ result: { type: 'object', additionalProperties: true }, duplicate: { type: 'boolean' },
25
+ error: { type: 'object', properties: {
26
+ code: { type: 'string' }, message: { type: 'string' }, data: { type: 'object', additionalProperties: true },
27
+ }, required: ['code', 'message'], additionalProperties: false },
28
+ createdAt: { type: 'string' }, updatedAt: { type: 'string' },
29
+ },
30
+ required: ['id', 'commandId', 'orderId', 'sessionId', 'registerId', 'envelope', 'status', 'createdAt', 'updatedAt'],
31
+ indexes: [['sessionId']],
32
+ };
33
+ /** Local-only, never replicated (ADR-080 amendment 1).
34
+ * Added with addCollections like cash_movements. */
35
+ export const posRefundCollection = () => ({ schema: posRefundSchema });
@@ -0,0 +1,43 @@
1
+ import type { CommandError, OrderRefundEnvelope, OrderRefundResult } from '@tallyui/core';
2
+ import { parseCommandResult } from '@tallyui/core/server';
3
+ import type { CommandTransport } from '../outbox/types';
4
+
5
+ export type OrderRefundOutcome =
6
+ | { kind: 'applied'; refund: OrderRefundResult; duplicate: boolean }
7
+ | { kind: 'rejected'; error: CommandError }
8
+ | { kind: 'unknown'; reason: string; retryAfterMs?: number }
9
+ | { kind: 'refused'; status: number; reason: string }
10
+ | { kind: 'unauthorized' };
11
+
12
+ /** Online only, never queued (ADR-080). On unknown, resend the same envelope (same id).
13
+ * After rejected, a new attempt needs a new id. Refused and unauthorized mean the store did not take the batch. */
14
+ export async function sendOrderRefund(
15
+ transport: CommandTransport<OrderRefundEnvelope>, envelope: OrderRefundEnvelope,
16
+ ): Promise<OrderRefundOutcome> {
17
+ let outcome;
18
+ try {
19
+ outcome = await transport.send([envelope]);
20
+ } catch {
21
+ return { kind: 'unknown', reason: 'transport_error' };
22
+ }
23
+ if (outcome.kind === 'retry') {
24
+ return {
25
+ kind: 'unknown', reason: outcome.reason,
26
+ ...(outcome.retryAfterMs !== undefined ? { retryAfterMs: outcome.retryAfterMs } : {}),
27
+ };
28
+ }
29
+ if (outcome.kind === 'refused' || outcome.kind === 'unauthorized') return outcome;
30
+ const matches = outcome.results.filter((result) => result.id === envelope.id);
31
+ if (matches.length !== 1) return { kind: 'unknown', reason: 'missing_result' };
32
+ let result;
33
+ try {
34
+ result = parseCommandResult(matches[0]);
35
+ } catch {
36
+ return { kind: 'unknown', reason: 'bad_result' };
37
+ }
38
+ if (result.status === 'applied' || result.status === 'duplicate') {
39
+ if (!result.refund) return { kind: 'unknown', reason: 'bad_result' };
40
+ return { kind: 'applied', refund: result.refund, duplicate: result.status === 'duplicate' };
41
+ }
42
+ return { kind: 'rejected', error: result.error! };
43
+ }