@tallyui/pos 3.8.0 → 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/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
 
@@ -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) {
@@ -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[] = [];
@@ -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
+ }
@@ -0,0 +1,85 @@
1
+ import type { OrderRefundEnvelope } from '@tallyui/core';
2
+ import { RxError } from 'rxdb';
3
+ import type { CommandTransport } from '../outbox/types';
4
+ import type { PosRefund, PosRefundCollection } from './pos-refund';
5
+ import { sendOrderRefund, type OrderRefundOutcome } from './send-order-refund';
6
+
7
+ export class RefundAnswerPendingError extends Error {
8
+ readonly code = 'REFUND_ANSWER_PENDING';
9
+ constructor(public readonly record: PosRefund) {
10
+ super("This refund's answer is still unknown. Send it again before trying a new refund attempt.");
11
+ this.name = 'RefundAnswerPendingError';
12
+ }
13
+ }
14
+
15
+ /** Online only, never queued; the record is written before the send.
16
+ * An applied record is final; a pending one is resent only with its own envelope
17
+ * (ADR-080 amendment 1). */
18
+ export async function submitOrderRefund({ refunds, transport, envelope, now = () => new Date().toISOString() }: {
19
+ refunds: PosRefundCollection; transport: CommandTransport<OrderRefundEnvelope>;
20
+ envelope: OrderRefundEnvelope; now?: () => string;
21
+ }): Promise<{ outcome: OrderRefundOutcome; record: PosRefund }> {
22
+ const id = envelope.payload.clientRefundId;
23
+ let doc = await refunds.findOne(id).exec();
24
+ if (!doc) {
25
+ const at = now();
26
+ const { orderId, sessionId, registerId } = envelope.payload;
27
+ try {
28
+ doc = await refunds.insert({
29
+ id, commandId: envelope.id, orderId, sessionId, registerId, envelope,
30
+ status: 'pending', createdAt: at, updatedAt: at,
31
+ });
32
+ } catch (error) {
33
+ if (!(error instanceof RxError) || error.code !== 'CONFLICT') throw error;
34
+ doc = (await refunds.findOne(id).exec())!;
35
+ }
36
+ }
37
+ let record = doc.toJSON() as PosRefund;
38
+ // Re-evaluate the stored state at most once after a guarded attempt write.
39
+ for (let pass = 0; ; pass++) {
40
+ if (pass === 2) throw new RefundAnswerPendingError(record);
41
+ if (record.status === 'applied') {
42
+ return { outcome: { kind: 'applied', refund: record.result!, duplicate: true }, record };
43
+ }
44
+ if (record.status === 'pending') {
45
+ if (record.commandId !== envelope.id) throw new RefundAnswerPendingError(record);
46
+ break;
47
+ }
48
+ if (record.status === 'rejected' && record.commandId === envelope.id) {
49
+ return { outcome: { kind: 'rejected', error: record.error! }, record };
50
+ }
51
+ if (record.status === 'unsent' && record.commandId === envelope.id) {
52
+ doc = await doc.incrementalModify((stored) => {
53
+ if (stored.status !== 'unsent' || stored.commandId !== envelope.id) return stored;
54
+ const { error, ...rest } = stored;
55
+ return { ...rest, status: 'pending', updatedAt: now() };
56
+ });
57
+ record = doc.toJSON() as PosRefund;
58
+ continue;
59
+ }
60
+ doc = await doc.incrementalModify((stored) => {
61
+ if (stored.status !== 'rejected' && stored.status !== 'unsent') return stored;
62
+ const { result, duplicate, error, ...rest } = stored;
63
+ return { ...rest, commandId: envelope.id, envelope, status: 'pending', updatedAt: now() };
64
+ });
65
+ record = doc.toJSON() as PosRefund;
66
+ if (record.commandId === envelope.id) break;
67
+ }
68
+ const outcome = await sendOrderRefund(transport, envelope);
69
+ if (outcome.kind !== 'unknown') {
70
+ doc = await doc.incrementalModify((stored) => {
71
+ if (stored.status !== 'pending' || stored.commandId !== envelope.id) return stored;
72
+ const updatedAt = now();
73
+ if (outcome.kind === 'applied') {
74
+ const { error, ...rest } = stored;
75
+ return { ...rest, status: 'applied', result: outcome.refund, duplicate: outcome.duplicate, updatedAt };
76
+ }
77
+ if (outcome.kind === 'rejected') return { ...stored, status: 'rejected', error: outcome.error, updatedAt };
78
+ const error = outcome.kind === 'refused'
79
+ ? { code: 'refused', message: outcome.reason, data: { status: outcome.status } }
80
+ : { code: 'unauthorized', message: 'unauthorized' };
81
+ return { ...stored, status: 'unsent', error, updatedAt };
82
+ });
83
+ }
84
+ return { outcome, record: doc.getLatest().toJSON() as PosRefund };
85
+ }
@@ -3,12 +3,23 @@
3
3
  * captured payments and cash movements — never what the cashier counts. Port provenance
