@tallyui/pos 0.1.0 → 2.0.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1770 -42
  3. package/dist/index.js +3422 -204
  4. package/package.json +21 -10
  5. package/src/currency/currency-provider.tsx +12 -4
  6. package/src/currency/index.ts +1 -2
  7. package/src/index.ts +45 -4
  8. package/src/order/allocate-order-discount.ts +34 -0
  9. package/src/order/index.ts +5 -0
  10. package/src/order/order-builder.ts +240 -95
  11. package/src/order/order-manager.ts +23 -42
  12. package/src/order/types.ts +67 -15
  13. package/src/outbox/http-transport.ts +66 -0
  14. package/src/outbox/index.ts +7 -0
  15. package/src/outbox/order-outbox.ts +198 -0
  16. package/src/outbox/types.ts +22 -0
  17. package/src/outbox/use-order-outbox.ts +161 -0
  18. package/src/pos-order/command.ts +35 -0
  19. package/src/pos-order/device-id.ts +23 -0
  20. package/src/pos-order/finalize.ts +81 -0
  21. package/src/pos-order/index.ts +10 -0
  22. package/src/pos-order/needs-attention.ts +11 -0
  23. package/src/pos-order/open.ts +206 -0
  24. package/src/pos-order/same-sale.ts +26 -0
  25. package/src/pos-order/schema.ts +100 -0
  26. package/src/pos-order/types.ts +67 -0
  27. package/src/pos-order/uuidv7.ts +54 -0
  28. package/src/product/index.ts +2 -0
  29. package/src/product/search-products.ts +29 -0
  30. package/src/product/stock.ts +25 -0
  31. package/src/receipt/build-receipt-data.ts +36 -22
  32. package/src/receipt/types.ts +16 -10
  33. package/src/register/__fixtures__/closure-local-row.json +61 -0
  34. package/src/register/__fixtures__/closure.json +197 -0
  35. package/src/register/__fixtures__/corrections-dst.json +63 -0
  36. package/src/register/closure-document.ts +270 -0
  37. package/src/register/closure-rows.ts +65 -0
  38. package/src/register/document-labels.ts +46 -0
  39. package/src/register/expected.ts +66 -0
  40. package/src/register/export-csv.ts +74 -0
  41. package/src/register/facts.ts +109 -0
  42. package/src/register/index.ts +30 -0
  43. package/src/register/money.ts +18 -0
  44. package/src/register/movement-input.ts +59 -0
  45. package/src/register/register-count.denominations.ts +11 -0
  46. package/src/register/register-count.helpers.ts +64 -0
  47. package/src/register/register-document.ts +243 -0
  48. package/src/register/schemas.ts +175 -0
  49. package/src/register/session-store.ts +541 -0
  50. package/src/register/settled-figures.ts +98 -0
  51. package/src/register/use-register-session.ts +425 -0
  52. package/src/rxdb/index.ts +2 -0
  53. package/src/sale/cart.ts +22 -0
  54. package/src/sale/catalogue.ts +21 -0
  55. package/src/sale/index.ts +5 -0
  56. package/src/sale/use-sale.ts +337 -0
  57. package/src/store-settings/index.ts +5 -0
  58. package/src/store-settings/map-store-settings.ts +13 -0
  59. package/src/store-settings/resolve-store-settings.ts +65 -0
  60. package/src/store-settings/use-store-settings.ts +69 -0
  61. package/src/tax/exact.ts +155 -0
  62. package/src/tax/index.ts +3 -2
  63. package/src/tax/tax-provider.tsx +13 -12
  64. package/src/tax/types.ts +3 -7
  65. package/src/tender/index.ts +2 -0
  66. package/src/tender/tender-state.ts +326 -0
  67. package/src/currency/currency-provider.test.tsx +0 -36
  68. package/src/currency/format-currency.test.ts +0 -36
  69. package/src/currency/format-currency.ts +0 -18
  70. package/src/logging/logger.test.ts +0 -154
  71. package/src/logging/sinks.test.ts +0 -60
  72. package/src/order/discount-engine.test.ts +0 -152
  73. package/src/order/order-builder.test.ts +0 -246
  74. package/src/order/order-manager.test.ts +0 -209
  75. package/src/order/payment.test.ts +0 -91
  76. package/src/receipt/build-receipt-data.test.ts +0 -166
  77. package/src/repository/create-repository.test.ts +0 -166
  78. package/src/tax/calculate.test.ts +0 -59
  79. package/src/tax/calculate.ts +0 -32
  80. package/src/tax/tax-provider.test.tsx +0 -51
