@tallyui/pos 0.1.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.
Files changed (37) hide show
  1. package/dist/index.d.ts +262 -0
  2. package/dist/index.js +632 -0
  3. package/package.json +39 -0
  4. package/src/currency/currency-provider.test.tsx +36 -0
  5. package/src/currency/currency-provider.tsx +32 -0
  6. package/src/currency/format-currency.test.ts +36 -0
  7. package/src/currency/format-currency.ts +18 -0
  8. package/src/currency/index.ts +3 -0
  9. package/src/index.ts +34 -0
  10. package/src/logging/index.ts +3 -0
  11. package/src/logging/logger.test.ts +154 -0
  12. package/src/logging/logger.ts +67 -0
  13. package/src/logging/sinks.test.ts +60 -0
  14. package/src/logging/sinks.ts +46 -0
  15. package/src/logging/types.ts +26 -0
  16. package/src/order/discount-engine.test.ts +152 -0
  17. package/src/order/index.ts +13 -0
  18. package/src/order/order-builder.test.ts +246 -0
  19. package/src/order/order-builder.ts +273 -0
  20. package/src/order/order-manager.test.ts +209 -0
  21. package/src/order/order-manager.ts +175 -0
  22. package/src/order/payment.test.ts +91 -0
  23. package/src/order/types.ts +66 -0
  24. package/src/receipt/build-receipt-data.test.ts +166 -0
  25. package/src/receipt/build-receipt-data.ts +56 -0
  26. package/src/receipt/index.ts +2 -0
  27. package/src/receipt/types.ts +42 -0
  28. package/src/repository/create-repository.test.ts +166 -0
  29. package/src/repository/create-repository.ts +74 -0
  30. package/src/repository/index.ts +2 -0
  31. package/src/repository/types.ts +15 -0
  32. package/src/tax/calculate.test.ts +59 -0
  33. package/src/tax/calculate.ts +32 -0
  34. package/src/tax/index.ts +4 -0
  35. package/src/tax/tax-provider.test.tsx +51 -0
  36. package/src/tax/tax-provider.tsx +36 -0
  37. package/src/tax/types.ts +15 -0