4
4
  * (ADR-032 amendment 1): WCPOS `next` `3b5331b5c`.
5
5
  *
6
- * **Refund attribution is deferred.** WCPOS's `attributeRefunds` debits a session's drawer for
7
- * refunds processed against its captured payments (by stamped session, then by a legacy
8
- * `refunded_amount` fallback). TallyUI has no refund model yet (ADR-032 amendment 1), so
9
- * `deriveExpected` covers only the float, the session's captured payment rows, and its
10
- * paid-in/paid-out cash movements under WCPOS's void rules.
6
+ * Refunds come from the till's applied `pos_refunds`, counted in the record's own `sessionId`
7
+ * (ADR-080 amendment 1), by the server's rule. WCPOS's `attributeRefunds` fallbacks (allocations,
8
+ * `refunded_amount`) are not ported: the store attributes each refund to a tender in `byMethod`.
11
9
  */
10
+ import type { PosRefund } from '../refund/pos-refund';
11
+
12
+ export type RefundRow = Pick<PosRefund, 'id' | 'sessionId' | 'status' | 'result'>;
13
+
14
+ export function refundAmounts(refund: RefundRow): Array<[string, number]> {
15
+ const result = refund.result;
16
+ if (typeof result !== 'object' || result === null ||
17
+ (Object.getPrototypeOf(result) !== Object.prototype && Object.getPrototypeOf(result) !== null)) return [];
18
+ const byMethod = result.byMethod;
19
+ if (typeof byMethod !== 'object' || byMethod === null ||
20
+ (Object.getPrototypeOf(byMethod) !== Object.prototype && Object.getPrototypeOf(byMethod) !== null)) return [];
21
+ return Object.entries(byMethod).filter(([method, amount]) => method.length > 0 && Number.isSafeInteger(amount) && amount >= 0);
22
+ }
12
23
 
13
24
  /** `session_id`, `kind`, `method_id`, `status` kept as WCPOS names them; money is TallyUI's integer-minor-units convention. */
