@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/dist/index.d.ts +112 -17
- package/dist/index.js +303 -35
- package/package.json +8 -7
- package/src/index.ts +3 -1
- package/src/order/index.ts +2 -1
- package/src/order/order-builder.ts +41 -9
- package/src/order/order-drafts.ts +3 -1
- package/src/order/types.ts +11 -1
- package/src/order/woocommerce-tax.ts +61 -4
- package/src/outbox/types.test-d.ts +5 -0
- package/src/refund/index.ts +3 -0
- package/src/refund/pos-refund.ts +35 -0
- package/src/refund/send-order-refund.ts +43 -0
- package/src/refund/submit-order-refund.ts +85 -0
- package/src/register/expected.ts +23 -6
- package/src/register/register-adoption.ts +7 -4
- package/src/register/schemas.ts +1 -1
- package/src/register/session-store.ts +21 -5
- package/src/register/use-register-session.ts +18 -13
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
|
|
package/src/order/index.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
241
|
-
const conversions = converted.reduce((count, index) => count + [
|
|
242
|
-
...
|
|
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(
|
|
40
|
+
data: JSON.stringify(draft),
|
|
39
41
|
customerName: snapshot.customer?.name ?? '',
|
|
40
42
|
itemCount: snapshot.lineItems.length,
|
|
41
43
|
total: snapshot.totalMinor,
|
package/src/order/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/register/expected.ts
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `refunded_amount`
|
|
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
|
-
|
|
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 }:
|
|
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:
|
|
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 } });
|
package/src/register/schemas.ts
CHANGED
|
@@ -82,7 +82,7 @@ export interface Closure {
|
|
|
82
82
|
counted: TenderMap;
|
|
83
83
|
variance: TenderMap;
|
|
84
84
|
period_sales_total_minor: number;
|
|
85
|
-
/**
|
|
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;
|