Spaces:
Sleeping
Sleeping
Commit ·
3c22061
1
Parent(s): 6ea96a3
feat(domain): add monotonic ULID generator and money parsing/formatting
Browse files- src/lib/domain/money.ts +32 -0
- src/lib/domain/ulid.ts +52 -0
- tests/unit/domain/money.test.ts +44 -0
- tests/unit/domain/ulid.test.ts +25 -0
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 |
+
});
|