14
25
  export type LedgerRow = {
@@ -32,7 +43,7 @@ export type Movement = {
32
43
  /**
33
44
  * Expected totals per tender method: the counted float, plus every captured ledger row for the
34
45
  * session (grouped under `cash` for cash rows, else `method_id`), plus its non-voided
35
- * paid-in/paid-out movements.
46
+ * paid-in/paid-out movements, minus its applied refunds.
36
47
  *
37
48
  * A movement is excluded when its own `voided_by` is set, or when a `type: 'void'` row's
38
49
  * `voids` names its id; the `void` row itself carries no amount of its own.
@@ -41,10 +52,12 @@ export function deriveExpected({
41
52
  session,
42
53
  movements,
43
54
  ledgerRowsBySession,
55
+ refunds,
44
56
  }: {
45
57
  session: { id: string; countedFloatMinor: number };
46
58
  movements: readonly Movement[];
47
59
  ledgerRowsBySession: readonly LedgerRow[];
60
+ refunds?: readonly RefundRow[];
48
61
  }): Record<string, number> {
49
62
  const totals: Record<string, number> = { cash: session.countedFloatMinor };
50
63
  for (const row of ledgerRowsBySession) {
@@ -62,5 +75,9 @@ export function deriveExpected({
62
75
  if (row.type === 'paid_in') totals.cash += row.amountMinor;
63
76
  if (row.type === 'paid_out') totals.cash -= row.amountMinor;
64
77
  }
78
+ for (const refund of refunds ?? []) {
79
+ if (refund.status !== 'applied' || refund.sessionId !== session.id) continue;
80
+ for (const [method, amount] of refundAmounts(refund)) totals[method] = (totals[method] ?? 0) - amount;
81
+ }
65
82
  return totals;
66
83
  }
@@ -5,6 +5,7 @@ import type { RegisterSession } from './schemas';
5
5
  import { isKnownSessionStatus, RegisterSessionRequiredError, RegisterTakeOverError, type RegisterSessionCollection } from './session-store';
6
6
 
7
7
  const chains = new WeakMap<RegisterCommandCollection, Promise<void>>();
8
+ type SessionTarget = { commands: RegisterCommandCollection; sessions: RegisterSessionCollection; sessionId: string; now?: string };
8
9
 
9
10
  /**
10
11
  * Applied opens fill a missing resume id; refused opens make open/counting sessions conflict.
@@ -57,9 +58,11 @@ export async function adoptRegisterResults({ commands, sessions, registerId }: {
57
58
  return run;
58
59
  }
59
60
 
60
- export async function takeOverSession({ commands, sessions, sessionId, now }: {
61
- commands: RegisterCommandCollection; sessions: RegisterSessionCollection; sessionId: string; now?: string;
61
+ export async function takeOverSession({ commands, sessions, sessionId, now, registerContract }: SessionTarget & {
62
+ /** The store's register contract (`capabilities.register`); take over needs 2, and a missing value counts as below 2. */
63
+ registerContract?: number;
62
64
  }): Promise<void> {
65
+ if ((registerContract ?? 0) < 2) throw new RegisterTakeOverError('REGISTER_TAKEOVER_UNSUPPORTED');
63
66
  const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
64
67
  const [session] = await readFresh(sessions, { selector: { id: sessionId } });
65
68
  const row = await commands.findOne(`session.open:${sessionId}`).exec();
@@ -76,7 +79,7 @@ export async function takeOverSession({ commands, sessions, sessionId, now }: {
76
79
  return run;
77
80
  }
78
81
 
79
- async function finishAbandon({ commands, sessions, sessionId, now }: Parameters<typeof takeOverSession>[0]) {
82
+ async function finishAbandon({ commands, sessions, sessionId, now }: SessionTarget) {
80
83
  const rows = await readFresh(commands, { selector: { 'payload.sessionId': sessionId, syncStatus: 'pending' } });
81
84
  for (const row of rows) {
82
85
  await (await commands.findOne(row.key).exec(true)).incrementalModify((doc) => doc.syncStatus !== 'pending' ? doc : ({
@@ -87,7 +90,7 @@ async function finishAbandon({ commands, sessions, sessionId, now }: Parameters<
87
90
  await (await sessions.findOne(sessionId).exec(true)).incrementalModify((doc) => doc.status === 'conflict' ? { ...doc, status: 'abandoned' } : doc);
88
91
  }
89
92
 
90
- export async function abandonSession(input: Parameters<typeof takeOverSession>[0]): Promise<void> {
93
+ export async function abandonSession(input: SessionTarget): Promise<void> {
91
94
  const { commands, sessions, sessionId } = input;
92
95
  const run = (chains.get(commands) ?? Promise.resolve()).then(async () => {
93
96
  const [session] = await readFresh(sessions, { selector: { id: sessionId } });
@@ -82,7 +82,7 @@ export interface Closure {
82
82
  counted: TenderMap;
83
83
  variance: TenderMap;
84
84
  period_sales_total_minor: number;
85
- /** Always 0 until TallyUI has a refund model (ADR-032 amendment 1). */
85
+ /** The session's applied refunds (ADR-080 amendment 1); 0 when the app passes none. */
86
86
  period_refunds_total_minor: number;
87
87
  perpetual_sales_total_minor: number;
88
88
  perpetual_refunds_total_minor: number;