@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.
- package/dist/index.d.ts +262 -0
- package/dist/index.js +632 -0
- package/package.json +39 -0
- package/src/currency/currency-provider.test.tsx +36 -0
- package/src/currency/currency-provider.tsx +32 -0
- package/src/currency/format-currency.test.ts +36 -0
- package/src/currency/format-currency.ts +18 -0
- package/src/currency/index.ts +3 -0
- package/src/index.ts +34 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/logger.test.ts +154 -0
- package/src/logging/logger.ts +67 -0
- package/src/logging/sinks.test.ts +60 -0
- package/src/logging/sinks.ts +46 -0
- package/src/logging/types.ts +26 -0
- package/src/order/discount-engine.test.ts +152 -0
- package/src/order/index.ts +13 -0
- package/src/order/order-builder.test.ts +246 -0
- package/src/order/order-builder.ts +273 -0
- package/src/order/order-manager.test.ts +209 -0
- package/src/order/order-manager.ts +175 -0
- package/src/order/payment.test.ts +91 -0
- package/src/order/types.ts +66 -0
- package/src/receipt/build-receipt-data.test.ts +166 -0
- package/src/receipt/build-receipt-data.ts +56 -0
- package/src/receipt/index.ts +2 -0
- package/src/receipt/types.ts +42 -0
- package/src/repository/create-repository.test.ts +166 -0
- package/src/repository/create-repository.ts +74 -0
- package/src/repository/index.ts +2 -0
- package/src/repository/types.ts +15 -0
- package/src/tax/calculate.test.ts +59 -0
- package/src/tax/calculate.ts +32 -0
- package/src/tax/index.ts +4 -0
- package/src/tax/tax-provider.test.tsx +51 -0
- package/src/tax/tax-provider.tsx +36 -0
- package/src/tax/types.ts +15 -0
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { BehaviorSubject, map, type Observable } from 'rxjs';
|
|
2
|
+
import type { RxCollection } from 'rxdb';
|
|
3
|
+
import type { TaxContext } from '../tax/types';
|
|
4
|
+
import { createOrderBuilder, type OrderBuilder } from './order-builder';
|
|
5
|
+
import type { Order } from './types';
|
|
6
|
+
|
|
7
|
+
export interface ParkedOrderSummary {
|
|
8
|
+
id: string;
|
|
9
|
+
customerName?: string;
|
|
10
|
+
itemCount: number;
|
|
11
|
+
total: number;
|
|
12
|
+
parkedAt: string;
|
|
13
|
+
source: 'local' | 'server';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OrderManagerOptions {
|
|
17
|
+
currency: string;
|
|
18
|
+
taxContext: TaxContext;
|
|
19
|
+
draftsCollection: RxCollection;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OrderManager {
|
|
23
|
+
activeOrder$: Observable<OrderBuilder>;
|
|
24
|
+
parkedOrders$: Observable<ParkedOrderSummary[]>;
|
|
25
|
+
newOrder(): OrderBuilder;
|
|
26
|
+
parkCurrentOrder(): Promise<string>;
|
|
27
|
+
resumeOrder(orderId: string): Promise<OrderBuilder>;
|
|
28
|
+
deleteParkedOrder(orderId: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createOrderManager(options: OrderManagerOptions): OrderManager {
|
|
32
|
+
const { currency, taxContext, draftsCollection } = options;
|
|
33
|
+
|
|
34
|
+
let activeBuilder = createOrderBuilder({ currency, taxContext });
|
|
35
|
+
const activeSubject = new BehaviorSubject<OrderBuilder>(activeBuilder);
|
|
36
|
+
|
|
37
|
+
const parkedOrders$: Observable<ParkedOrderSummary[]> = draftsCollection.find().$.pipe(
|
|
38
|
+
map((docs) =>
|
|
39
|
+
docs.map((doc) => {
|
|
40
|
+
const json = doc.toJSON() as any;
|
|
41
|
+
return {
|
|
42
|
+
id: json.id,
|
|
43
|
+
customerName: json.customerName || undefined,
|
|
44
|
+
itemCount: json.itemCount ?? 0,
|
|
45
|
+
total: json.total ?? 0,
|
|
46
|
+
parkedAt: json.parkedAt ?? '',
|
|
47
|
+
source: 'local' as const,
|
|
48
|
+
};
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
activeOrder$: activeSubject.asObservable(),
|
|
55
|
+
parkedOrders$,
|
|
56
|
+
|
|
57
|
+
newOrder() {
|
|
58
|
+
activeBuilder = createOrderBuilder({ currency, taxContext });
|
|
59
|
+
activeSubject.next(activeBuilder);
|
|
60
|
+
return activeBuilder;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async parkCurrentOrder() {
|
|
64
|
+
const snapshot = activeBuilder.getSnapshot();
|
|
65
|
+
await draftsCollection.upsert({
|
|
66
|
+
id: snapshot.id,
|
|
67
|
+
data: JSON.stringify(snapshot),
|
|
68
|
+
customerName: snapshot.customer?.name ?? '',
|
|
69
|
+
itemCount: snapshot.lineItems.length,
|
|
70
|
+
total: snapshot.total,
|
|
71
|
+
parkedAt: new Date().toISOString(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
activeBuilder = createOrderBuilder({ currency, taxContext });
|
|
75
|
+
activeSubject.next(activeBuilder);
|
|
76
|
+
return snapshot.id;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async resumeOrder(orderId) {
|
|
80
|
+
const doc = await draftsCollection.findOne(orderId).exec();
|
|
81
|
+
if (!doc) throw new Error(`Parked order ${orderId} not found`);
|
|
82
|
+
|
|
83
|
+
const json = doc.toJSON() as any;
|
|
84
|
+
const savedOrder: Order = JSON.parse(json.data);
|
|
85
|
+
|
|
86
|
+
const builder = createOrderBuilder({
|
|
87
|
+
currency,
|
|
88
|
+
taxContext,
|
|
89
|
+
id: savedOrder.id,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Restore state from saved order
|
|
93
|
+
if (savedOrder.customer) {
|
|
94
|
+
builder.setCustomer(savedOrder.customer);
|
|
95
|
+
}
|
|
96
|
+
if (savedOrder.note) {
|
|
97
|
+
builder.setNote(savedOrder.note);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Restore line items using synthetic traits
|
|
101
|
+
for (const line of savedOrder.lineItems) {
|
|
102
|
+
const syntheticTraits = {
|
|
103
|
+
getId: (d: any) => d.productId,
|
|
104
|
+
getName: (d: any) => d.name,
|
|
105
|
+
getSku: (d: any) => d.sku,
|
|
106
|
+
getPrice: (d: any) => String(d.price),
|
|
107
|
+
getRegularPrice: (d: any) => String(d.price),
|
|
108
|
+
getSalePrice: () => undefined,
|
|
109
|
+
isOnSale: () => false,
|
|
110
|
+
getImageUrl: (d: any) => d.imageUrl,
|
|
111
|
+
getImageUrls: (d: any) => (d.imageUrl ? [d.imageUrl] : []),
|
|
112
|
+
getDescription: () => undefined,
|
|
113
|
+
getStockStatus: () => 'instock' as const,
|
|
114
|
+
getStockQuantity: () => null,
|
|
115
|
+
hasVariants: () => false,
|
|
116
|
+
getType: () => 'simple',
|
|
117
|
+
getBarcode: () => undefined,
|
|
118
|
+
getCategoryNames: () => [],
|
|
119
|
+
};
|
|
120
|
+
builder.addProduct(line, syntheticTraits, {
|
|
121
|
+
variantId: line.variantId,
|
|
122
|
+
quantity: line.quantity,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Restore line-level discounts
|
|
127
|
+
const resumedOrder = builder.getSnapshot();
|
|
128
|
+
for (const savedLine of savedOrder.lineItems) {
|
|
129
|
+
const newLine = resumedOrder.lineItems.find(
|
|
130
|
+
(li) => li.productId === savedLine.productId && li.variantId === savedLine.variantId,
|
|
131
|
+
);
|
|
132
|
+
if (newLine) {
|
|
133
|
+
for (const discount of savedLine.discounts) {
|
|
134
|
+
builder.applyLineDiscount(newLine.id, {
|
|
135
|
+
type: discount.type,
|
|
136
|
+
value: discount.value,
|
|
137
|
+
label: discount.label,
|
|
138
|
+
couponCode: discount.couponCode,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Restore order-level discounts
|
|
145
|
+
for (const discount of savedOrder.discounts) {
|
|
146
|
+
builder.applyOrderDiscount({
|
|
147
|
+
type: discount.type,
|
|
148
|
+
value: discount.value,
|
|
149
|
+
label: discount.label,
|
|
150
|
+
couponCode: discount.couponCode,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Restore payments
|
|
155
|
+
for (const payment of savedOrder.payments) {
|
|
156
|
+
builder.addPayment({
|
|
157
|
+
method: payment.method,
|
|
158
|
+
amount: payment.amount,
|
|
159
|
+
reference: payment.reference,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await doc.remove();
|
|
164
|
+
|
|
165
|
+
activeBuilder = builder;
|
|
166
|
+
activeSubject.next(activeBuilder);
|
|
167
|
+
return builder;
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async deleteParkedOrder(orderId) {
|
|
171
|
+
const doc = await draftsCollection.findOne(orderId).exec();
|
|
172
|
+
if (doc) await doc.remove();
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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: () => '',
|
|
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, pricesIncludeTax: false };
|
|
27
|
+
|
|
28
|
+
describe('payment splitting', () => {
|
|
29
|
+
it('tracks a single payment', async () => {
|
|
30
|
+
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
31
|
+
builder.addProduct({ id: 'p1', name: 'Item', price: 20 }, traits);
|
|
32
|
+
builder.addPayment({ method: 'cash', amount: 20 });
|
|
33
|
+
|
|
34
|
+
const order = await firstValueFrom(builder.order$);
|
|
35
|
+
expect(order.payments).toHaveLength(1);
|
|
36
|
+
expect(order.balanceDue).toBe(0);
|
|
37
|
+
expect(order.changeDue).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('splits payment across two methods', async () => {
|
|
41
|
+
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
42
|
+
builder.addProduct({ id: 'p1', name: 'Item', price: 35.50 }, traits);
|
|
43
|
+
builder.addPayment({ method: 'cash', amount: 20 });
|
|
44
|
+
builder.addPayment({ method: 'card', amount: 15.50 });
|
|
45
|
+
|
|
46
|
+
const order = await firstValueFrom(builder.order$);
|
|
47
|
+
expect(order.payments).toHaveLength(2);
|
|
48
|
+
expect(order.balanceDue).toBe(0);
|
|
49
|
+
expect(order.changeDue).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('calculates balance due when partially paid', async () => {
|
|
53
|
+
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
54
|
+
builder.addProduct({ id: 'p1', name: 'Item', price: 30 }, traits);
|
|
55
|
+
builder.addPayment({ method: 'card', amount: 10 });
|
|
56
|
+
|
|
57
|
+
const order = await firstValueFrom(builder.order$);
|
|
58
|
+
expect(order.balanceDue).toBeCloseTo(20);
|
|
59
|
+
expect(order.changeDue).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('calculates change due when overpaid with cash', async () => {
|
|
63
|
+
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
64
|
+
builder.addProduct({ id: 'p1', name: 'Item', price: 17 }, traits);
|
|
65
|
+
builder.addPayment({ method: 'cash', amount: 20 });
|
|
66
|
+
|
|
67
|
+
const order = await firstValueFrom(builder.order$);
|
|
68
|
+
expect(order.balanceDue).toBe(0);
|
|
69
|
+
expect(order.changeDue).toBeCloseTo(3);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('removes a payment', async () => {
|
|
73
|
+
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
74
|
+
builder.addProduct({ id: 'p1', name: 'Item', price: 20 }, traits);
|
|
75
|
+
const paymentId = builder.addPayment({ method: 'cash', amount: 20 });
|
|
76
|
+
|
|
77
|
+
builder.removePayment(paymentId);
|
|
78
|
+
const order = await firstValueFrom(builder.order$);
|
|
79
|
+
expect(order.payments).toHaveLength(0);
|
|
80
|
+
expect(order.balanceDue).toBe(20);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('includes payment reference', async () => {
|
|
84
|
+
const builder = createOrderBuilder({ currency: 'USD', taxContext });
|
|
85
|
+
builder.addProduct({ id: 'p1', name: 'Item', price: 10 }, traits);
|
|
86
|
+
builder.addPayment({ method: 'card', amount: 10, reference: 'TXN-4567' });
|
|
87
|
+
|
|
88
|
+
const order = await firstValueFrom(builder.order$);
|
|
89
|
+
expect(order.payments[0].reference).toBe('TXN-4567');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface Order {
|
|
2
|
+
id: string;
|
|
3
|
+
status: 'draft' | 'parked' | 'saved' | 'completed';
|
|
4
|
+
lineItems: LineItem[];
|
|
5
|
+
discounts: AppliedDiscount[];
|
|
6
|
+
payments: Payment[];
|
|
7
|
+
customer: CustomerSummary | null;
|
|
8
|
+
note: string;
|
|
9
|
+
subtotal: number;
|
|
10
|
+
discountTotal: number;
|
|
11
|
+
taxTotal: number;
|
|
12
|
+
total: number;
|
|
13
|
+
balanceDue: number;
|
|
14
|
+
changeDue: number;
|
|
15
|
+
currency: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LineItem {
|
|
21
|
+
id: string;
|
|
22
|
+
productId: string;
|
|
23
|
+
variantId?: string;
|
|
24
|
+
name: string;
|
|
25
|
+
sku: string;
|
|
26
|
+
imageUrl?: string;
|
|
27
|
+
price: number;
|
|
28
|
+
quantity: number;
|
|
29
|
+
taxRate: number;
|
|
30
|
+
taxAmount: number;
|
|
31
|
+
discounts: AppliedDiscount[];
|
|
32
|
+
discountAmount: number;
|
|
33
|
+
lineTotal: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Discount {
|
|
37
|
+
type: 'percentage' | 'fixed';
|
|
38
|
+
value: number;
|
|
39
|
+
label?: string;
|
|
40
|
+
couponCode?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AppliedDiscount extends Discount {
|
|
44
|
+
id: string;
|
|
45
|
+
amount: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Payment {
|
|
49
|
+
id: string;
|
|
50
|
+
method: string;
|
|
51
|
+
amount: number;
|
|
52
|
+
reference?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CustomerSummary {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
email?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PaymentMethod {
|
|
62
|
+
id: string;
|
|
63
|
+
label: string;
|
|
64
|
+
icon?: string;
|
|
65
|
+
requiresReference?: boolean;
|
|
66
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildReceiptData } from './build-receipt-data';
|
|
3
|
+
import type { Order } from '../order/types';
|
|
4
|
+
import type { ReceiptConfig } from './types';
|
|
5
|
+
|
|
6
|
+
const baseOrder: Order = {
|
|
7
|
+
id: 'order-001',
|
|
8
|
+
status: 'completed',
|
|
9
|
+
lineItems: [
|
|
10
|
+
{
|
|
11
|
+
id: 'li1',
|
|
12
|
+
productId: 'p1',
|
|
13
|
+
name: 'Espresso',
|
|
14
|
+
sku: 'ESP-001',
|
|
15
|
+
price: 4.5,
|
|
16
|
+
quantity: 2,
|
|
17
|
+
taxRate: 0.1,
|
|
18
|
+
taxAmount: 0.9,
|
|
19
|
+
discounts: [],
|
|
20
|
+
discountAmount: 0,
|
|
21
|
+
lineTotal: 9.9,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'li2',
|
|
25
|
+
productId: 'p2',
|
|
26
|
+
name: 'Muffin',
|
|
27
|
+
sku: 'MUF-001',
|
|
28
|
+
price: 3.0,
|
|
29
|
+
quantity: 1,
|
|
30
|
+
taxRate: 0.05,
|
|
31
|
+
taxAmount: 0.15,
|
|
32
|
+
discounts: [],
|
|
33
|
+
discountAmount: 0,
|
|
34
|
+
lineTotal: 3.15,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
discounts: [{ id: 'd1', type: 'fixed', value: 1, amount: 1, label: 'Loyalty' }],
|
|
38
|
+
payments: [{ id: 'pay1', method: 'cash', amount: 15.0 }],
|
|
39
|
+
customer: { id: 'c1', name: 'Alice' },
|
|
40
|
+
note: 'Extra hot',
|
|
41
|
+
subtotal: 12.0,
|
|
42
|
+
discountTotal: 1.0,
|
|
43
|
+
taxTotal: 1.05,
|
|
44
|
+
total: 12.05,
|
|
45
|
+
balanceDue: 0,
|
|
46
|
+
changeDue: 2.95,
|
|
47
|
+
currency: 'USD',
|
|
48
|
+
createdAt: '2026-02-26T10:00:00Z',
|
|
49
|
+
updatedAt: '2026-02-26T10:05:00Z',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const config: ReceiptConfig = {
|
|
53
|
+
storeName: 'Test Coffee Shop',
|
|
54
|
+
storeAddress: '123 Main St',
|
|
55
|
+
cashier: 'Bob',
|
|
56
|
+
register: 'POS-1',
|
|
57
|
+
taxLabels: { 0.1: 'VAT 10%', 0.05: 'Reduced VAT 5%' },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe('buildReceiptData', () => {
|
|
61
|
+
it('builds receipt header from config and order', () => {
|
|
62
|
+
const receipt = buildReceiptData(baseOrder, config);
|
|
63
|
+
expect(receipt.header.storeName).toBe('Test Coffee Shop');
|
|
64
|
+
expect(receipt.header.storeAddress).toBe('123 Main St');
|
|
65
|
+
expect(receipt.header.orderNumber).toBe('order-001');
|
|
66
|
+
expect(receipt.header.cashier).toBe('Bob');
|
|
67
|
+
expect(receipt.header.register).toBe('POS-1');
|
|
68
|
+
expect(receipt.header.date).toBe('2026-02-26T10:00:00Z');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('maps line items', () => {
|
|
72
|
+
const receipt = buildReceiptData(baseOrder, config);
|
|
73
|
+
expect(receipt.lineItems).toHaveLength(2);
|
|
74
|
+
expect(receipt.lineItems[0]).toEqual({
|
|
75
|
+
name: 'Espresso',
|
|
76
|
+
sku: 'ESP-001',
|
|
77
|
+
quantity: 2,
|
|
78
|
+
unitPrice: 4.5,
|
|
79
|
+
lineTotal: 9.9,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('maps discounts', () => {
|
|
84
|
+
const receipt = buildReceiptData(baseOrder, config);
|
|
85
|
+
expect(receipt.discounts).toHaveLength(1);
|
|
86
|
+
expect(receipt.discounts[0]).toEqual({ label: 'Loyalty', amount: 1 });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('groups tax lines by rate with labels', () => {
|
|
90
|
+
const receipt = buildReceiptData(baseOrder, config);
|
|
91
|
+
expect(receipt.totals.taxLines).toHaveLength(2);
|
|
92
|
+
expect(receipt.totals.taxLines).toContainEqual({
|
|
93
|
+
label: 'VAT 10%',
|
|
94
|
+
rate: 0.1,
|
|
95
|
+
amount: 0.9,
|
|
96
|
+
});
|
|
97
|
+
expect(receipt.totals.taxLines).toContainEqual({
|
|
98
|
+
label: 'Reduced VAT 5%',
|
|
99
|
+
rate: 0.05,
|
|
100
|
+
amount: 0.15,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('uses default tax label when not configured', () => {
|
|
105
|
+
const receipt = buildReceiptData(baseOrder, { storeName: 'Shop' });
|
|
106
|
+
const labels = receipt.totals.taxLines.map((t) => t.label);
|
|
107
|
+
expect(labels).toContain('Tax 10%');
|
|
108
|
+
expect(labels).toContain('Tax 5%');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('calculates totals', () => {
|
|
112
|
+
const receipt = buildReceiptData(baseOrder, config);
|
|
113
|
+
expect(receipt.totals.subtotal).toBe(12.0);
|
|
114
|
+
expect(receipt.totals.discountTotal).toBe(1.0);
|
|
115
|
+
expect(receipt.totals.taxTotal).toBe(1.05);
|
|
116
|
+
expect(receipt.totals.total).toBe(12.05);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('maps payments', () => {
|
|
120
|
+
const receipt = buildReceiptData(baseOrder, config);
|
|
121
|
+
expect(receipt.payments).toHaveLength(1);
|
|
122
|
+
expect(receipt.payments[0]).toEqual({ method: 'cash', amount: 15.0 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('includes change due and footer', () => {
|
|
126
|
+
const receipt = buildReceiptData(baseOrder, config);
|
|
127
|
+
expect(receipt.changeDue).toBe(2.95);
|
|
128
|
+
expect(receipt.footer.note).toBe('Extra hot');
|
|
129
|
+
expect(receipt.footer.barcode).toBe('order-001');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('includes currency', () => {
|
|
133
|
+
const receipt = buildReceiptData(baseOrder, config);
|
|
134
|
+
expect(receipt.currency).toBe('USD');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('handles empty order with no line items', () => {
|
|
138
|
+
const emptyOrder: Order = {
|
|
139
|
+
...baseOrder,
|
|
140
|
+
lineItems: [],
|
|
141
|
+
discounts: [],
|
|
142
|
+
payments: [],
|
|
143
|
+
subtotal: 0,
|
|
144
|
+
discountTotal: 0,
|
|
145
|
+
taxTotal: 0,
|
|
146
|
+
total: 0,
|
|
147
|
+
balanceDue: 0,
|
|
148
|
+
changeDue: 0,
|
|
149
|
+
};
|
|
150
|
+
const receipt = buildReceiptData(emptyOrder, config);
|
|
151
|
+
expect(receipt.lineItems).toHaveLength(0);
|
|
152
|
+
expect(receipt.totals.taxLines).toHaveLength(0);
|
|
153
|
+
expect(receipt.totals.total).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('uses fallback label for discount without label or couponCode', () => {
|
|
157
|
+
const orderWithUnlabeledDiscount: Order = {
|
|
158
|
+
...baseOrder,
|
|
159
|
+
discounts: [
|
|
160
|
+
{ id: 'd1', type: 'percentage', value: 10, amount: 1.20 },
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
const receipt = buildReceiptData(orderWithUnlabeledDiscount, config);
|
|
164
|
+
expect(receipt.discounts[0].label).toBe('percentage discount');
|
|
165
|
+
});
|
|
166
|
+
});
|