@@ -5,23 +5,24 @@ import type { TaxContext } from './types';
5
5
  const TaxCtx = createContext<TaxContext | null>(null);
6
6
 
7
7
  export interface TaxProviderProps {
8
- rates: Record<string, number>;
8
+ ratesPpm: Record<string, number>;
9
9
  pricesIncludeTax: boolean;
10
10
  children: ReactNode;
11
11
  }
12
12
 
13
- export function TaxProvider({ rates, pricesIncludeTax, children }: TaxProviderProps) {
13
+ export function TaxProvider({ ratesPpm, pricesIncludeTax, children }: TaxProviderProps) {
14
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],
15
+ () => {
16
+ if (Object.values(ratesPpm).some((rate) => !Number.isSafeInteger(rate) || rate < 0)) {
17
+ throw new RangeError('TaxProvider: rates must be integer ppm');
18
+ }
19
+ return {
20
+ getTaxRatePpm: (taxClass) =>
21
+ (taxClass === undefined ? undefined : ratesPpm[taxClass]) ?? ratesPpm.default ?? 0,
22
+ pricesIncludeTax,
23
+ };
24
+ },
25
+ [ratesPpm, pricesIncludeTax],
25
26
  );
26
27
 
27
28
  return <TaxCtx.Provider value={value}>{children}</TaxCtx.Provider>;
package/src/tax/types.ts CHANGED
@@ -1,15 +1,11 @@
1
- export interface TaxResult {
2
- priceExclTax: number;
3
- priceInclTax: number;
4
- taxAmount: number;
5
- }
6
-
1
+ /** Tax rates as integer parts per million (19% = 190000). */
7
2
  export type TaxRateMap = {
8
3
  default: number;
9
4
  [taxClass: string]: number;
10
5
  };
11
6
 
12
7
  export interface TaxContext {
13
- getTaxRate(taxClass?: string): number;
8
+ /** Tax rate for a tax class as integer parts per million (19% = 190000); the default class when omitted or unknown. */
9
+ getTaxRatePpm(taxClass?: string): number;
14
10
  pricesIncludeTax: boolean;
15
11
  }
