@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/dist/index.d.ts +167 -16
- package/dist/index.js +487 -49
- package/package.json +8 -7
- package/src/index.ts +5 -3
- 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/register-outbox.ts +26 -3
- package/src/outbox/types.test-d.ts +5 -0
- package/src/outbox/use-register-outbox.ts +6 -2
- 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/index.ts +2 -1
- package/src/register/register-adoption.ts +105 -0
- package/src/register/register-commands.ts +20 -7
- package/src/register/schemas.ts +3 -3
- package/src/register/session-store.ts +42 -6
- package/src/register/use-register-session.ts +64 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/pos",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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.
|
|
42
|
-
"@tallyui/connector-woocommerce": "3.
|
|
43
|
-
"@tallyui/database": "3.
|
|
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';
|
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) {
|
|
@@ -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 (!
|
|
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' &&
|
|
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
|
+
}
|