@tallyui/pos 0.1.0 → 2.0.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/LICENSE +21 -0
- package/dist/index.d.ts +1770 -42
- package/dist/index.js +3422 -204
- package/package.json +21 -10
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +45 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +5 -0
- package/src/order/order-builder.ts +240 -95
- package/src/order/order-manager.ts +23 -42
- package/src/order/types.ts +67 -15
- package/src/outbox/http-transport.ts +66 -0
- package/src/outbox/index.ts +7 -0
- package/src/outbox/order-outbox.ts +198 -0
- package/src/outbox/types.ts +22 -0
- package/src/outbox/use-order-outbox.ts +161 -0
- package/src/pos-order/command.ts +35 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +81 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +11 -0
- package/src/pos-order/open.ts +206 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +100 -0
- package/src/pos-order/types.ts +67 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +29 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +36 -22
- package/src/receipt/types.ts +16 -10
- package/src/register/__fixtures__/closure-local-row.json +61 -0
- package/src/register/__fixtures__/closure.json +197 -0
- package/src/register/__fixtures__/corrections-dst.json +63 -0
- package/src/register/closure-document.ts +270 -0
- package/src/register/closure-rows.ts +65 -0
- package/src/register/document-labels.ts +46 -0
- package/src/register/expected.ts +66 -0
- package/src/register/export-csv.ts +74 -0
- package/src/register/facts.ts +109 -0
- package/src/register/index.ts +30 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -0
- package/src/register/register-count.denominations.ts +11 -0
- package/src/register/register-count.helpers.ts +64 -0
- package/src/register/register-document.ts +243 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +541 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +425 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +22 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +337 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +13 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +69 -0
- package/src/tax/exact.ts +155 -0
- package/src/tax/index.ts +3 -2
- package/src/tax/tax-provider.tsx +13 -12
- package/src/tax/types.ts +3 -7
- package/src/tender/index.ts +2 -0
- package/src/tender/tender-state.ts +326 -0
- package/src/currency/currency-provider.test.tsx +0 -36
- package/src/currency/format-currency.test.ts +0 -36
- package/src/currency/format-currency.ts +0 -18
- package/src/logging/logger.test.ts +0 -154
- package/src/logging/sinks.test.ts +0 -60
- package/src/order/discount-engine.test.ts +0 -152
- package/src/order/order-builder.test.ts +0 -246
- package/src/order/order-manager.test.ts +0 -209
- package/src/order/payment.test.ts +0 -91
- package/src/receipt/build-receipt-data.test.ts +0 -166
- package/src/repository/create-repository.test.ts +0 -166
- package/src/tax/calculate.test.ts +0 -59
- package/src/tax/calculate.ts +0 -32
- package/src/tax/tax-provider.test.tsx +0 -51
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { consoleSink, callbackSink } from './sinks';
|
|
3
|
-
import type { LogEntry } from './types';
|
|
4
|
-
|
|
5
|
-
const entry: LogEntry = {
|
|
6
|
-
level: 'info',
|
|
7
|
-
scope: 'test',
|
|
8
|
-
message: 'hello',
|
|
9
|
-
data: { key: 'value' },
|
|
10
|
-
timestamp: 1000,
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
describe('consoleSink', () => {
|
|
14
|
-
it('writes info/debug to console.log', () => {
|
|
15
|
-
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
16
|
-
const sink = consoleSink({ levels: ['info'] });
|
|
17
|
-
sink.write(entry);
|
|
18
|
-
expect(spy).toHaveBeenCalledOnce();
|
|
19
|
-
spy.mockRestore();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('writes warn to console.warn', () => {
|
|
23
|
-
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
24
|
-
const sink = consoleSink({ levels: ['warn'] });
|
|
25
|
-
sink.write({ ...entry, level: 'warn' });
|
|
26
|
-
expect(spy).toHaveBeenCalledOnce();
|
|
27
|
-
spy.mockRestore();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('writes error to console.error', () => {
|
|
31
|
-
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
32
|
-
const sink = consoleSink({ levels: ['error'] });
|
|
33
|
-
sink.write({ ...entry, level: 'error' });
|
|
34
|
-
expect(spy).toHaveBeenCalledOnce();
|
|
35
|
-
spy.mockRestore();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('uses provided id or defaults to console', () => {
|
|
39
|
-
const s1 = consoleSink({ levels: ['info'] });
|
|
40
|
-
expect(s1.id).toBe('console');
|
|
41
|
-
|
|
42
|
-
const s2 = consoleSink({ id: 'debug-console', levels: ['debug'] });
|
|
43
|
-
expect(s2.id).toBe('debug-console');
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe('callbackSink', () => {
|
|
48
|
-
it('calls the callback for matching entries', () => {
|
|
49
|
-
const cb = vi.fn();
|
|
50
|
-
const sink = callbackSink({ id: 'toast', levels: ['error'], callback: cb });
|
|
51
|
-
sink.write({ ...entry, level: 'error' });
|
|
52
|
-
expect(cb).toHaveBeenCalledWith(expect.objectContaining({ level: 'error', message: 'hello' }));
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('has correct id and levels', () => {
|
|
56
|
-
const sink = callbackSink({ id: 'my-sink', levels: ['warn', 'error'], callback: vi.fn() });
|
|
57
|
-
expect(sink.id).toBe('my-sink');
|
|
58
|
-
expect(sink.levels).toEqual(['warn', 'error']);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { firstValueFrom } from 'rxjs';
|
|
3
|
-
import { createOrderBuilder } from './order-builder';
|
|
4
|
-
import type { ProductTraits } from '@tallyui/core';
|
|
5
|
-
import type { TaxContext } from '../tax/types';
|
|
6
|
-
|
|
7
|
-
const traits: ProductTraits = {
|
|
8
|
-
getId: (doc) => doc.id,
|
|
9
|
-
getName: (doc) => doc.name,
|
|
10
|
-
getSku: (doc) => doc.sku ?? '',
|
|
11
|
-
getPrice: (doc) => String(doc.price),
|
|
12
|
-
getRegularPrice: (doc) => String(doc.price),
|
|
13
|
-
getSalePrice: () => undefined,
|
|
14
|
-
isOnSale: () => false,
|
|
15
|
-
getImageUrl: () => undefined,
|
|
16
|
-
getImageUrls: () => [],
|
|
17
|
-
getDescription: () => undefined,
|
|
18
|
-
getStockStatus: () => 'instock',
|
|
19
|
-
getStockQuantity: () => null,
|
|
20
|
-
hasVariants: () => false,
|
|
21
|
-
getType: () => 'simple',
|
|
22
|
-
getBarcode: () => undefined,
|
|
23
|
-
getCategoryNames: () => [],
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const taxContext: TaxContext = { getTaxRate: () => 0.1, pricesIncludeTax: false };
|
|
27
|
-
const product = { id: 'p1', name: 'Coffee', price: 10 };
|
|
28
|
-
|
|
29
|
-
describe('discount engine', () => {
|
|
30
|
-
describe('line-level discounts', () => {
|
|
31
|
-
it('applies a percentage discount to a line item', async () => {
|
|
32
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
33
|
-
const lineId = builder.addProduct(product, traits);
|
|
34
|
-
builder.applyLineDiscount(lineId, { type: 'percentage', value: 10, label: '10% off' });
|
|
35
|
-
|
|
36
|
-
const order = await firstValueFrom(builder.order$);
|
|
37
|
-
const line = order.lineItems[0];
|
|
38
|
-
expect(line.discountAmount).toBeCloseTo(1.00);
|
|
39
|
-
expect(line.lineTotal).toBeCloseTo(9.90); // (10 - 1) + 0.90 tax
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('applies a fixed discount to a line item', async () => {
|
|
43
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
44
|
-
const lineId = builder.addProduct(product, traits);
|
|
45
|
-
builder.applyLineDiscount(lineId, { type: 'fixed', value: 3 });
|
|
46
|
-
|
|
47
|
-
const order = await firstValueFrom(builder.order$);
|
|
48
|
-
const line = order.lineItems[0];
|
|
49
|
-
expect(line.discountAmount).toBe(3);
|
|
50
|
-
expect(line.lineTotal).toBeCloseTo(7.70); // (10 - 3) + 0.70 tax
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('caps fixed discount at line gross', async () => {
|
|
54
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
55
|
-
const lineId = builder.addProduct(product, traits);
|
|
56
|
-
builder.applyLineDiscount(lineId, { type: 'fixed', value: 50 });
|
|
57
|
-
|
|
58
|
-
const order = await firstValueFrom(builder.order$);
|
|
59
|
-
expect(order.lineItems[0].discountAmount).toBe(10);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('order-level discounts', () => {
|
|
64
|
-
it('applies a percentage discount to the order', async () => {
|
|
65
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
66
|
-
builder.addProduct(product, traits);
|
|
67
|
-
builder.applyOrderDiscount({ type: 'percentage', value: 20 });
|
|
68
|
-
|
|
69
|
-
const order = await firstValueFrom(builder.order$);
|
|
70
|
-
expect(order.discountTotal).toBeCloseTo(2.00);
|
|
71
|
-
expect(order.total).toBeCloseTo(9.00); // 11 - 2
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('applies a fixed discount to the order', async () => {
|
|
75
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
76
|
-
builder.addProduct(product, traits);
|
|
77
|
-
builder.applyOrderDiscount({ type: 'fixed', value: 5 });
|
|
78
|
-
|
|
79
|
-
const order = await firstValueFrom(builder.order$);
|
|
80
|
-
expect(order.discountTotal).toBeCloseTo(5.00);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe('discount recalculation on quantity change', () => {
|
|
85
|
-
it('percentage discount recalculates when quantity changes', async () => {
|
|
86
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
87
|
-
const lineId = builder.addProduct(product, traits); // $10
|
|
88
|
-
builder.applyLineDiscount(lineId, { type: 'percentage', value: 10 }); // 10% = $1
|
|
89
|
-
|
|
90
|
-
// Change quantity to 3 — discount should now be $3 (10% of $30)
|
|
91
|
-
builder.updateQuantity(lineId, 3);
|
|
92
|
-
|
|
93
|
-
const order = await firstValueFrom(builder.order$);
|
|
94
|
-
const line = order.lineItems[0];
|
|
95
|
-
expect(line.discountAmount).toBeCloseTo(3.00); // 10% of $30
|
|
96
|
-
expect(line.lineTotal).toBeCloseTo(29.70); // ($30 - $3) + $2.70 tax
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('fixed discount recalculates (caps) when quantity changes', async () => {
|
|
100
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
101
|
-
const lineId = builder.addProduct({ id: 'p1', name: 'Cheap', price: 2 }, traits);
|
|
102
|
-
builder.applyLineDiscount(lineId, { type: 'fixed', value: 5 }); // capped at $2
|
|
103
|
-
|
|
104
|
-
// Change quantity to 3 — gross now $6, fixed $5 is no longer capped
|
|
105
|
-
builder.updateQuantity(lineId, 3);
|
|
106
|
-
|
|
107
|
-
const order = await firstValueFrom(builder.order$);
|
|
108
|
-
expect(order.lineItems[0].discountAmount).toBe(5);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('stacks multiple discounts on the same line', async () => {
|
|
112
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
113
|
-
const lineId = builder.addProduct(product, traits); // $10
|
|
114
|
-
builder.applyLineDiscount(lineId, { type: 'percentage', value: 10, label: '10% off' });
|
|
115
|
-
builder.applyLineDiscount(lineId, { type: 'fixed', value: 2, label: '$2 off' });
|
|
116
|
-
|
|
117
|
-
const order = await firstValueFrom(builder.order$);
|
|
118
|
-
const line = order.lineItems[0];
|
|
119
|
-
expect(line.discounts).toHaveLength(2);
|
|
120
|
-
expect(line.discountAmount).toBeCloseTo(3.00); // $1 + $2
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe('removing discounts', () => {
|
|
125
|
-
it('removes a line discount by id', async () => {
|
|
126
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
127
|
-
const lineId = builder.addProduct(product, traits);
|
|
128
|
-
builder.applyLineDiscount(lineId, { type: 'fixed', value: 2 });
|
|
129
|
-
|
|
130
|
-
let order = await firstValueFrom(builder.order$);
|
|
131
|
-
const discountId = order.lineItems[0].discounts[0].id;
|
|
132
|
-
builder.removeDiscount(discountId);
|
|
133
|
-
|
|
134
|
-
order = await firstValueFrom(builder.order$);
|
|
135
|
-
expect(order.lineItems[0].discountAmount).toBe(0);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('removes an order discount by id', async () => {
|
|
139
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
140
|
-
builder.addProduct(product, traits);
|
|
141
|
-
builder.applyOrderDiscount({ type: 'fixed', value: 5 });
|
|
142
|
-
|
|
143
|
-
let order = await firstValueFrom(builder.order$);
|
|
144
|
-
const discountId = order.discounts[0].id;
|
|
145
|
-
builder.removeDiscount(discountId);
|
|
146
|
-
|
|
147
|
-
order = await firstValueFrom(builder.order$);
|
|
148
|
-
expect(order.discounts).toHaveLength(0);
|
|
149
|
-
expect(order.discountTotal).toBe(0);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
});
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { firstValueFrom } from 'rxjs';
|
|
3
|
-
import { createOrderBuilder } from './order-builder';
|
|
4
|
-
import type { ProductTraits } from '@tallyui/core';
|
|
5
|
-
import type { TaxContext } from '../tax/types';
|
|
6
|
-
|
|
7
|
-
const traits: ProductTraits = {
|
|
8
|
-
getId: (doc) => doc.id,
|
|
9
|
-
getName: (doc) => doc.name,
|
|
10
|
-
getSku: (doc) => doc.sku ?? '',
|
|
11
|
-
getPrice: (doc) => String(doc.price),
|
|
12
|
-
getRegularPrice: (doc) => String(doc.price),
|
|
13
|
-
getSalePrice: () => undefined,
|
|
14
|
-
isOnSale: () => false,
|
|
15
|
-
getImageUrl: (doc) => doc.imageUrl,
|
|
16
|
-
getImageUrls: (doc) => doc.imageUrl ? [doc.imageUrl] : [],
|
|
17
|
-
getDescription: () => undefined,
|
|
18
|
-
getStockStatus: () => 'instock',
|
|
19
|
-
getStockQuantity: () => null,
|
|
20
|
-
hasVariants: () => false,
|
|
21
|
-
getType: () => 'simple',
|
|
22
|
-
getBarcode: () => undefined,
|
|
23
|
-
getCategoryNames: () => [],
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const taxContext: TaxContext = {
|
|
27
|
-
getTaxRate: () => 0.1,
|
|
28
|
-
pricesIncludeTax: false,
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const productDoc = { id: 'p1', name: 'Espresso', sku: 'ESP-001', price: 4.50 };
|
|
32
|
-
const productDoc2 = { id: 'p2', name: 'Latte', sku: 'LAT-001', price: 5.00 };
|
|
33
|
-
|
|
34
|
-
describe('OrderBuilder', () => {
|
|
35
|
-
it('starts with an empty draft order', async () => {
|
|
36
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
37
|
-
const order = await firstValueFrom(builder.order$);
|
|
38
|
-
expect(order.status).toBe('draft');
|
|
39
|
-
expect(order.lineItems).toHaveLength(0);
|
|
40
|
-
expect(order.subtotal).toBe(0);
|
|
41
|
-
expect(order.total).toBe(0);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('adds a product as a line item', async () => {
|
|
45
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
46
|
-
builder.addProduct(productDoc, traits);
|
|
47
|
-
|
|
48
|
-
const order = await firstValueFrom(builder.order$);
|
|
49
|
-
expect(order.lineItems).toHaveLength(1);
|
|
50
|
-
expect(order.lineItems[0].name).toBe('Espresso');
|
|
51
|
-
expect(order.lineItems[0].price).toBe(4.50);
|
|
52
|
-
expect(order.lineItems[0].quantity).toBe(1);
|
|
53
|
-
expect(order.lineItems[0].taxRate).toBe(0.1);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('increments quantity when adding same product twice', async () => {
|
|
57
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
58
|
-
builder.addProduct(productDoc, traits);
|
|
59
|
-
builder.addProduct(productDoc, traits);
|
|
60
|
-
|
|
61
|
-
const order = await firstValueFrom(builder.order$);
|
|
62
|
-
expect(order.lineItems).toHaveLength(1);
|
|
63
|
-
expect(order.lineItems[0].quantity).toBe(2);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('treats different products as separate line items', async () => {
|
|
67
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
68
|
-
builder.addProduct(productDoc, traits);
|
|
69
|
-
builder.addProduct(productDoc2, traits);
|
|
70
|
-
|
|
71
|
-
const order = await firstValueFrom(builder.order$);
|
|
72
|
-
expect(order.lineItems).toHaveLength(2);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('calculates line totals with tax (exclusive)', async () => {
|
|
76
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
77
|
-
builder.addProduct(productDoc, traits);
|
|
78
|
-
|
|
79
|
-
const order = await firstValueFrom(builder.order$);
|
|
80
|
-
const line = order.lineItems[0];
|
|
81
|
-
expect(line.taxAmount).toBeCloseTo(0.45);
|
|
82
|
-
expect(line.lineTotal).toBeCloseTo(4.95);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('calculates order totals', async () => {
|
|
86
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
87
|
-
builder.addProduct(productDoc, traits);
|
|
88
|
-
builder.addProduct(productDoc2, traits);
|
|
89
|
-
|
|
90
|
-
const order = await firstValueFrom(builder.order$);
|
|
91
|
-
expect(order.subtotal).toBeCloseTo(9.50);
|
|
92
|
-
expect(order.taxTotal).toBeCloseTo(0.95);
|
|
93
|
-
expect(order.total).toBeCloseTo(10.45);
|
|
94
|
-
expect(order.balanceDue).toBeCloseTo(10.45);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('updates line item quantity', async () => {
|
|
98
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
99
|
-
const lineId = builder.addProduct(productDoc, traits);
|
|
100
|
-
builder.updateQuantity(lineId, 3);
|
|
101
|
-
|
|
102
|
-
const order = await firstValueFrom(builder.order$);
|
|
103
|
-
expect(order.lineItems[0].quantity).toBe(3);
|
|
104
|
-
expect(order.subtotal).toBeCloseTo(13.50);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('removes a line item', async () => {
|
|
108
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
109
|
-
const lineId = builder.addProduct(productDoc, traits);
|
|
110
|
-
builder.addProduct(productDoc2, traits);
|
|
111
|
-
builder.removeItem(lineId);
|
|
112
|
-
|
|
113
|
-
const order = await firstValueFrom(builder.order$);
|
|
114
|
-
expect(order.lineItems).toHaveLength(1);
|
|
115
|
-
expect(order.lineItems[0].name).toBe('Latte');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('sets and clears customer', async () => {
|
|
119
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
120
|
-
builder.setCustomer({ id: 'c1', name: 'Alice', email: 'alice@example.com' });
|
|
121
|
-
|
|
122
|
-
let order = await firstValueFrom(builder.order$);
|
|
123
|
-
expect(order.customer?.name).toBe('Alice');
|
|
124
|
-
|
|
125
|
-
builder.setCustomer(null);
|
|
126
|
-
order = await firstValueFrom(builder.order$);
|
|
127
|
-
expect(order.customer).toBeNull();
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('sets note', async () => {
|
|
131
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
132
|
-
builder.setNote('Extra hot');
|
|
133
|
-
|
|
134
|
-
const order = await firstValueFrom(builder.order$);
|
|
135
|
-
expect(order.note).toBe('Extra hot');
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('clears the order', async () => {
|
|
139
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
140
|
-
builder.addProduct(productDoc, traits);
|
|
141
|
-
builder.setNote('Test');
|
|
142
|
-
builder.clear();
|
|
143
|
-
|
|
144
|
-
const order = await firstValueFrom(builder.order$);
|
|
145
|
-
expect(order.lineItems).toHaveLength(0);
|
|
146
|
-
expect(order.note).toBe('');
|
|
147
|
-
expect(order.subtotal).toBe(0);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('handles tax-inclusive pricing', async () => {
|
|
151
|
-
const inclTax: TaxContext = { getTaxRate: () => 0.1, pricesIncludeTax: true };
|
|
152
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext: inclTax });
|
|
153
|
-
builder.addProduct(productDoc, traits);
|
|
154
|
-
|
|
155
|
-
const order = await firstValueFrom(builder.order$);
|
|
156
|
-
const line = order.lineItems[0];
|
|
157
|
-
expect(line.taxAmount).toBeCloseTo(0.41, 1);
|
|
158
|
-
expect(line.lineTotal).toBeCloseTo(4.50);
|
|
159
|
-
expect(order.subtotal).toBeCloseTo(4.09, 1);
|
|
160
|
-
expect(order.total).toBeCloseTo(4.50);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('adds product with specific quantity', async () => {
|
|
164
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
165
|
-
builder.addProduct(productDoc, traits, { quantity: 5 });
|
|
166
|
-
|
|
167
|
-
const order = await firstValueFrom(builder.order$);
|
|
168
|
-
expect(order.lineItems[0].quantity).toBe(5);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('removes line item when quantity set to 0', async () => {
|
|
172
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
173
|
-
const lineId = builder.addProduct(productDoc, traits);
|
|
174
|
-
builder.updateQuantity(lineId, 0);
|
|
175
|
-
|
|
176
|
-
const order = await firstValueFrom(builder.order$);
|
|
177
|
-
expect(order.lineItems).toHaveLength(0);
|
|
178
|
-
expect(order.subtotal).toBe(0);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('removes line item when quantity set to negative', async () => {
|
|
182
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
183
|
-
const lineId = builder.addProduct(productDoc, traits);
|
|
184
|
-
builder.updateQuantity(lineId, -1);
|
|
185
|
-
|
|
186
|
-
const order = await firstValueFrom(builder.order$);
|
|
187
|
-
expect(order.lineItems).toHaveLength(0);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('getSnapshot() matches order$ emission', async () => {
|
|
191
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
192
|
-
builder.addProduct(productDoc, traits);
|
|
193
|
-
|
|
194
|
-
const fromObservable = await firstValueFrom(builder.order$);
|
|
195
|
-
const fromSnapshot = builder.getSnapshot();
|
|
196
|
-
expect(fromSnapshot.id).toBe(fromObservable.id);
|
|
197
|
-
expect(fromSnapshot.lineItems).toEqual(fromObservable.lineItems);
|
|
198
|
-
expect(fromSnapshot.total).toBe(fromObservable.total);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('clear() resets payments, customer, and discounts', async () => {
|
|
202
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
203
|
-
const lineId = builder.addProduct(productDoc, traits);
|
|
204
|
-
builder.setCustomer({ id: 'c1', name: 'Alice' });
|
|
205
|
-
builder.addPayment({ method: 'cash', amount: 5 });
|
|
206
|
-
builder.applyLineDiscount(lineId, { type: 'fixed', value: 1 });
|
|
207
|
-
builder.applyOrderDiscount({ type: 'percentage', value: 10 });
|
|
208
|
-
builder.clear();
|
|
209
|
-
|
|
210
|
-
const order = await firstValueFrom(builder.order$);
|
|
211
|
-
expect(order.lineItems).toHaveLength(0);
|
|
212
|
-
expect(order.payments).toHaveLength(0);
|
|
213
|
-
expect(order.discounts).toHaveLength(0);
|
|
214
|
-
expect(order.customer).toBeNull();
|
|
215
|
-
expect(order.note).toBe('');
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('handles product with undefined price gracefully', async () => {
|
|
219
|
-
const noPriceTraits = { ...traits, getPrice: () => undefined };
|
|
220
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
221
|
-
builder.addProduct({ id: 'p1', name: 'Free Item' }, noPriceTraits);
|
|
222
|
-
|
|
223
|
-
const order = await firstValueFrom(builder.order$);
|
|
224
|
-
expect(order.lineItems[0].price).toBe(0);
|
|
225
|
-
expect(order.total).toBe(0);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('adds same product with different variantIds as separate lines', async () => {
|
|
229
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
230
|
-
builder.addProduct(productDoc, traits, { variantId: 'small' });
|
|
231
|
-
builder.addProduct(productDoc, traits, { variantId: 'large' });
|
|
232
|
-
|
|
233
|
-
const order = await firstValueFrom(builder.order$);
|
|
234
|
-
expect(order.lineItems).toHaveLength(2);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('merges same product+variant on second add', async () => {
|
|
238
|
-
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
239
|
-
builder.addProduct(productDoc, traits, { variantId: 'small' });
|
|
240
|
-
builder.addProduct(productDoc, traits, { variantId: 'small' });
|
|
241
|
-
|
|
242
|
-
const order = await firstValueFrom(builder.order$);
|
|
243
|
-
expect(order.lineItems).toHaveLength(1);
|
|
244
|
-
expect(order.lineItems[0].quantity).toBe(2);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { firstValueFrom } from 'rxjs';
|
|
3
|
-
import { createRxDatabase, addRxPlugin, type RxDatabase } from 'rxdb';
|
|
4
|
-
import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode';
|
|
5
|
-
import { getRxStorageMemory } from 'rxdb/plugins/storage-memory';
|
|
6
|
-
import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv';
|
|
7
|
-
import { createOrderManager } from './order-manager';
|
|
8
|
-
import type { ProductTraits } from '@tallyui/core';
|
|
9
|
-
import type { TaxContext } from '../tax/types';
|
|
10
|
-
|
|
11
|
-
addRxPlugin(RxDBDevModePlugin);
|
|
12
|
-
|
|
13
|
-
const storage = wrappedValidateAjvStorage({ storage: getRxStorageMemory() });
|
|
14
|
-
|
|
15
|
-
const traits: ProductTraits = {
|
|
16
|
-
getId: (doc) => doc.id,
|
|
17
|
-
getName: (doc) => doc.name,
|
|
18
|
-
getSku: () => '',
|
|
19
|
-
getPrice: (doc) => String(doc.price),
|
|
20
|
-
getRegularPrice: (doc) => String(doc.price),
|
|
21
|
-
getSalePrice: () => undefined,
|
|
22
|
-
isOnSale: () => false,
|
|
23
|
-
getImageUrl: () => undefined,
|
|
24
|
-
getImageUrls: () => [],
|
|
25
|
-
getDescription: () => undefined,
|
|
26
|
-
getStockStatus: () => 'instock',
|
|
27
|
-
getStockQuantity: () => null,
|
|
28
|
-
hasVariants: () => false,
|
|
29
|
-
getType: () => 'simple',
|
|
30
|
-
getBarcode: () => undefined,
|
|
31
|
-
getCategoryNames: () => [],
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const taxContext: TaxContext = { getTaxRate: () => 0, pricesIncludeTax: false };
|
|
35
|
-
const product = { id: 'p1', name: 'Coffee', price: 5 };
|
|
36
|
-
|
|
37
|
-
const draftsSchema = {
|
|
38
|
-
version: 0,
|
|
39
|
-
primaryKey: 'id',
|
|
40
|
-
type: 'object' as const,
|
|
41
|
-
properties: {
|
|
42
|
-
id: { type: 'string', maxLength: 100 },
|
|
43
|
-
data: { type: 'string' },
|
|
44
|
-
customerName: { type: 'string' },
|
|
45
|
-
itemCount: { type: 'integer', minimum: 0, maximum: 2147483647, multipleOf: 1 },
|
|
46
|
-
total: { type: 'number' },
|
|
47
|
-
parkedAt: { type: 'string' },
|
|
48
|
-
},
|
|
49
|
-
required: ['id', 'data'] as const,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
describe('OrderManager', () => {
|
|
53
|
-
let db: RxDatabase;
|
|
54
|
-
|
|
55
|
-
beforeEach(async () => {
|
|
56
|
-
db = await createRxDatabase({
|
|
57
|
-
name: `test_mgr_${Date.now()}`,
|
|
58
|
-
storage,
|
|
59
|
-
multiInstance: false,
|
|
60
|
-
ignoreDuplicate: true,
|
|
61
|
-
});
|
|
62
|
-
await db.addCollections({ pos_drafts: { schema: draftsSchema } });
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
afterEach(async () => {
|
|
66
|
-
await db?.close();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('starts with an active order', async () => {
|
|
70
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
71
|
-
const builder = await firstValueFrom(mgr.activeOrder$);
|
|
72
|
-
expect(builder).toBeDefined();
|
|
73
|
-
|
|
74
|
-
const order = await firstValueFrom(builder.order$);
|
|
75
|
-
expect(order.status).toBe('draft');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('creates a new order and switches to it', async () => {
|
|
79
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
80
|
-
|
|
81
|
-
const first = await firstValueFrom(mgr.activeOrder$);
|
|
82
|
-
const firstOrder = await firstValueFrom(first.order$);
|
|
83
|
-
|
|
84
|
-
mgr.newOrder();
|
|
85
|
-
const second = await firstValueFrom(mgr.activeOrder$);
|
|
86
|
-
const secondOrder = await firstValueFrom(second.order$);
|
|
87
|
-
|
|
88
|
-
expect(firstOrder.id).not.toBe(secondOrder.id);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('parks an order to local storage and lists it', async () => {
|
|
92
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
93
|
-
|
|
94
|
-
const builder = await firstValueFrom(mgr.activeOrder$);
|
|
95
|
-
builder.addProduct(product, traits);
|
|
96
|
-
builder.setCustomer({ id: 'c1', name: 'Alice' });
|
|
97
|
-
|
|
98
|
-
await mgr.parkCurrentOrder();
|
|
99
|
-
|
|
100
|
-
const parked = await firstValueFrom(mgr.parkedOrders$);
|
|
101
|
-
expect(parked).toHaveLength(1);
|
|
102
|
-
expect(parked[0].customerName).toBe('Alice');
|
|
103
|
-
expect(parked[0].itemCount).toBe(1);
|
|
104
|
-
expect(parked[0].source).toBe('local');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('resumes a parked order', async () => {
|
|
108
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
109
|
-
|
|
110
|
-
const builder = await firstValueFrom(mgr.activeOrder$);
|
|
111
|
-
builder.addProduct(product, traits);
|
|
112
|
-
const orderId = (await firstValueFrom(builder.order$)).id;
|
|
113
|
-
|
|
114
|
-
await mgr.parkCurrentOrder();
|
|
115
|
-
await mgr.resumeOrder(orderId);
|
|
116
|
-
|
|
117
|
-
const resumed = await firstValueFrom(mgr.activeOrder$);
|
|
118
|
-
const order = await firstValueFrom(resumed.order$);
|
|
119
|
-
expect(order.id).toBe(orderId);
|
|
120
|
-
expect(order.lineItems).toHaveLength(1);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('deletes a parked order', async () => {
|
|
124
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
125
|
-
|
|
126
|
-
const builder = await firstValueFrom(mgr.activeOrder$);
|
|
127
|
-
builder.addProduct(product, traits);
|
|
128
|
-
const orderId = (await firstValueFrom(builder.order$)).id;
|
|
129
|
-
|
|
130
|
-
await mgr.parkCurrentOrder();
|
|
131
|
-
await mgr.deleteParkedOrder(orderId);
|
|
132
|
-
|
|
133
|
-
const parked = await firstValueFrom(mgr.parkedOrders$);
|
|
134
|
-
expect(parked).toHaveLength(0);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('preserves line-level discounts through park/resume', async () => {
|
|
138
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
139
|
-
|
|
140
|
-
const builder = await firstValueFrom(mgr.activeOrder$);
|
|
141
|
-
const lineId = builder.addProduct(product, traits);
|
|
142
|
-
builder.applyLineDiscount(lineId, { type: 'percentage', value: 10, label: '10% off' });
|
|
143
|
-
const orderId = (await firstValueFrom(builder.order$)).id;
|
|
144
|
-
|
|
145
|
-
await mgr.parkCurrentOrder();
|
|
146
|
-
await mgr.resumeOrder(orderId);
|
|
147
|
-
|
|
148
|
-
const resumed = await firstValueFrom(mgr.activeOrder$);
|
|
149
|
-
const order = await firstValueFrom(resumed.order$);
|
|
150
|
-
expect(order.lineItems[0].discounts).toHaveLength(1);
|
|
151
|
-
expect(order.lineItems[0].discounts[0].label).toBe('10% off');
|
|
152
|
-
expect(order.lineItems[0].discountAmount).toBeCloseTo(0.50); // 10% of $5
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('preserves order-level discounts through park/resume', async () => {
|
|
156
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
157
|
-
|
|
158
|
-
const builder = await firstValueFrom(mgr.activeOrder$);
|
|
159
|
-
builder.addProduct(product, traits);
|
|
160
|
-
builder.applyOrderDiscount({ type: 'fixed', value: 1, label: 'Loyalty' });
|
|
161
|
-
const orderId = (await firstValueFrom(builder.order$)).id;
|
|
162
|
-
|
|
163
|
-
await mgr.parkCurrentOrder();
|
|
164
|
-
await mgr.resumeOrder(orderId);
|
|
165
|
-
|
|
166
|
-
const resumed = await firstValueFrom(mgr.activeOrder$);
|
|
167
|
-
const order = await firstValueFrom(resumed.order$);
|
|
168
|
-
expect(order.discounts).toHaveLength(1);
|
|
169
|
-
expect(order.discounts[0].label).toBe('Loyalty');
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('preserves payments through park/resume', async () => {
|
|
173
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
174
|
-
|
|
175
|
-
const builder = await firstValueFrom(mgr.activeOrder$);
|
|
176
|
-
builder.addProduct(product, traits);
|
|
177
|
-
builder.addPayment({ method: 'cash', amount: 3 });
|
|
178
|
-
const orderId = (await firstValueFrom(builder.order$)).id;
|
|
179
|
-
|
|
180
|
-
await mgr.parkCurrentOrder();
|
|
181
|
-
await mgr.resumeOrder(orderId);
|
|
182
|
-
|
|
183
|
-
const resumed = await firstValueFrom(mgr.activeOrder$);
|
|
184
|
-
const order = await firstValueFrom(resumed.order$);
|
|
185
|
-
expect(order.payments).toHaveLength(1);
|
|
186
|
-
expect(order.payments[0].method).toBe('cash');
|
|
187
|
-
expect(order.payments[0].amount).toBe(3);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('throws when resuming non-existent order', async () => {
|
|
191
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
192
|
-
await expect(mgr.resumeOrder('nonexistent')).rejects.toThrow('Parked order nonexistent not found');
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('parking creates a fresh active order', async () => {
|
|
196
|
-
const mgr = createOrderManager({ currency: 'USD', taxContext, draftsCollection: db.pos_drafts });
|
|
197
|
-
|
|
198
|
-
const builder = await firstValueFrom(mgr.activeOrder$);
|
|
199
|
-
builder.addProduct(product, traits);
|
|
200
|
-
const oldId = (await firstValueFrom(builder.order$)).id;
|
|
201
|
-
|
|
202
|
-
await mgr.parkCurrentOrder();
|
|
203
|
-
|
|
204
|
-
const newBuilder = await firstValueFrom(mgr.activeOrder$);
|
|
205
|
-
const newOrder = await firstValueFrom(newBuilder.order$);
|
|
206
|
-
expect(newOrder.id).not.toBe(oldId);
|
|
207
|
-
expect(newOrder.lineItems).toHaveLength(0);
|
|
208
|
-
});
|
|
209
|
-
});
|