assafvayner HF Staff commited on
Commit
3c22061
·
1 Parent(s): 6ea96a3

feat(domain): add monotonic ULID generator and money parsing/formatting

Browse files
src/lib/domain/money.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const AMOUNT_RE = /^(\d+)(?:\.(\d{1,2}))?$/;
2
+
3
+ /** "12.34" -> 1234. Accepts thousands separators. Returns null for anything invalid or non-positive. */
4
+ export function parseAmountToCents(input: string): number | null {
5
+ const cleaned = input.trim().replace(/,/g, '');
6
+ const m = AMOUNT_RE.exec(cleaned);
7
+ if (!m) return null;
8
+ const whole = Number(m[1]);
9
+ const frac = (m[2] ?? '').padEnd(2, '0');
10
+ const cents = whole * 100 + Number(frac);
11
+ if (!Number.isSafeInteger(cents) || cents <= 0) return null;
12
+ return cents;
13
+ }
14
+
15
+ /** "33.33" -> 3333 basis points. Must be in (0, 100]. */
16
+ export function parsePercentToBp(input: string): number | null {
17
+ const cents = parseAmountToCents(input);
18
+ if (cents === null || cents > 10000) return null;
19
+ return cents;
20
+ }
21
+
22
+ export function formatCents(cents: number, currency: string, locale = 'en-US'): string {
23
+ return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(cents / 100);
24
+ }
25
+
26
+ export function centsToInput(cents: number): string {
27
+ return (cents / 100).toFixed(2);
28
+ }
29
+
30
+ export function bpToInput(bp: number): string {
31
+ return (bp / 100).toFixed(2).replace(/\.?0+$/, '');
32
+ }
src/lib/domain/ulid.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
4
+ const TIME_LEN = 10;
5
+ const RANDOM_LEN = 16;
6
+
7
+ function encodeTime(ms: number): string {
8
+ let out = '';
9
+ let t = ms;
10
+ for (let i = 0; i < TIME_LEN; i++) {
11
+ out = ENCODING[t % 32] + out;
12
+ t = Math.floor(t / 32);
13
+ }
14
+ return out;
15
+ }
16
+
17
+ function randomDigits(): number[] {
18
+ const bytes = randomBytes(RANDOM_LEN);
19
+ return Array.from(bytes, (b) => b % 32);
20
+ }
21
+
22
+ function incrementDigits(digits: number[]): void {
23
+ for (let i = digits.length - 1; i >= 0; i--) {
24
+ if (digits[i] < 31) {
25
+ digits[i]++;
26
+ return;
27
+ }
28
+ digits[i] = 0;
29
+ }
30
+ throw new Error('ULID random component overflow');
31
+ }
32
+
33
+ /**
34
+ * Returns a monotonic ULID generator: ids created in the same millisecond
35
+ * strictly increase, so lexical order equals creation order within a process.
36
+ */
37
+ export function createUlidFactory(now: () => number = Date.now): () => string {
38
+ let lastTime = -1;
39
+ let lastRandom: number[] = [];
40
+ return () => {
41
+ const t = now();
42
+ if (t === lastTime) {
43
+ incrementDigits(lastRandom);
44
+ } else {
45
+ lastTime = t;
46
+ lastRandom = randomDigits();
47
+ }
48
+ return encodeTime(t) + lastRandom.map((d) => ENCODING[d]).join('');
49
+ };
50
+ }
51
+
52
+ export const ulid = createUlidFactory();
tests/unit/domain/money.test.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatCents, parseAmountToCents, parsePercentToBp } from '../../../src/lib/domain/money';
3
+
4
+ describe('parseAmountToCents', () => {
5
+ it('parses whole and decimal amounts', () => {
6
+ expect(parseAmountToCents('12')).toBe(1200);
7
+ expect(parseAmountToCents('12.3')).toBe(1230);
8
+ expect(parseAmountToCents('12.34')).toBe(1234);
9
+ expect(parseAmountToCents(' 0.05 ')).toBe(5);
10
+ expect(parseAmountToCents('1,234.56')).toBe(123456);
11
+ });
12
+
13
+ it('rejects invalid input', () => {
14
+ expect(parseAmountToCents('')).toBeNull();
15
+ expect(parseAmountToCents('abc')).toBeNull();
16
+ expect(parseAmountToCents('12.345')).toBeNull();
17
+ expect(parseAmountToCents('-5')).toBeNull();
18
+ expect(parseAmountToCents('0')).toBeNull();
19
+ expect(parseAmountToCents('1e3')).toBeNull();
20
+ });
21
+ });
22
+
23
+ describe('parsePercentToBp', () => {
24
+ it('parses up to two decimals into basis points', () => {
25
+ expect(parsePercentToBp('50')).toBe(5000);
26
+ expect(parsePercentToBp('33.33')).toBe(3333);
27
+ expect(parsePercentToBp('0.01')).toBe(1);
28
+ expect(parsePercentToBp('100')).toBe(10000);
29
+ });
30
+
31
+ it('rejects invalid percents', () => {
32
+ expect(parsePercentToBp('100.01')).toBeNull();
33
+ expect(parsePercentToBp('0')).toBeNull();
34
+ expect(parsePercentToBp('12.345')).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe('formatCents', () => {
39
+ it('formats with the currency symbol', () => {
40
+ expect(formatCents(1234, 'USD')).toBe('$12.34');
41
+ expect(formatCents(-500, 'USD')).toBe('-$5.00');
42
+ expect(formatCents(0, 'USD')).toBe('$0.00');
43
+ });
44
+ });
tests/unit/domain/ulid.test.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createUlidFactory, ulid } from '../../../src/lib/domain/ulid';
3
+
4
+ describe('ulid', () => {
5
+ it('produces 26-char Crockford base32 strings', () => {
6
+ expect(ulid()).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
7
+ });
8
+
9
+ it('is monotonic within the same millisecond and sorts by time across ms', () => {
10
+ let t = 1_700_000_000_000;
11
+ const next = createUlidFactory(() => t);
12
+ const a = next();
13
+ const b = next();
14
+ expect(b > a).toBe(true);
15
+ t += 1;
16
+ const c = next();
17
+ expect(c > b).toBe(true);
18
+ expect(c.slice(0, 10) > a.slice(0, 10)).toBe(true);
19
+ });
20
+
21
+ it('encodes the timestamp in the first 10 characters', () => {
22
+ const next = createUlidFactory(() => 0);
23
+ expect(next().slice(0, 10)).toBe('0000000000');
24
+ });
25
+ });