@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,56 @@
1
+ import type { Order } from '../order/types';
2
+ import type { ReceiptConfig, ReceiptData } from './types';
3
+
4
+ export function buildReceiptData(order: Order, config: ReceiptConfig): ReceiptData {
5
+ const taxByRate = new Map<number, number>();
6
+ for (const line of order.lineItems) {
7
+ const existing = taxByRate.get(line.taxRate) ?? 0;
8
+ taxByRate.set(line.taxRate, existing + line.taxAmount);
9
+ }
10
+
11
+ const taxLines = Array.from(taxByRate.entries()).map(([rate, amount]) => ({
12
+ label: config.taxLabels?.[rate] ?? `Tax ${Math.round(rate * 100)}%`,
13
+ rate,
14
+ amount: Math.round(amount * 100) / 100,
15
+ }));
16
+
17
+ return {
18
+ header: {
19
+ storeName: config.storeName,
20
+ storeAddress: config.storeAddress,
21
+ orderNumber: order.id,
22
+ date: order.createdAt,
23
+ cashier: config.cashier,
24
+ register: config.register,
25
+ },
26
+ lineItems: order.lineItems.map((li) => ({
27
+ name: li.name,
28
+ sku: li.sku,
29
+ quantity: li.quantity,
30
+ unitPrice: li.price,
31
+ lineTotal: li.lineTotal,
32
+ })),
33
+ discounts: order.discounts.map((d) => ({
34
+ label: d.label ?? d.couponCode ?? `${d.type} discount`,
35
+ amount: d.amount,
36
+ })),
37
+ totals: {
38
+ subtotal: order.subtotal,
39
+ discountTotal: order.discountTotal,
40
+ taxLines,
41
+ taxTotal: order.taxTotal,
42
+ total: order.total,
43
+ },
44
+ payments: order.payments.map((p) => ({
45
+ method: p.method,
46
+ amount: p.amount,
47
+ reference: p.reference,
48
+ })),
49
+ changeDue: order.changeDue,
50
+ footer: {
51
+ note: order.note || undefined,
52
+ barcode: order.id,
53
+ },
54
+ currency: order.currency,
55
+ };
56
+ }
@@ -0,0 +1,2 @@
1
+ export { buildReceiptData } from './build-receipt-data';
2
+ export type { ReceiptData, ReceiptLineItem, ReceiptConfig } from './types';
@@ -0,0 +1,42 @@
1
+ export interface ReceiptLineItem {
2
+ name: string;
3
+ sku: string;
4
+ quantity: number;
5
+ unitPrice: number;
6
+ lineTotal: number;
7
+ }
8
+
9
+ export interface ReceiptData {
10
+ header: {
11
+ storeName: string;
12
+ storeAddress?: string;
13
+ orderNumber: string;
14
+ date: string;
15
+ cashier?: string;
16
+ register?: string;
17
+ };
18
+ lineItems: ReceiptLineItem[];
19
+ discounts: { label: string; amount: number }[];
20
+ totals: {
21
+ subtotal: number;
22
+ discountTotal: number;
23
+ taxLines: { label: string; rate: number; amount: number }[];
24
+ taxTotal: number;
25
+ total: number;
26
+ };
27
+ payments: { method: string; amount: number; reference?: string }[];
28
+ changeDue: number;
29
+ footer: {
30
+ note?: string;
31
+ barcode?: string;
32
+ };
33
+ currency: string;
34
+ }
35
+
36
+ export interface ReceiptConfig {
37
+ storeName: string;
38
+ storeAddress?: string;
39
+ cashier?: string;
40
+ register?: string;
41
+ taxLabels?: Record<number, string>;
42
+ }
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { createRxDatabase, addRxPlugin, type RxDatabase } from 'rxdb';
3
+ import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode';
4
+ import { getRxStorageMemory } from 'rxdb/plugins/storage-memory';
5
+ import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv';
6
+ import { firstValueFrom } from 'rxjs';
7
+ import { createRepository } from './create-repository';
8
+
9
+ addRxPlugin(RxDBDevModePlugin);
10
+
11
+ const storage = wrappedValidateAjvStorage({ storage: getRxStorageMemory() });
12
+
13
+ const testSchema = {
14
+ version: 0,
15
+ primaryKey: 'id',
16
+ type: 'object' as const,
17
+ properties: {
18
+ id: { type: 'string', maxLength: 100 },
19
+ name: { type: 'string' },
20
+ category: { type: 'string' },
21
+ },
22
+ required: ['id', 'name'] as const,
23
+ };
24
+
25
+ type TestDoc = { id: string; name: string; category?: string };
26
+
27
+ describe('createRepository', () => {
28
+ let db: RxDatabase;
29
+
30
+ beforeEach(async () => {
31
+ db = await createRxDatabase({
32
+ name: `test_repo_${Date.now()}`,
33
+ storage,
34
+ multiInstance: false,
35
+ ignoreDuplicate: true,
36
+ });
37
+ await db.addCollections({ items: { schema: testSchema } });
38
+ });
39
+
40
+ afterEach(async () => {
41
+ await db?.close();
42
+ });
43
+
44
+ it('creates a document and retrieves it by id', async () => {
45
+ const repo = createRepository<TestDoc>(db.items);
46
+ const doc = await repo.create({ id: '1', name: 'Widget' });
47
+ expect(doc.name).toBe('Widget');
48
+
49
+ const found = await firstValueFrom(repo.findById$('1'));
50
+ expect(found).not.toBeNull();
51
+ expect(found!.name).toBe('Widget');
52
+ });
53
+
54
+ it('returns null for non-existent id', async () => {
55
+ const repo = createRepository<TestDoc>(db.items);
56
+ const found = await firstValueFrom(repo.findById$('nonexistent'));
57
+ expect(found).toBeNull();
58
+ });
59
+
60
+ it('finds all documents', async () => {
61
+ const repo = createRepository<TestDoc>(db.items);
62
+ await repo.create({ id: '1', name: 'A' });
63
+ await repo.create({ id: '2', name: 'B' });
64
+
65
+ const all = await firstValueFrom(repo.findAll$());
66
+ expect(all).toHaveLength(2);
67
+ });
68
+
69
+ it('finds documents with a mango query', async () => {
70
+ const repo = createRepository<TestDoc>(db.items);
71
+ await repo.create({ id: '1', name: 'A', category: 'x' });
72
+ await repo.create({ id: '2', name: 'B', category: 'y' });
73
+
74
+ const filtered = await firstValueFrom(
75
+ repo.findAll$({ selector: { category: 'x' } }),
76
+ );
77
+ expect(filtered).toHaveLength(1);
78
+ expect(filtered[0].name).toBe('A');
79
+ });
80
+
81
+ it('updates a document', async () => {
82
+ const repo = createRepository<TestDoc>(db.items);
83
+ await repo.create({ id: '1', name: 'Old' });
84
+ const updated = await repo.update('1', { name: 'New' });
85
+ expect(updated.name).toBe('New');
86
+ });
87
+
88
+ it('removes a document', async () => {
89
+ const repo = createRepository<TestDoc>(db.items);
90
+ await repo.create({ id: '1', name: 'A' });
91
+ await repo.remove('1');
92
+
93
+ const all = await firstValueFrom(repo.findAll$());
94
+ expect(all).toHaveLength(0);
95
+ });
96
+
97
+ it('bulk creates documents', async () => {
98
+ const repo = createRepository<TestDoc>(db.items);
99
+ const docs = await repo.bulkCreate([
100
+ { id: '1', name: 'A' },
101
+ { id: '2', name: 'B' },
102
+ { id: '3', name: 'C' },
103
+ ]);
104
+ expect(docs).toHaveLength(3);
105
+
106
+ const all = await firstValueFrom(repo.findAll$());
107
+ expect(all).toHaveLength(3);
108
+ });
109
+
110
+ it('bulk removes documents', async () => {
111
+ const repo = createRepository<TestDoc>(db.items);
112
+ await repo.bulkCreate([
113
+ { id: '1', name: 'A' },
114
+ { id: '2', name: 'B' },
115
+ { id: '3', name: 'C' },
116
+ ]);
117
+ await repo.bulkRemove(['1', '3']);
118
+
119
+ const all = await firstValueFrom(repo.findAll$());
120
+ expect(all).toHaveLength(1);
121
+ expect(all[0].id).toBe('2');
122
+ });
123
+
124
+ it('count$ returns document count', async () => {
125
+ const repo = createRepository<TestDoc>(db.items);
126
+ const count0 = await firstValueFrom(repo.count$());
127
+ expect(count0).toBe(0);
128
+
129
+ await repo.create({ id: '1', name: 'A' });
130
+ await repo.create({ id: '2', name: 'B' });
131
+
132
+ const count2 = await firstValueFrom(repo.count$());
133
+ expect(count2).toBe(2);
134
+ });
135
+
136
+ it('search$ filters by text across specified fields', async () => {
137
+ const repo = createRepository<TestDoc>(db.items);
138
+ await repo.create({ id: '1', name: 'Coffee Beans' });
139
+ await repo.create({ id: '2', name: 'Tea Leaves' });
140
+ await repo.create({ id: '3', name: 'Coffee Grinder' });
141
+
142
+ const results = await firstValueFrom(repo.search$('coffee', ['name']));
143
+ expect(results).toHaveLength(2);
144
+ });
145
+
146
+ it('search$ handles regex special characters in search term', async () => {
147
+ const repo = createRepository<TestDoc>(db.items);
148
+ await repo.create({ id: '1', name: 'Coffee (Organic)' });
149
+ await repo.create({ id: '2', name: 'Tea' });
150
+
151
+ // This should not throw — the parentheses are escaped
152
+ const results = await firstValueFrom(repo.search$('(Organic)', ['name']));
153
+ expect(results).toHaveLength(1);
154
+ expect(results[0].name).toBe('Coffee (Organic)');
155
+ });
156
+
157
+ it('throws when updating a non-existent document', async () => {
158
+ const repo = createRepository<TestDoc>(db.items);
159
+ await expect(repo.update('nonexistent', { name: 'New' })).rejects.toThrow('Document nonexistent not found');
160
+ });
161
+
162
+ it('throws when removing a non-existent document', async () => {
163
+ const repo = createRepository<TestDoc>(db.items);
164
+ await expect(repo.remove('nonexistent')).rejects.toThrow('Document nonexistent not found');
165
+ });
166
+ });
@@ -0,0 +1,74 @@
1
+ import { map } from 'rxjs';
2
+ import type { Observable } from 'rxjs';
3
+ import type { RxCollection, MangoQuery } from 'rxdb';
4
+ import type { Repository } from './types';
5
+
6
+ export function createRepository<T extends { id: string }>(
7
+ collection: RxCollection<T>,
8
+ ): Repository<T> {
9
+ return {
10
+ findById$(id: string): Observable<T | null> {
11
+ return collection.findOne(id).$.pipe(
12
+ map((doc) => (doc ? (doc.toJSON() as T) : null)),
13
+ );
14
+ },
15
+
16
+ findAll$(query?: MangoQuery<T>): Observable<T[]> {
17
+ const q = query ? collection.find(query) : collection.find();
18
+ return q.$.pipe(
19
+ map((docs) => docs.map((d) => d.toJSON() as T)),
20
+ );
21
+ },
22
+
23
+ search$(term: string, fields: (keyof T)[]): Observable<T[]> {
24
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+ const regex = new RegExp(escaped, 'i');
26
+ return collection.find().$.pipe(
27
+ map((docs) =>
28
+ docs
29
+ .filter((doc) => {
30
+ const json = doc.toJSON() as Record<string, unknown>;
31
+ return fields.some((field) => {
32
+ const val = json[field as string];
33
+ return typeof val === 'string' && regex.test(val);
34
+ });
35
+ })
36
+ .map((d) => d.toJSON() as T),
37
+ ),
38
+ );
39
+ },
40
+
41
+ count$(): Observable<number> {
42
+ return collection.count().$.pipe(
43
+ map((count) => count),
44
+ );
45
+ },
46
+
47
+ async create(data: Partial<T>): Promise<T> {
48
+ const doc = await collection.insert(data as T);
49
+ return doc.toJSON() as T;
50
+ },
51
+
52
+ async update(id: string, patch: Partial<T>): Promise<T> {
53
+ const doc = await collection.findOne(id).exec();
54
+ if (!doc) throw new Error(`Document ${id} not found`);
55
+ const updated = await doc.incrementalPatch(patch);
56
+ return updated.toJSON() as T;
57
+ },
58
+
59
+ async remove(id: string): Promise<void> {
60
+ const doc = await collection.findOne(id).exec();
61
+ if (!doc) throw new Error(`Document ${id} not found`);
62
+ await doc.remove();
63
+ },
64
+
65
+ async bulkCreate(items: Partial<T>[]): Promise<T[]> {
66
+ const result = await collection.bulkInsert(items as T[]);
67
+ return result.success.map((d) => d.toJSON() as T);
68
+ },
69
+
70
+ async bulkRemove(ids: string[]): Promise<void> {
71
+ await collection.bulkRemove(ids);
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,2 @@
1
+ export { createRepository } from './create-repository';
2
+ export type { Repository } from './types';
@@ -0,0 +1,15 @@
1
+ import type { Observable } from 'rxjs';
2
+ import type { MangoQuery } from 'rxdb';
3
+
4
+ export interface Repository<T> {
5
+ findById$(id: string): Observable<T | null>;
6
+ findAll$(query?: MangoQuery<T>): Observable<T[]>;
7
+ search$(term: string, fields: (keyof T)[]): Observable<T[]>;
8
+ count$(): Observable<number>;
9
+
10
+ create(data: Partial<T>): Promise<T>;
11
+ update(id: string, patch: Partial<T>): Promise<T>;
12
+ remove(id: string): Promise<void>;
13
+ bulkCreate(items: Partial<T>[]): Promise<T[]>;
14
+ bulkRemove(ids: string[]): Promise<void>;
15
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { calculateTax, extractTax, addTax } from './calculate';
3
+
4
+ describe('addTax', () => {
5
+ it('adds 10% tax to $10.00', () => {
6
+ expect(addTax(10, 0.1)).toBeCloseTo(11);
7
+ });
8
+
9
+ it('adds 0% tax', () => {
10
+ expect(addTax(10, 0)).toBe(10);
11
+ });
12
+
13
+ it('adds 20% tax to $25.00', () => {
14
+ expect(addTax(25, 0.2)).toBeCloseTo(30);
15
+ });
16
+ });
17
+
18
+ describe('extractTax', () => {
19
+ it('extracts 10% tax from $11.00 inclusive', () => {
20
+ expect(extractTax(11, 0.1)).toBeCloseTo(1);
21
+ });
22
+
23
+ it('extracts 0% tax', () => {
24
+ expect(extractTax(10, 0)).toBe(0);
25
+ });
26
+
27
+ it('extracts 20% tax from $30.00 inclusive', () => {
28
+ expect(extractTax(30, 0.2)).toBeCloseTo(5);
29
+ });
30
+ });
31
+
32
+ describe('calculateTax', () => {
33
+ it('calculates from tax-exclusive price', () => {
34
+ const result = calculateTax(10, 0.1, false);
35
+ expect(result.priceExclTax).toBeCloseTo(10);
36
+ expect(result.taxAmount).toBeCloseTo(1);
37
+ expect(result.priceInclTax).toBeCloseTo(11);
38
+ });
39
+
40
+ it('calculates from tax-inclusive price', () => {
41
+ const result = calculateTax(11, 0.1, true);
42
+ expect(result.priceInclTax).toBeCloseTo(11);
43
+ expect(result.taxAmount).toBeCloseTo(1);
44
+ expect(result.priceExclTax).toBeCloseTo(10);
45
+ });
46
+
47
+ it('handles zero rate', () => {
48
+ const result = calculateTax(10, 0, false);
49
+ expect(result.priceExclTax).toBe(10);
50
+ expect(result.taxAmount).toBe(0);
51
+ expect(result.priceInclTax).toBe(10);
52
+ });
53
+
54
+ it('rounds to 2 decimal places', () => {
55
+ const result = calculateTax(10.33, 0.075, false);
56
+ expect(result.taxAmount).toBeCloseTo(0.77, 2);
57
+ expect(result.priceInclTax).toBeCloseTo(11.10, 2);
58
+ });
59
+ });
@@ -0,0 +1,32 @@
1
+ import type { TaxResult } from './types';
2
+
3
+ function round2(n: number): number {
4
+ return Math.round(n * 100) / 100;
5
+ }
6
+
7
+ export function addTax(priceExclTax: number, rate: number): number {
8
+ return round2(priceExclTax * (1 + rate));
9
+ }
10
+
11
+ export function extractTax(priceInclTax: number, rate: number): number {
12
+ if (rate === 0) return 0;
13
+ return round2(priceInclTax - priceInclTax / (1 + rate));
14
+ }
15
+
16
+ export function calculateTax(price: number, rate: number, pricesIncludeTax: boolean): TaxResult {
17
+ if (pricesIncludeTax) {
18
+ const taxAmount = extractTax(price, rate);
19
+ return {
20
+ priceInclTax: round2(price),
21
+ priceExclTax: round2(price - taxAmount),
22
+ taxAmount,
23
+ };
24
+ }
25
+
26
+ const taxAmount = round2(price * rate);
27
+ return {
28
+ priceExclTax: round2(price),
29
+ priceInclTax: round2(price + taxAmount),
30
+ taxAmount,
31
+ };
32
+ }
@@ -0,0 +1,4 @@
1
+ export { calculateTax, extractTax, addTax } from './calculate';
2
+ export { TaxProvider, useTax } from './tax-provider';
3
+ export type { TaxProviderProps } from './tax-provider';
4
+ export type { TaxResult, TaxRateMap, TaxContext } from './types';
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { TaxProvider, useTax } from './tax-provider';
5
+
6
+ function wrapper(rates: Record<string, number>, pricesIncludeTax = false) {
7
+ return function Wrapper({ children }: { children: ReactNode }) {
8
+ return (
9
+ <TaxProvider rates={rates} pricesIncludeTax={pricesIncludeTax}>
10
+ {children}
11
+ </TaxProvider>
12
+ );
13
+ };
14
+ }
15
+
16
+ describe('TaxProvider + useTax', () => {
17
+ it('provides default tax rate', () => {
18
+ const { result } = renderHook(() => useTax(), {
19
+ wrapper: wrapper({ default: 0.1 }),
20
+ });
21
+ expect(result.current.getTaxRate()).toBe(0.1);
22
+ });
23
+
24
+ it('provides tax rate by class', () => {
25
+ const { result } = renderHook(() => useTax(), {
26
+ wrapper: wrapper({ default: 0.2, reduced: 0.05, zero: 0 }),
27
+ });
28
+ expect(result.current.getTaxRate('reduced')).toBe(0.05);
29
+ expect(result.current.getTaxRate('zero')).toBe(0);
30
+ });
31
+
32
+ it('falls back to default for unknown tax class', () => {
33
+ const { result } = renderHook(() => useTax(), {
34
+ wrapper: wrapper({ default: 0.1 }),
35
+ });
36
+ expect(result.current.getTaxRate('nonexistent')).toBe(0.1);
37
+ });
38
+
39
+ it('provides pricesIncludeTax flag', () => {
40
+ const { result } = renderHook(() => useTax(), {
41
+ wrapper: wrapper({ default: 0.1 }, true),
42
+ });
43
+ expect(result.current.pricesIncludeTax).toBe(true);
44
+ });
45
+
46
+ it('throws when used outside provider', () => {
47
+ expect(() => {
48
+ renderHook(() => useTax());
49
+ }).toThrow('useTax() must be used within a <TaxProvider>');
50
+ });
51
+ });
@@ -0,0 +1,36 @@
1
+ import { createContext, useContext, useMemo } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import type { TaxContext } from './types';
4
+
5
+ const TaxCtx = createContext<TaxContext | null>(null);
6
+
7
+ export interface TaxProviderProps {
8
+ rates: Record<string, number>;
9
+ pricesIncludeTax: boolean;
10
+ children: ReactNode;
11
+ }
12
+
13
+ export function TaxProvider({ rates, pricesIncludeTax, children }: TaxProviderProps) {
14
+ const value = useMemo<TaxContext>(
15
+ () => ({
16
+ getTaxRate(taxClass?: string) {
17
+ if (taxClass && taxClass in rates) {
18
+ return rates[taxClass];
19
+ }
20
+ return rates.default ?? 0;
21
+ },
22
+ pricesIncludeTax,
23
+ }),
24
+ [rates, pricesIncludeTax],
25
+ );
26
+
27
+ return <TaxCtx.Provider value={value}>{children}</TaxCtx.Provider>;
28
+ }
29
+
30
+ export function useTax(): TaxContext {
31
+ const ctx = useContext(TaxCtx);
32
+ if (!ctx) {
33
+ throw new Error('useTax() must be used within a <TaxProvider>');
34
+ }
35
+ return ctx;
36
+ }
@@ -0,0 +1,15 @@
1
+ export interface TaxResult {
2
+ priceExclTax: number;
3
+ priceInclTax: number;
4
+ taxAmount: number;
5
+ }
6
+
7
+ export type TaxRateMap = {
8
+ default: number;
9
+ [taxClass: string]: number;
10
+ };
11
+
12
+ export interface TaxContext {
13
+ getTaxRate(taxClass?: string): number;
14
+ pricesIncludeTax: boolean;
15
+ }