@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,246 @@
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
+ });
@@ -0,0 +1,273 @@
1
+ import { BehaviorSubject, type Observable } from 'rxjs';
2
+ import type { ProductTraits } from '@tallyui/core';
3
+ import type { TaxContext } from '../tax/types';
4
+ import { calculateTax } from '../tax/calculate';
5
+ import type {
6
+ Order,
7
+ LineItem,
8
+ Discount,
9
+ AppliedDiscount,
10
+ Payment,
11
+ CustomerSummary,
12
+ } from './types';
13
+
14
+ let nextId = 0;
15
+ function uid(): string {
16
+ return `${Date.now()}-${++nextId}`;
17
+ }
18
+
19
+ export interface OrderBuilderOptions {
20
+ currency: string;
21
+ taxContext: TaxContext;
22
+ id?: string;
23
+ }
24
+
25
+ export interface OrderBuilder {
26
+ order$: Observable<Order>;
27
+ addProduct(doc: any, traits: ProductTraits, options?: { variantId?: string; quantity?: number }): string;
28
+ updateQuantity(lineId: string, quantity: number): void;
29
+ removeItem(lineId: string): void;
30
+ applyLineDiscount(lineId: string, discount: Discount): void;
31
+ applyOrderDiscount(discount: Discount): void;
32
+ removeDiscount(discountId: string): void;
33
+ addPayment(payment: Omit<Payment, 'id'>): string;
34
+ removePayment(paymentId: string): void;
35
+ setCustomer(customer: CustomerSummary | null): void;
36
+ setNote(note: string): void;
37
+ clear(): void;
38
+ getSnapshot(): Order;
39
+ }
40
+
41
+ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
42
+ const { currency, taxContext } = options;
43
+ const orderId = options.id ?? uid();
44
+
45
+ let lineItems: LineItem[] = [];
46
+ let orderDiscounts: AppliedDiscount[] = [];
47
+ let payments: Payment[] = [];
48
+ let customer: CustomerSummary | null = null;
49
+ let note = '';
50
+ const now = new Date().toISOString();
51
+
52
+ const subject = new BehaviorSubject<Order>(buildOrder());
53
+
54
+ function recalculateLine(line: LineItem): LineItem {
55
+ const gross = line.price * line.quantity;
56
+
57
+ // Recompute discount amounts from current gross
58
+ const recalcedDiscounts = line.discounts.map((d) => ({
59
+ ...d,
60
+ amount: computeDiscountAmount(d, gross),
61
+ }));
62
+
63
+ const discountAmount = recalcedDiscounts.reduce((sum, d) => sum + d.amount, 0);
64
+ const afterDiscount = gross - discountAmount;
65
+ const tax = calculateTax(afterDiscount, line.taxRate, taxContext.pricesIncludeTax);
66
+
67
+ return {
68
+ ...line,
69
+ discounts: recalcedDiscounts,
70
+ discountAmount,
71
+ taxAmount: tax.taxAmount,
72
+ lineTotal: taxContext.pricesIncludeTax ? afterDiscount : afterDiscount + tax.taxAmount,
73
+ };
74
+ }
75
+
76
+ function computeDiscountAmount(discount: Discount, base: number): number {
77
+ if (discount.type === 'percentage') {
78
+ return Math.round((base * discount.value) / 100 * 100) / 100;
79
+ }
80
+ return Math.min(discount.value, base);
81
+ }
82
+
83
+ function buildOrder(): Order {
84
+ const subtotalLines = lineItems.reduce((sum, li) => {
85
+ const lineGross = li.price * li.quantity - li.discountAmount;
86
+ if (taxContext.pricesIncludeTax) {
87
+ const tax = calculateTax(lineGross, li.taxRate, true);
88
+ return sum + tax.priceExclTax;
89
+ }
90
+ return sum + lineGross;
91
+ }, 0);
92
+
93
+ const subtotal = Math.round(subtotalLines * 100) / 100;
94
+
95
+ const recalcedOrderDiscounts = orderDiscounts.map((d) => ({
96
+ ...d,
97
+ amount: computeDiscountAmount(d, subtotal),
98
+ }));
99
+
100
+ const discountTotal =
101
+ lineItems.reduce((sum, li) => sum + li.discountAmount, 0) +
102
+ recalcedOrderDiscounts.reduce((sum, d) => sum + d.amount, 0);
103
+
104
+ const taxTotal = lineItems.reduce((sum, li) => sum + li.taxAmount, 0);
105
+
106
+ const total = lineItems.reduce((sum, li) => sum + li.lineTotal, 0) -
107
+ recalcedOrderDiscounts.reduce((sum, d) => sum + d.amount, 0);
108
+
109
+ const roundedTotal = Math.round(total * 100) / 100;
110
+ const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0);
111
+ const balanceDue = Math.max(0, Math.round((roundedTotal - paidAmount) * 100) / 100);
112
+ const changeDue = Math.max(0, Math.round((paidAmount - roundedTotal) * 100) / 100);
113
+
114
+ return {
115
+ id: orderId,
116
+ status: 'draft',
117
+ lineItems: [...lineItems],
118
+ discounts: recalcedOrderDiscounts,
119
+ payments: [...payments],
120
+ customer,
121
+ note,
122
+ subtotal,
123
+ discountTotal: Math.round(discountTotal * 100) / 100,
124
+ taxTotal: Math.round(taxTotal * 100) / 100,
125
+ total: roundedTotal,
126
+ balanceDue,
127
+ changeDue,
128
+ currency,
129
+ createdAt: now,
130
+ updatedAt: new Date().toISOString(),
131
+ };
132
+ }
133
+
134
+ function emit() {
135
+ subject.next(buildOrder());
136
+ }
137
+
138
+ return {
139
+ order$: subject.asObservable(),
140
+
141
+ addProduct(doc, traits, opts) {
142
+ const productId = traits.getId(doc);
143
+ const variantId = opts?.variantId;
144
+ const quantity = opts?.quantity ?? 1;
145
+
146
+ const existing = lineItems.find(
147
+ (li) => li.productId === productId && li.variantId === variantId,
148
+ );
149
+
150
+ if (existing) {
151
+ existing.quantity += quantity;
152
+ lineItems = lineItems.map((li) =>
153
+ li.id === existing.id ? recalculateLine(existing) : li,
154
+ );
155
+ emit();
156
+ return existing.id;
157
+ }
158
+
159
+ const priceStr = traits.getPrice(doc);
160
+ const price = priceStr ? parseFloat(priceStr) : 0;
161
+ const taxRate = taxContext.getTaxRate();
162
+
163
+ const lineId = uid();
164
+ const line: LineItem = {
165
+ id: lineId,
166
+ productId,
167
+ variantId,
168
+ name: traits.getName(doc),
169
+ sku: traits.getSku(doc) ?? '',
170
+ imageUrl: traits.getImageUrl(doc),
171
+ price,
172
+ quantity,
173
+ taxRate,
174
+ taxAmount: 0,
175
+ discounts: [],
176
+ discountAmount: 0,
177
+ lineTotal: 0,
178
+ };
179
+
180
+ lineItems = [...lineItems, recalculateLine(line)];
181
+ emit();
182
+ return lineId;
183
+ },
184
+
185
+ updateQuantity(lineId, quantity) {
186
+ if (quantity <= 0) {
187
+ // Treat zero/negative as removal
188
+ lineItems = lineItems.filter((li) => li.id !== lineId);
189
+ } else {
190
+ lineItems = lineItems.map((li) =>
191
+ li.id === lineId ? recalculateLine({ ...li, quantity }) : li,
192
+ );
193
+ }
194
+ emit();
195
+ },
196
+
197
+ removeItem(lineId) {
198
+ lineItems = lineItems.filter((li) => li.id !== lineId);
199
+ emit();
200
+ },
201
+
202
+ applyLineDiscount(lineId, discount) {
203
+ lineItems = lineItems.map((li) => {
204
+ if (li.id !== lineId) return li;
205
+ const base = li.price * li.quantity;
206
+ const applied: AppliedDiscount = {
207
+ ...discount,
208
+ id: uid(),
209
+ amount: computeDiscountAmount(discount, base),
210
+ };
211
+ return recalculateLine({ ...li, discounts: [...li.discounts, applied] });
212
+ });
213
+ emit();
214
+ },
215
+
216
+ applyOrderDiscount(discount) {
217
+ const applied: AppliedDiscount = {
218
+ ...discount,
219
+ id: uid(),
220
+ amount: 0,
221
+ };
222
+ orderDiscounts = [...orderDiscounts, applied];
223
+ emit();
224
+ },
225
+
226
+ removeDiscount(discountId) {
227
+ orderDiscounts = orderDiscounts.filter((d) => d.id !== discountId);
228
+ lineItems = lineItems.map((li) => {
229
+ const filtered = li.discounts.filter((d) => d.id !== discountId);
230
+ if (filtered.length !== li.discounts.length) {
231
+ return recalculateLine({ ...li, discounts: filtered });
232
+ }
233
+ return li;
234
+ });
235
+ emit();
236
+ },
237
+
238
+ addPayment(payment) {
239
+ const id = uid();
240
+ payments = [...payments, { ...payment, id }];
241
+ emit();
242
+ return id;
243
+ },
244
+
245
+ removePayment(paymentId) {
246
+ payments = payments.filter((p) => p.id !== paymentId);
247
+ emit();
248
+ },
249
+
250
+ setCustomer(c) {
251
+ customer = c;
252
+ emit();
253
+ },
254
+
255
+ setNote(n) {
256
+ note = n;
257
+ emit();
258
+ },
259
+
260
+ clear() {
261
+ lineItems = [];
262
+ orderDiscounts = [];
263
+ payments = [];
264
+ customer = null;
265
+ note = '';
266
+ emit();
267
+ },
268
+
269
+ getSnapshot() {
270
+ return subject.getValue();
271
+ },
272
+ };
273
+ }