@@ -0,0 +1,2 @@
1
+ export { tenderReducer, initTenderState, initialTenderState, appliedMinor, changeMinor, quickTenderedAmounts, evenSplitShareMinor, activePlan, planLegs, MAX_TENDER_MINOR } from './tender-state';
2
+ export type { PaymentTransport, TenderView, TenderLineId, TenderPlan, SplitTab, TenderState, TenderKey, TenderAction, PlanLeg } from './tender-state';
@@ -0,0 +1,326 @@
1
+ /** The transport a payment terminal is reached over: hardware-neutral, not tied to any backend. */
2
+ export type PaymentTransport = 'bluetooth' | 'usb' | 'network' | 'tap_to_pay';
3
+
4
+ export type TenderView = 'select' | 'amount' | 'cancel';
5
+ export type TenderLineId = string | number;
6
+
7
+ export type TenderPlan =
8
+ | { kind: 'even'; ways: number; from: number }
9
+ | { kind: 'fixed'; firstMinor: number; title: string | null; from: number }
10
+ | { kind: 'items'; lineIds: TenderLineId[]; firstMinor: number; ways: number; from: number };
11
+ export type SplitTab = 'even' | 'amount' | 'percent' | 'item';
12
+
13
+ export interface TenderState {
14
+ view: TenderView;
15
+ /** Method id being tendered; null in the 'select' and 'cancel' views. */
16
+ methodId: string | null;
17
+ readerId: string | null;
18
+ transport: PaymentTransport | null;
19
+ /** Keypad entry in minor units. */
20
+ entryMinor: number;
21
+ /** False until the cashier has touched the keypad since the entry was pre-filled. */
22
+ entryDirty: boolean;
23
+ plan: TenderPlan | null;
24
+ splitView: boolean;
25
+ splitTab: SplitTab;
26
+ pickedLineIds: TenderLineId[];
27
+ linesPaidBy: Record<TenderLineId, string[]>;
28
+ }
29
+
30
+ /** '0'..'9' plus the two edit keys. There is deliberately no decimal key: digits shift in from the right. */
31
+ export type TenderKey =
32
+ '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | 'clear' | 'backspace';
33
+
34
+ export type TenderAction =
35
+ | {
36
+ type: 'pick-method';
37
+ methodId: string;
38
+ prefillMinor: number;
39
+ readerId: string | null;
40
+ transport?: PaymentTransport | null;
41
+ }
42
+ | { type: 'pick-transport'; transport: PaymentTransport }
43
+ | { type: 'pick-reader'; readerId: string | null }
44
+ | { type: 'tender-started' }
45
+ | { type: 'key'; key: TenderKey }
46
+ | { type: 'set-entry'; minor: number }
47
+ | { type: 'back' }
48
+ | {
49
+ type: 'tender-recorded';
50
+ rowsSinceFrom: { title: string; amountMinor: number }[];
51
+ /** The order's balance once this row counts, so the next leg can be pre-typed. */
52
+ balanceMinor: number;
53
+ }
54
+ | { type: 'open-split' }
55
+ | { type: 'close-split' }
56
+ | { type: 'set-split-tab'; tab: SplitTab }
57
+ | { type: 'toggle-split-line'; lineId: TenderLineId }
58
+ | { type: 'set-plan'; plan: TenderPlan; balanceMinor: number }
59
+ | { type: 'clear-plan'; balanceMinor: number }
60
+ | { type: 'arm-custom' }
61
+ | { type: 'request-cancel' }
62
+ | { type: 'reset' };
63
+ /** $9,999,999.99 at two decimals — a till will never legitimately take more, and it stops a stuck key running the display off the screen. */
64
+ export const MAX_TENDER_MINOR = 999999999;
65
+ export const initialTenderState: TenderState = {
66
+ view: 'select',
67
+ methodId: null,
68
+ readerId: null,
69
+ transport: null,
70
+ entryMinor: 0,
71
+ entryDirty: false,
72
+ plan: null,
73
+ splitView: false,
74
+ splitTab: 'even',
75
+ pickedLineIds: [],
76
+ linesPaidBy: {},
77
+ };
78
+
79
+ /**
80
+ * Reducer init. An order whose method the checkout store already holds — a URL seed, or a
81
+ * tab the cashier is coming back to — reopens its keypad prefilled with the balance, exactly
82
+ * as a tap on that tile would. Everything else starts from scratch.
83
+ */
84
+ export function initTenderState({
85
+ methodId,
86
+ balanceMinor,
87
+ }: {
88
+ methodId: string | null;
89
+ balanceMinor: number;
90
+ }): TenderState {
91
+ if (!methodId) return initialTenderState;
92
+ return { ...initialTenderState, view: 'amount', methodId, entryMinor: balanceMinor };
93
+ }
94
+
95
+ export function tenderReducer(state: TenderState, action: TenderAction): TenderState {
96
+ switch (action.type) {
97
+ case 'pick-method':
98
+ return {
99
+ ...state,
100
+ view: 'amount',
101
+ methodId: action.methodId,
102
+ readerId: action.readerId,
103
+ transport: action.transport ?? null,
104
+ entryMinor: state.entryDirty ? state.entryMinor : action.prefillMinor,
105
+ entryDirty: state.entryDirty,
106
+ };
107
+ case 'pick-transport':
108
+ return { ...state, transport: action.transport };
109
+ case 'pick-reader':
110
+ return { ...state, readerId: action.readerId };
111
+ case 'key': {
112
+ if (state.view !== 'amount') {
113
+ return state;
114
+ }
115
+ if (action.key === 'backspace') {
116
+ return { ...state, entryMinor: Math.floor(state.entryMinor / 10), entryDirty: true };
117
+ }
118
+ if (action.key === 'clear') {
119
+ return { ...state, entryMinor: 0, entryDirty: true };
120
+ }
121
+ const digit = Number(action.key);
122
+ if (!state.entryDirty) {
123
+ return { ...state, entryMinor: digit, entryDirty: true };
124
+ }
125
+ const entryMinor = state.entryMinor * 10 + digit;
126
+ return entryMinor > MAX_TENDER_MINOR ? state : { ...state, entryMinor };
127
+ }
128
+ case 'set-entry':
129
+ return {
130
+ ...state,
131
+ entryMinor: Math.max(0, Math.min(action.minor, MAX_TENDER_MINOR)),
132
+ entryDirty: true,
133
+ };
134
+ case 'tender-started':
135
+ // A leg is being recorded: the method stays chosen for the next one; the entry
136
+ // clears until `tender-recorded` pre-types the next planned leg.
137
+ return { ...state, entryMinor: 0, entryDirty: false, splitView: false };
138
+ case 'back':
139
+ return {
140
+ ...state,
141
+ view: 'select',
142
+ methodId: null,
143
+ readerId: null,
144
+ transport: null,
145
+ entryMinor: 0,
146
+ entryDirty: false,
147
+ };
148
+ case 'tender-recorded': {
149
+ const linesPaidBy = { ...state.linesPaidBy };
150
+ if (
151
+ state.plan?.kind === 'items' &&
152
+ action.rowsSinceFrom.reduce((sum, row) => sum + row.amountMinor, 0) >= state.plan.firstMinor
153
+ ) {
154
+ for (const id of state.plan.lineIds) {
155
+ // Preserve the group's original methods when subsequently taking the rest.
156
+ linesPaidBy[id] ??= [...new Set(action.rowsSinceFrom.map((row) => row.title))];
157
+ }
158
+ }
159
+ // The method stays chosen for the next leg (the selector is always on screen now);
160
+ // the entry pre-types the next planned leg, or the balance when no plan is left.
161
+ const rows = action.rowsSinceFrom.map((row) => ({
162
+ minor: row.amountMinor,
163
+ title: row.title,
164
+ }));
165
+ const plan = activePlan(state.plan, rows.length, action.balanceMinor);
166
+ const entryMinor = plan
167
+ ? planLegs(plan, rows, action.balanceMinor).thisPaymentMinor
168
+ : action.balanceMinor;
169
+ return {
170
+ ...state,
171
+ view: state.methodId ? 'amount' : 'select',
172
+ entryMinor,
173
+ entryDirty: false,
174
+ splitView: false,
175
+ pickedLineIds: [],
176
+ linesPaidBy,
177
+ };
178
+ }
179
+ case 'open-split':
180
+ return {
181
+ ...state,
182
+ splitView: true,
183
+ pickedLineIds:
184
+ state.plan?.kind === 'items'
185
+ ? state.plan.lineIds.filter((id) => !state.linesPaidBy[id])
186
+ : state.pickedLineIds,
187
+ };
188
+ case 'close-split':
189
+ return { ...state, splitView: false };
190
+ case 'set-split-tab':
191
+ return { ...state, splitTab: action.tab };
192
+ case 'toggle-split-line':
193
+ if (state.linesPaidBy[action.lineId]) return state;
194
+ return {
195
+ ...state,
196
+ pickedLineIds: state.pickedLineIds.includes(action.lineId)
197
+ ? state.pickedLineIds.filter((id) => id !== action.lineId)
198
+ : [...state.pickedLineIds, action.lineId],
199
+ };
200
+ case 'set-plan':
201
+ return {
202
+ ...state,
203
+ plan: action.plan,
204
+ splitView: false,
205
+ pickedLineIds: [],
206
+ entryMinor:
207
+ state.view === 'amount'
208
+ ? planLegs(action.plan, [], action.balanceMinor).thisPaymentMinor
209
+ : state.entryMinor,
210
+ entryDirty: state.view !== 'amount' && state.entryDirty,
211
+ };
212
+ case 'clear-plan':
213
+ case 'arm-custom':
214
+ return {
215
+ ...state,
216
+ plan: null,
217
+ splitView: false,
218
+ view: action.type === 'arm-custom' ? 'amount' : state.view,
219
+ entryMinor: action.type === 'clear-plan' ? action.balanceMinor : 0,
220
+ entryDirty: false,
221
+ };
222
+ case 'request-cancel':
223
+ return { ...state, view: 'cancel', splitView: false };
224
+ case 'reset':
225
+ return initialTenderState;
226
+ }
227
+ }
228
+ /**
229
+ * What the leg applies to the order. Cash may be tendered above the balance;
230
+ * the excess is change, never an overpayment on the order.
231
+ */
232
+ export function appliedMinor(entryMinor: number, balanceMinor: number): number {
233
+ return Math.max(0, Math.min(entryMinor, balanceMinor));
234
+ }
235
+ /** Change handed back. Zero for any tender whose method cannot give change. */
236
+ export function changeMinor(
237
+ entryMinor: number,
238
+ appliedAmountMinor: number,
239
+ givesChange: boolean,
240
+ ): number {
241
+ return givesChange ? Math.max(0, entryMinor - appliedAmountMinor) : 0;
242
+ }
243
+ /**
244
+ * Quick tendered amounts under a cash keypad: the balance itself, then the next
245
+ * whole 5, 10 and 50 above it. Deduped, ascending, never below the balance.
246
+ * The caller supplies those major-unit steps already scaled to minor units.
247
+ */
248
+ export function quickTenderedAmounts(
249
+ balanceMinor: number,
250
+ stepsMinor: readonly number[],
251
+ ): number[] {
252
+ if (balanceMinor === 0) {
253
+ return [];
254
+ }
255
+
256
+ const amounts = new Set<number>([balanceMinor]);
257
+ for (const stepMinor of stepsMinor) {
258
+ if (stepMinor <= 0) continue;
259
+ const remainder = balanceMinor % stepMinor;
260
+ amounts.add(remainder === 0 ? balanceMinor : balanceMinor + stepMinor - remainder);
261
+ }
262
+ return [...amounts].sort((left, right) => left - right);
263
+ }
264
+ /**
265
+ * Even split shares. Returns the next tender's share, rounded half-up to the
266
+ * minor unit; the last leg remains whatever balance is left.
267
+ */
268
+ export function evenSplitShareMinor(balanceMinor: number, ways: number): number {
269
+ if (ways < 2) {
270
+ return balanceMinor;
271
+ }
272
+ const wholeShare = Math.floor(balanceMinor / ways);
273
+ const remainder = balanceMinor % ways;
274
+ return wholeShare + (remainder * 2 >= ways ? 1 : 0);
275
+ }
276
+
277
+ /** Completion is derived from payment rows, never an extra counter in the reducer. */
278
+ export function activePlan(
279
+ plan: TenderPlan | null,
280
+ taken: number,
281
+ balanceMinor: number,
282
+ ): TenderPlan | null {
283
+ if (!plan || balanceMinor === 0) return null;
284
+ if (plan.kind !== 'items' && taken >= (plan.kind === 'even' ? plan.ways : 2)) return null;
285
+ return plan;
286
+ }
287
+
288
+ export interface PlanLeg {
289
+ minor: number;
290
+ state: 'done' | 'now' | 'todo' | 'rest';
291
+ title?: string;
292
+ }
293
+
294
+ /** Label pieces stay untranslated here; the flow supplies localized copy and item names. */
295
+ export function planLegs(
296
+ plan: TenderPlan,
297
+ rowsSinceFrom: { minor: number; title: string }[],
298
+ balanceMinor: number,
299
+ ) {
300
+ const taken = rowsSinceFrom.length;
301
+ const legs: PlanLeg[] = rowsSinceFrom.map((row) => ({ ...row, state: 'done' }));
302
+ const groupPaid = rowsSinceFrom.reduce((sum, row) => sum + row.minor, 0);
303
+ const groupLeft =
304
+ plan.kind === 'items'
305
+ ? Math.min(balanceMinor, Math.max(0, plan.firstMinor - groupPaid))
306
+ : balanceMinor;
307
+ const rest = plan.kind === 'items' && groupLeft === 0;
308
+ const ways = plan.kind === 'fixed' ? 2 : Math.max(plan.ways, taken + 1);
309
+ const left = plan.kind === 'fixed' || rest ? 1 : Math.max(1, ways - taken);
310
+ const amount = rest ? balanceMinor : groupLeft;
311
+ const each =
312
+ plan.kind === 'fixed' && taken === 0
313
+ ? Math.min(plan.firstMinor, balanceMinor)
314
+ : evenSplitShareMinor(amount, left);
315
+ let remaining = amount;
316
+ for (let i = 0; i < left; i++) {
317
+ const minor = Math.min(remaining, i === left - 1 && plan.kind !== 'fixed' ? remaining : each);
318
+ legs.push({ minor, state: i === 0 ? 'now' : 'todo' });
319
+ remaining -= minor;
320
+ }
321
+ if (plan.kind === 'fixed' && remaining > 0) legs.push({ minor: remaining, state: 'todo' });
322
+ if (plan.kind === 'items' && !rest && balanceMinor > groupLeft)
323
+ legs.push({ minor: balanceMinor - groupLeft, state: 'rest' });
324
+ const title = plan.kind === 'fixed' && taken === 0 ? plan.title : null;
325
+ return { legs, thisPaymentMinor: each, label: { n: taken + 1, ways, rest, title } };
326
+ }
@@ -1,36 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { renderHook } from '@testing-library/react';
3
- import type { ReactNode } from 'react';
4
- import { CurrencyProvider, useCurrencyFormatter } from './currency-provider';
5
-
6
- function wrapper(currency: string, locale?: string) {
7
- return function Wrapper({ children }: { children: ReactNode }) {
8
- return (
9
- <CurrencyProvider currencyCode={currency} locale={locale}>
10
- {children}
11
- </CurrencyProvider>
12
- );
13
- };
14
- }
15
-
16
- describe('CurrencyProvider + useCurrencyFormatter', () => {
17
- it('formats using provided currency and locale', () => {
18
- const { result } = renderHook(() => useCurrencyFormatter(), {
19
- wrapper: wrapper('USD', 'en-US'),
20
- });
21
- expect(result.current(10.5)).toBe('$10.50');
22
- });
23
-
24
- it('formats EUR with German locale', () => {
25
- const { result } = renderHook(() => useCurrencyFormatter(), {
26
- wrapper: wrapper('EUR', 'de-DE'),
27
- });
28
- expect(result.current(10.5).replace(/\s/g, ' ')).toBe('10,50 €');
29
- });
30
-
31
- it('throws when used outside provider', () => {
32
- expect(() => {
33
- renderHook(() => useCurrencyFormatter());
34
- }).toThrow('useCurrencyFormatter() must be used within a <CurrencyProvider>');
35
- });
36
- });
@@ -1,36 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { formatCurrency } from './format-currency';
3
-
4
- describe('formatCurrency', () => {
5
- it('formats USD with default locale', () => {
6
- const result = formatCurrency(10.5, 'USD', 'en-US');
7
- expect(result).toBe('$10.50');
8
- });
9
-
10
- it('formats EUR with German locale', () => {
11
- const result = formatCurrency(10.5, 'EUR', 'de-DE');
12
- expect(result.replace(/\s/g, ' ')).toBe('10,50 €');
13
- });
14
-
15
- it('formats JPY (zero decimals)', () => {
16
- const result = formatCurrency(1000, 'JPY', 'ja-JP');
17
- expect(result).toContain('1,000');
18
- });
19
-
20
- it('formats zero', () => {
21
- const result = formatCurrency(0, 'USD', 'en-US');
22
- expect(result).toBe('$0.00');
23
- });
24
-
25
- it('formats negative numbers', () => {
26
- const result = formatCurrency(-5.99, 'USD', 'en-US');
27
- expect(result).toContain('5.99');
28
- });
29
-
30
- it('memoizes formatter for same currency+locale pair', () => {
31
- const a = formatCurrency(1, 'USD', 'en-US');
32
- const b = formatCurrency(2, 'USD', 'en-US');
33
- expect(a).toBe('$1.00');
34
- expect(b).toBe('$2.00');
35
- });
36
- });
@@ -1,18 +0,0 @@
1
- const formatters = new Map<string, Intl.NumberFormat>();
2
-
3
- function getFormatter(currencyCode: string, locale?: string): Intl.NumberFormat {
4
- const key = `${currencyCode}:${locale ?? 'default'}`;
5
- let formatter = formatters.get(key);
6
- if (!formatter) {
7
- formatter = new Intl.NumberFormat(locale, {
8
- style: 'currency',
9
- currency: currencyCode,
10
- });
11
- formatters.set(key, formatter);
12
- }
13
- return formatter;
14
- }
15
-
16
- export function formatCurrency(amount: number, currencyCode: string, locale?: string): string {
17
- return getFormatter(currencyCode, locale).format(amount);
18
- }
@@ -1,154 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { createLogger } from './logger';
3
- import type { LogSink, LogEntry } from './types';
4
-
5
- function createTestSink(overrides?: Partial<LogSink>): LogSink & { entries: LogEntry[] } {
6
- const entries: LogEntry[] = [];
7
- return {
8
- id: 'test',
9
- levels: ['debug', 'info', 'warn', 'error'],
10
- write: (entry) => entries.push(entry),
11
- entries,
12
- ...overrides,
13
- };
14
- }
15
-
16
- describe('createLogger', () => {
17
- it('creates a logger with default root scope', () => {
18
- const logger = createLogger();
19
- expect(logger).toBeDefined();
20
- expect(typeof logger.debug).toBe('function');
21
- expect(typeof logger.info).toBe('function');
22
- expect(typeof logger.warn).toBe('function');
23
- expect(typeof logger.error).toBe('function');
24
- });
25
-
26
- it('writes log entries to registered sinks', () => {
27
- const logger = createLogger();
28
- const sink = createTestSink();
29
- logger.addSink(sink);
30
-
31
- logger.info('hello world');
32
-
33
- expect(sink.entries).toHaveLength(1);
34
- expect(sink.entries[0].level).toBe('info');
35
- expect(sink.entries[0].message).toBe('hello world');
36
- expect(sink.entries[0].scope).toBe('root');
37
- expect(sink.entries[0].timestamp).toBeGreaterThan(0);
38
- });
39
-
40
- it('includes data in log entries', () => {
41
- const logger = createLogger();
42
- const sink = createTestSink();
43
- logger.addSink(sink);
44
-
45
- logger.error('failed', { code: 500 });
46
-
47
- expect(sink.entries[0].data).toEqual({ code: 500 });
48
- });
49
-
50
- it('only writes to sinks that match the log level', () => {
51
- const logger = createLogger();
52
- const errorOnly = createTestSink({ id: 'errors', levels: ['error'] });
53
- logger.addSink(errorOnly);
54
-
55
- logger.debug('debug msg');
56
- logger.info('info msg');
57
- logger.warn('warn msg');
58
- logger.error('error msg');
59
-
60
- expect(errorOnly.entries).toHaveLength(1);
61
- expect(errorOnly.entries[0].level).toBe('error');
62
- });
63
-
64
- it('removes sinks by id', () => {
65
- const logger = createLogger();
66
- const sink = createTestSink();
67
- logger.addSink(sink);
68
- logger.info('before');
69
- logger.removeSink('test');
70
- logger.info('after');
71
-
72
- expect(sink.entries).toHaveLength(1);
73
- expect(sink.entries[0].message).toBe('before');
74
- });
75
- });
76
-
77
- describe('scoped loggers', () => {
78
- it('creates a child logger with a scoped name', () => {
79
- const logger = createLogger();
80
- const sink = createTestSink();
81
- logger.addSink(sink);
82
-
83
- const child = logger.createScope('order-builder');
84
- child.info('order created');
85
-
86
- expect(sink.entries[0].scope).toBe('order-builder');
87
- });
88
-
89
- it('child loggers share sinks with parent', () => {
90
- const logger = createLogger();
91
- const sink = createTestSink();
92
- logger.addSink(sink);
93
-
94
- const child = logger.createScope('tax');
95
- child.warn('rate missing');
96
- logger.info('root message');
97
-
98
- expect(sink.entries).toHaveLength(2);
99
- expect(sink.entries[0].scope).toBe('tax');
100
- expect(sink.entries[1].scope).toBe('root');
101
- });
102
-
103
- it('sinks can filter by scope', () => {
104
- const logger = createLogger();
105
- const taxOnly = createTestSink({ id: 'tax-sink', scopes: ['tax'] });
106
- logger.addSink(taxOnly);
107
-
108
- const taxLog = logger.createScope('tax');
109
- const orderLog = logger.createScope('order');
110
-
111
- taxLog.info('tax message');
112
- orderLog.info('order message');
113
-
114
- expect(taxOnly.entries).toHaveLength(1);
115
- expect(taxOnly.entries[0].scope).toBe('tax');
116
- });
117
-
118
- it('sinks added to parent are visible to existing children', () => {
119
- const logger = createLogger();
120
- const child = logger.createScope('child');
121
-
122
- const sink = createTestSink();
123
- logger.addSink(sink);
124
-
125
- child.info('after add');
126
- expect(sink.entries).toHaveLength(1);
127
- });
128
-
129
- it('sinks removed from parent are removed from children', () => {
130
- const logger = createLogger();
131
- const sink = createTestSink();
132
- logger.addSink(sink);
133
-
134
- const child = logger.createScope('child');
135
- child.info('before');
136
- logger.removeSink('test');
137
- child.info('after');
138
-
139
- expect(sink.entries).toHaveLength(1);
140
- });
141
-
142
- it('nested scopes (grandchild) share sinks with root', () => {
143
- const logger = createLogger();
144
- const sink = createTestSink();
145
- logger.addSink(sink);
146
-
147
- const child = logger.createScope('parent');
148
- const grandchild = child.createScope('grandchild');
149
-
150
- grandchild.info('deep message');
151
- expect(sink.entries).toHaveLength(1);
152
- expect(sink.entries[0].scope).toBe('grandchild');
153
- });
154
- });