@tallyui/pos 0.1.0 → 3.0.0-next.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 +2031 -43
- package/dist/index.js +4629 -205
- package/package.json +23 -12
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +53 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +8 -0
- package/src/order/order-builder.ts +256 -95
- package/src/order/order-manager.ts +26 -43
- package/src/order/tax-figures.ts +23 -0
- package/src/order/types.ts +73 -15
- package/src/outbox/backend-not-found.ts +27 -0
- package/src/outbox/http-transport.ts +67 -0
- package/src/outbox/index.ts +9 -0
- package/src/outbox/logger.ts +3 -0
- package/src/outbox/order-outbox.ts +502 -0
- package/src/outbox/register-outbox.ts +250 -0
- package/src/outbox/types.test-d.ts +22 -0
- package/src/outbox/types.ts +36 -0
- package/src/outbox/use-order-outbox.ts +175 -0
- package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
- package/src/pos-order/command.ts +123 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +219 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +16 -0
- package/src/pos-order/open.ts +269 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +130 -0
- package/src/pos-order/types.ts +93 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +32 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +39 -23
- package/src/receipt/types.ts +17 -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 +277 -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 +32 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -0
- package/src/register/register-commands.ts +147 -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 +268 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +570 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +469 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +23 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +394 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +18 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +84 -0
- package/src/tax/exact.ts +218 -0
- package/src/tax/index.ts +4 -3
- package/src/tax/tax-provider.tsx +35 -12
- package/src/tax/types.ts +8 -6
- 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,36 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { renderHook } from '@testing-library/react';
|
|
3
|
-
import type { ReactNode } from 'react';
|
|
4
|
-
import { CurrencyProvider, useCurrencyFormatter } from './currency-provider';
|
|
5
|
-
|
|
6
|
-
function wrapper(currency: string, locale?: string) {
|
|
7
|
-
return function Wrapper({ children }: { children: ReactNode }) {
|
|
8
|
-
return (
|
|
9
|
-
<CurrencyProvider currencyCode={currency} locale={locale}>
|
|
10
|
-
{children}
|
|
11
|
-
</CurrencyProvider>
|
|
12
|
-
);
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe('CurrencyProvider + useCurrencyFormatter', () => {
|
|
17
|
-
it('formats using provided currency and locale', () => {
|
|
18
|
-
const { result } = renderHook(() => useCurrencyFormatter(), {
|
|
19
|
-
wrapper: wrapper('USD', 'en-US'),
|
|
20
|
-
});
|
|
21
|
-
expect(result.current(10.5)).toBe('$10.50');
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('formats EUR with German locale', () => {
|
|
25
|
-
const { result } = renderHook(() => useCurrencyFormatter(), {
|
|
26
|
-
wrapper: wrapper('EUR', 'de-DE'),
|
|
27
|
-
});
|
|
28
|
-
expect(result.current(10.5).replace(/\s/g, ' ')).toBe('10,50 €');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('throws when used outside provider', () => {
|
|
32
|
-
expect(() => {
|
|
33
|
-
renderHook(() => useCurrencyFormatter());
|
|
34
|
-
}).toThrow('useCurrencyFormatter() must be used within a <CurrencyProvider>');
|
|
35
|
-
});
|
|
36
|
-
});
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { formatCurrency } from './format-currency';
|
|
3
|
-
|
|
4
|
-
describe('formatCurrency', () => {
|
|
5
|
-
it('formats USD with default locale', () => {
|
|
6
|
-
const result = formatCurrency(10.5, 'USD', 'en-US');
|
|
7
|
-
expect(result).toBe('$10.50');
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it('formats EUR with German locale', () => {
|
|
11
|
-
const result = formatCurrency(10.5, 'EUR', 'de-DE');
|
|
12
|
-
expect(result.replace(/\s/g, ' ')).toBe('10,50 €');
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('formats JPY (zero decimals)', () => {
|
|
16
|
-
const result = formatCurrency(1000, 'JPY', 'ja-JP');
|
|
17
|
-
expect(result).toContain('1,000');
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('formats zero', () => {
|
|
21
|
-
const result = formatCurrency(0, 'USD', 'en-US');
|
|
22
|
-
expect(result).toBe('$0.00');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('formats negative numbers', () => {
|
|
26
|
-
const result = formatCurrency(-5.99, 'USD', 'en-US');
|
|
27
|
-
expect(result).toContain('5.99');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('memoizes formatter for same currency+locale pair', () => {
|
|
31
|
-
const a = formatCurrency(1, 'USD', 'en-US');
|
|
32
|
-
const b = formatCurrency(2, 'USD', 'en-US');
|
|
33
|
-
expect(a).toBe('$1.00');
|
|
34
|
-
expect(b).toBe('$2.00');
|
|
35
|
-
});
|
|
36
|
-
});
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
const formatters = new Map<string, Intl.NumberFormat>();
|
|
2
|
-
|
|
3
|
-
function getFormatter(currencyCode: string, locale?: string): Intl.NumberFormat {
|
|
4
|
-
const key = `${currencyCode}:${locale ?? 'default'}`;
|
|
5
|
-
let formatter = formatters.get(key);
|
|
6
|
-
if (!formatter) {
|
|
7
|
-
formatter = new Intl.NumberFormat(locale, {
|
|
8
|
-
style: 'currency',
|
|
9
|
-
currency: currencyCode,
|
|
10
|
-
});
|
|
11
|
-
formatters.set(key, formatter);
|
|
12
|
-
}
|
|
13
|
-
return formatter;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function formatCurrency(amount: number, currencyCode: string, locale?: string): string {
|
|
17
|
-
return getFormatter(currencyCode, locale).format(amount);
|
|
18
|
-
}
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { createLogger } from './logger';
|
|
3
|
-
import type { LogSink, LogEntry } from './types';
|
|
4
|
-
|
|
5
|
-
function createTestSink(overrides?: Partial<LogSink>): LogSink & { entries: LogEntry[] } {
|
|
6
|
-
const entries: LogEntry[] = [];
|
|
7
|
-
return {
|
|
8
|
-
id: 'test',
|
|
9
|
-
levels: ['debug', 'info', 'warn', 'error'],
|
|
10
|
-
write: (entry) => entries.push(entry),
|
|
11
|
-
entries,
|
|
12
|
-
...overrides,
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe('createLogger', () => {
|
|
17
|
-
it('creates a logger with default root scope', () => {
|
|
18
|
-
const logger = createLogger();
|
|
19
|
-
expect(logger).toBeDefined();
|
|
20
|
-
expect(typeof logger.debug).toBe('function');
|
|
21
|
-
expect(typeof logger.info).toBe('function');
|
|
22
|
-
expect(typeof logger.warn).toBe('function');
|
|
23
|
-
expect(typeof logger.error).toBe('function');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('writes log entries to registered sinks', () => {
|
|
27
|
-
const logger = createLogger();
|
|
28
|
-
const sink = createTestSink();
|
|
29
|
-
logger.addSink(sink);
|
|
30
|
-
|
|
31
|
-
logger.info('hello world');
|
|
32
|
-
|
|
33
|
-
expect(sink.entries).toHaveLength(1);
|
|
34
|
-
expect(sink.entries[0].level).toBe('info');
|
|
35
|
-
expect(sink.entries[0].message).toBe('hello world');
|
|
36
|
-
expect(sink.entries[0].scope).toBe('root');
|
|
37
|
-
expect(sink.entries[0].timestamp).toBeGreaterThan(0);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('includes data in log entries', () => {
|
|
41
|
-
const logger = createLogger();
|
|
42
|
-
const sink = createTestSink();
|
|
43
|
-
logger.addSink(sink);
|
|
44
|
-
|
|
45
|
-
logger.error('failed', { code: 500 });
|
|
46
|
-
|
|
47
|
-
expect(sink.entries[0].data).toEqual({ code: 500 });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('only writes to sinks that match the log level', () => {
|
|
51
|
-
const logger = createLogger();
|
|
52
|
-
const errorOnly = createTestSink({ id: 'errors', levels: ['error'] });
|
|
53
|
-
logger.addSink(errorOnly);
|
|
54
|
-
|
|
55
|
-
logger.debug('debug msg');
|
|
56
|
-
logger.info('info msg');
|
|
57
|
-
logger.warn('warn msg');
|
|
58
|
-
logger.error('error msg');
|
|
59
|
-
|
|
60
|
-
expect(errorOnly.entries).toHaveLength(1);
|
|
61
|
-
expect(errorOnly.entries[0].level).toBe('error');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('removes sinks by id', () => {
|
|
65
|
-
const logger = createLogger();
|
|
66
|
-
const sink = createTestSink();
|
|
67
|
-
logger.addSink(sink);
|
|
68
|
-
logger.info('before');
|
|
69
|
-
logger.removeSink('test');
|
|
70
|
-
logger.info('after');
|
|
71
|
-
|
|
72
|
-
expect(sink.entries).toHaveLength(1);
|
|
73
|
-
expect(sink.entries[0].message).toBe('before');
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe('scoped loggers', () => {
|
|
78
|
-
it('creates a child logger with a scoped name', () => {
|
|
79
|
-
const logger = createLogger();
|
|
80
|
-
const sink = createTestSink();
|
|
81
|
-
logger.addSink(sink);
|
|
82
|
-
|
|
83
|
-
const child = logger.createScope('order-builder');
|
|
84
|
-
child.info('order created');
|
|
85
|
-
|
|
86
|
-
expect(sink.entries[0].scope).toBe('order-builder');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('child loggers share sinks with parent', () => {
|
|
90
|
-
const logger = createLogger();
|
|
91
|
-
const sink = createTestSink();
|
|
92
|
-
logger.addSink(sink);
|
|
93
|
-
|
|
94
|
-
const child = logger.createScope('tax');
|
|
95
|
-
child.warn('rate missing');
|
|
96
|
-
logger.info('root message');
|
|
97
|
-
|
|
98
|
-
expect(sink.entries).toHaveLength(2);
|
|
99
|
-
expect(sink.entries[0].scope).toBe('tax');
|
|
100
|
-
expect(sink.entries[1].scope).toBe('root');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('sinks can filter by scope', () => {
|
|
104
|
-
const logger = createLogger();
|
|
105
|
-
const taxOnly = createTestSink({ id: 'tax-sink', scopes: ['tax'] });
|
|
106
|
-
logger.addSink(taxOnly);
|
|
107
|
-
|
|
108
|
-
const taxLog = logger.createScope('tax');
|
|
109
|
-
const orderLog = logger.createScope('order');
|
|
110
|
-
|
|
111
|
-
taxLog.info('tax message');
|
|
112
|
-
orderLog.info('order message');
|
|
113
|
-
|
|
114
|
-
expect(taxOnly.entries).toHaveLength(1);
|
|
115
|
-
expect(taxOnly.entries[0].scope).toBe('tax');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('sinks added to parent are visible to existing children', () => {
|
|
119
|
-
const logger = createLogger();
|
|
120
|
-
const child = logger.createScope('child');
|
|
121
|
-
|
|
122
|
-
const sink = createTestSink();
|
|
123
|
-
logger.addSink(sink);
|
|
124
|
-
|
|
125
|
-
child.info('after add');
|
|
126
|
-
expect(sink.entries).toHaveLength(1);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('sinks removed from parent are removed from children', () => {
|
|
130
|
-
const logger = createLogger();
|
|
131
|
-
const sink = createTestSink();
|
|
132
|
-
logger.addSink(sink);
|
|
133
|
-
|
|
134
|
-
const child = logger.createScope('child');
|
|
135
|
-
child.info('before');
|
|
136
|
-
logger.removeSink('test');
|
|
137
|
-
child.info('after');
|
|
138
|
-
|
|
139
|
-
expect(sink.entries).toHaveLength(1);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('nested scopes (grandchild) share sinks with root', () => {
|
|
143
|
-
const logger = createLogger();
|
|
144
|
-
const sink = createTestSink();
|
|
145
|
-
logger.addSink(sink);
|
|
146
|
-
|
|
147
|
-
const child = logger.createScope('parent');
|
|
148
|
-
const grandchild = child.createScope('grandchild');
|
|
149
|
-
|
|
150
|
-
grandchild.info('deep message');
|
|
151
|
-
expect(sink.entries).toHaveLength(1);
|
|
152
|
-
expect(sink.entries[0].scope).toBe('grandchild');
|
|
153
|
-
});
|
|
154
|
-
});
|
|
@@ -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
|
-
});
|