@@ -0,0 +1,32 @@
1
+ import { createContext, useContext, useMemo, useCallback } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { formatCurrency } from './format-currency';
4
+
5
+ interface CurrencyContext {
6
+ currencyCode: string;
7
+ locale?: string;
8
+ }
9
+
10
+ const CurrencyCtx = createContext<CurrencyContext | null>(null);
11
+
12
+ export interface CurrencyProviderProps {
13
+ currencyCode: string;
14
+ locale?: string;
15
+ children: ReactNode;
16
+ }
17
+
18
+ export function CurrencyProvider({ currencyCode, locale, children }: CurrencyProviderProps) {
19
+ const value = useMemo(() => ({ currencyCode, locale }), [currencyCode, locale]);
20
+ return <CurrencyCtx.Provider value={value}>{children}</CurrencyCtx.Provider>;
21
+ }
22
+
23
+ export function useCurrencyFormatter(): (amount: number) => string {
24
+ const ctx = useContext(CurrencyCtx);
25
+ if (!ctx) {
26
+ throw new Error('useCurrencyFormatter() must be used within a <CurrencyProvider>');
27
+ }
28
+ return useCallback(
29
+ (amount: number) => formatCurrency(amount, ctx.currencyCode, ctx.locale),
30
+ [ctx.currencyCode, ctx.locale],
31
+ );
32
+ }
@@ -0,0 +1,36 @@
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
+ });
@@ -0,0 +1,18 @@
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
+ }
@@ -0,0 +1,3 @@
1
+ export { formatCurrency } from './format-currency';
2
+ export { CurrencyProvider, useCurrencyFormatter } from './currency-provider';
3
+ export type { CurrencyProviderProps } from './currency-provider';
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ // Logging
2
+ export { createLogger, consoleSink, callbackSink } from './logging';
3
+ export type { Logger, LogSink, LogEntry, LogLevel } from './logging';
4
+
5
+ // Currency
6
+ export { formatCurrency, CurrencyProvider, useCurrencyFormatter } from './currency';
7
+ export type { CurrencyProviderProps } from './currency';
8
+
9
+ // Tax
10
+ export { calculateTax, extractTax, addTax, TaxProvider, useTax } from './tax';
11
+ export type { TaxProviderProps } from './tax';
12
+ export type { TaxResult, TaxRateMap, TaxContext } from './tax';
13
+
14
+ // Repository
15
+ export { createRepository } from './repository';
16
+ export type { Repository } from './repository';
17
+
18
+ // Order
19
+ export { createOrderBuilder, createOrderManager } from './order';
20
+ export type { OrderBuilder, OrderBuilderOptions } from './order';
21
+ export type { OrderManager, OrderManagerOptions, ParkedOrderSummary } from './order';
22
+ export type {
23
+ Order,
24
+ LineItem,
25
+ Discount,
26
+ AppliedDiscount,
27
+ Payment,
28
+ CustomerSummary,
29
+ PaymentMethod,
30
+ } from './order';
31
+
32
+ // Receipt
33
+ export { buildReceiptData } from './receipt';
34
+ export type { ReceiptData, ReceiptLineItem, ReceiptConfig } from './receipt';
@@ -0,0 +1,3 @@
1
+ export { createLogger } from './logger';
2
+ export { consoleSink, callbackSink } from './sinks';
3
+ export type { Logger, LogSink, LogEntry, LogLevel } from './types';
@@ -0,0 +1,154 @@
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
+ });
@@ -0,0 +1,67 @@
1
+ import type { Logger, LogEntry, LogLevel, LogSink } from './types';
2
+
3
+ export function createLogger(scope = 'root'): Logger {
4
+ const sinks: Map<string, LogSink> = new Map();
5
+
6
+ function log(level: LogLevel, message: string, data?: Record<string, unknown>) {
7
+ const entry: LogEntry = {
8
+ level,
9
+ scope,
10
+ message,
11
+ data,
12
+ timestamp: Date.now(),
13
+ };
14
+
15
+ for (const sink of sinks.values()) {
16
+ if (!sink.levels.includes(level)) continue;
17
+ if (sink.scopes && !sink.scopes.includes(scope)) continue;
18
+ sink.write(entry);
19
+ }
20
+ }
21
+
22
+ return {
23
+ debug: (message, data) => log('debug', message, data),
24
+ info: (message, data) => log('info', message, data),
25
+ warn: (message, data) => log('warn', message, data),
26
+ error: (message, data) => log('error', message, data),
27
+
28
+ createScope(name: string): Logger {
29
+ return createChildLogger(sinks, name);
30
+ },
31
+
32
+ addSink(sink: LogSink) {
33
+ sinks.set(sink.id, sink);
34
+ },
35
+
36
+ removeSink(sinkId: string) {
37
+ sinks.delete(sinkId);
38
+ },
39
+ };
40
+ }
41
+
42
+ function logWithSinks(
43
+ sinks: Map<string, LogSink>,
44
+ level: LogLevel,
45
+ scope: string,
46
+ message: string,
47
+ data?: Record<string, unknown>,
48
+ ) {
49
+ const entry: LogEntry = { level, scope, message, data, timestamp: Date.now() };
50
+ for (const sink of sinks.values()) {
51
+ if (!sink.levels.includes(level)) continue;
52
+ if (sink.scopes && !sink.scopes.includes(scope)) continue;
53
+ sink.write(entry);
54
+ }
55
+ }
56
+
57
+ function createChildLogger(sinks: Map<string, LogSink>, scope: string): Logger {
58
+ return {
59
+ debug: (message, data) => logWithSinks(sinks, 'debug', scope, message, data),
60
+ info: (message, data) => logWithSinks(sinks, 'info', scope, message, data),
61
+ warn: (message, data) => logWithSinks(sinks, 'warn', scope, message, data),
62
+ error: (message, data) => logWithSinks(sinks, 'error', scope, message, data),
63
+ createScope: (childName: string) => createChildLogger(sinks, childName),
64
+ addSink: (sink: LogSink) => sinks.set(sink.id, sink),
65
+ removeSink: (sinkId: string) => sinks.delete(sinkId),
66
+ };
67
+ }
@@ -0,0 +1,60 @@
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
+ });
@@ -0,0 +1,46 @@
1
+ import type { LogEntry, LogLevel, LogSink } from './types';
2
+
3
+ export interface ConsoleSinkOptions {
4
+ id?: string;
5
+ levels: LogLevel[];
6
+ scopes?: string[];
7
+ }
8
+
9
+ export function consoleSink(options: ConsoleSinkOptions): LogSink {
10
+ return {
11
+ id: options.id ?? 'console',
12
+ levels: options.levels,
13
+ scopes: options.scopes,
14
+ write(entry: LogEntry) {
15
+ const prefix = `[${entry.scope}]`;
16
+ const args = entry.data ? [prefix, entry.message, entry.data] : [prefix, entry.message];
17
+
18
+ switch (entry.level) {
19
+ case 'error':
20
+ console.error(...args);
21
+ break;
22
+ case 'warn':
23
+ console.warn(...args);
24
+ break;
25
+ default:
26
+ console.log(...args);
27
+ }
28
+ },
29
+ };
30
+ }
31
+
32
+ export interface CallbackSinkOptions {
33
+ id: string;
34
+ levels: LogLevel[];
35
+ scopes?: string[];
36
+ callback: (entry: LogEntry) => void;
37
+ }
38
+
39
+ export function callbackSink(options: CallbackSinkOptions): LogSink {
40
+ return {
41
+ id: options.id,
42
+ levels: options.levels,
43
+ scopes: options.scopes,
44
+ write: options.callback,
45
+ };
46
+ }
@@ -0,0 +1,26 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+
3
+ export interface LogEntry {
4
+ level: LogLevel;
5
+ scope: string;
6
+ message: string;
7
+ data?: Record<string, unknown>;
8
+ timestamp: number;
9
+ }
10
+
11
+ export interface LogSink {
12
+ id: string;
13
+ levels: LogLevel[];
14
+ scopes?: string[];
15
+ write(entry: LogEntry): void;
16
+ }
17
+
18
+ export interface Logger {
19
+ debug(message: string, data?: Record<string, unknown>): void;
20
+ info(message: string, data?: Record<string, unknown>): void;
21
+ warn(message: string, data?: Record<string, unknown>): void;
22
+ error(message: string, data?: Record<string, unknown>): void;
23
+ createScope(name: string): Logger;
24
+ addSink(sink: LogSink): void;
25
+ removeSink(sinkId: string): void;
26
+ }
@@ -0,0 +1,152 @@
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
+ });
@@ -0,0 +1,13 @@
1
+ export { createOrderBuilder } from './order-builder';
2
+ export type { OrderBuilder, OrderBuilderOptions } from './order-builder';
3
+ export { createOrderManager } from './order-manager';
4
+ export type { OrderManager, OrderManagerOptions, ParkedOrderSummary } from './order-manager';
5
+ export type {
6
+ Order,
7
+ LineItem,
8
+ Discount,
9
+ AppliedDiscount,
10
+ Payment,
11
+ CustomerSummary,
12
+ PaymentMethod,
13
+ } from './types';