assafvayner HF Staff commited on
Commit
6ea96a3
·
1 Parent(s): 52ec2ea

feat(domain): add core types and zod schemas

Browse files
src/lib/domain/schemas.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from 'zod';
2
+
3
+ export const USERNAME_RE = /^[a-z0-9_.-]{3,32}$/;
4
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
5
+
6
+ export function isValidDate(s: string): boolean {
7
+ if (!DATE_RE.test(s)) return false;
8
+ const d = new Date(`${s}T00:00:00Z`);
9
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
10
+ }
11
+
12
+ export const DateString = z.string().refine(isValidDate, 'Invalid date');
13
+ export const IsoTimestamp = z.string().refine((s) => !Number.isNaN(Date.parse(s)), 'Invalid timestamp');
14
+ export const Cents = z.number().int().positive();
15
+ export const UserId = z.string().min(1);
16
+
17
+ export const UserSchema = z.object({
18
+ id: UserId,
19
+ username: z.string().regex(USERNAME_RE),
20
+ displayName: z.string().trim().min(1).max(120),
21
+ passwordHash: z.string().min(1),
22
+ role: z.enum(['admin', 'member']),
23
+ active: z.boolean(),
24
+ mustChangePassword: z.boolean(),
25
+ sessionVersion: z.number().int().nonnegative(),
26
+ createdAt: IsoTimestamp,
27
+ updatedAt: IsoTimestamp
28
+ });
29
+
30
+ export const SplitSchema = z.discriminatedUnion('mode', [
31
+ z.object({ mode: z.literal('equal'), participants: z.array(UserId).min(1) }),
32
+ z.object({ mode: z.literal('percent'), bp: z.record(UserId, z.number().int().positive()) }),
33
+ z.object({ mode: z.literal('exact'), cents: z.record(UserId, Cents) })
34
+ ]);
35
+
36
+ const AuditSchema = {
37
+ createdBy: UserId,
38
+ createdAt: IsoTimestamp,
39
+ updatedBy: UserId,
40
+ updatedAt: IsoTimestamp
41
+ };
42
+
43
+ export const ExpensePhotoSchema = z.object({
44
+ key: z.string().min(1),
45
+ contentType: z.string().min(1),
46
+ width: z.number().int().positive().optional(),
47
+ height: z.number().int().positive().optional()
48
+ });
49
+
50
+ export const ExpenseSchema = z.object({
51
+ id: z.string().min(1),
52
+ version: z.number().int().positive(),
53
+ description: z.string().trim().min(1).max(120),
54
+ amountCents: Cents,
55
+ currency: z.string().length(3),
56
+ date: DateString,
57
+ paidBy: UserId,
58
+ split: SplitSchema,
59
+ shares: z.record(UserId, Cents),
60
+ photo: ExpensePhotoSchema.optional(),
61
+ notes: z.string().max(2000).optional(),
62
+ ...AuditSchema
63
+ });
64
+
65
+ export const SettlementSchema = z
66
+ .object({
67
+ id: z.string().min(1),
68
+ version: z.number().int().positive(),
69
+ from: UserId,
70
+ to: UserId,
71
+ amountCents: Cents,
72
+ date: DateString,
73
+ note: z.string().max(2000).optional(),
74
+ ...AuditSchema
75
+ })
76
+ .refine((s) => s.from !== s.to, { message: 'from and to must differ', path: ['to'] });
77
+
78
+ export const UsersFileSchema = z.object({
79
+ version: z.literal(1),
80
+ users: z.array(UserSchema)
81
+ });
82
+ export type UsersFile = z.infer<typeof UsersFileSchema>;
src/lib/domain/types.ts CHANGED
@@ -1,3 +1,81 @@
 
 
1
  export interface User {
2
  id: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  }
 
1
+ export type Role = 'admin' | 'member';
2
+
3
  export interface User {
4
  id: string;
5
+ username: string;
6
+ displayName: string;
7
+ passwordHash: string;
8
+ role: Role;
9
+ active: boolean;
10
+ mustChangePassword: boolean;
11
+ sessionVersion: number;
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ }
15
+
16
+ export type PublicUser = Omit<User, 'passwordHash'>;
17
+
18
+ export function toPublicUser(u: User): PublicUser {
19
+ const { passwordHash: _omit, ...rest } = u;
20
+ return rest;
21
+ }
22
+
23
+ export type Split =
24
+ | { mode: 'equal'; participants: string[] }
25
+ | { mode: 'percent'; bp: Record<string, number> }
26
+ | { mode: 'exact'; cents: Record<string, number> };
27
+
28
+ export type SplitMode = Split['mode'];
29
+
30
+ export interface ExpensePhoto {
31
+ key: string;
32
+ contentType: string;
33
+ width?: number;
34
+ height?: number;
35
+ }
36
+
37
+ export interface AuditFields {
38
+ createdBy: string;
39
+ createdAt: string;
40
+ updatedBy: string;
41
+ updatedAt: string;
42
+ }
43
+
44
+ export interface Expense extends AuditFields {
45
+ id: string;
46
+ version: number;
47
+ description: string;
48
+ amountCents: number;
49
+ currency: string;
50
+ date: string;
51
+ paidBy: string;
52
+ split: Split;
53
+ shares: Record<string, number>;
54
+ photo?: ExpensePhoto;
55
+ notes?: string;
56
+ }
57
+
58
+ export interface Settlement extends AuditFields {
59
+ id: string;
60
+ version: number;
61
+ from: string;
62
+ to: string;
63
+ amountCents: number;
64
+ date: string;
65
+ note?: string;
66
+ }
67
+
68
+ /** Everything the caller supplies; id/version/audit fields are added by the Ledger. */
69
+ export type ExpenseBody = Omit<Expense, 'id' | 'version' | keyof AuditFields>;
70
+ export type SettlementBody = Omit<Settlement, 'id' | 'version' | keyof AuditFields>;
71
+
72
+ export function splitParticipants(split: Split): string[] {
73
+ switch (split.mode) {
74
+ case 'equal':
75
+ return [...split.participants];
76
+ case 'percent':
77
+ return Object.keys(split.bp);
78
+ case 'exact':
79
+ return Object.keys(split.cents);
80
+ }
81
  }
tests/unit/domain/schemas.test.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ExpenseSchema, SettlementSchema, UserSchema } from '../../../src/lib/domain/schemas';
3
+
4
+ const baseExpense = {
5
+ id: 'e_01HZX',
6
+ version: 1,
7
+ description: 'Dinner',
8
+ amountCents: 3000,
9
+ currency: 'USD',
10
+ date: '2026-08-29',
11
+ paidBy: 'u_a',
12
+ split: { mode: 'equal', participants: ['u_a', 'u_b'] },
13
+ shares: { u_a: 1500, u_b: 1500 },
14
+ createdBy: 'u_a',
15
+ createdAt: '2026-08-29T10:00:00.000Z',
16
+ updatedBy: 'u_a',
17
+ updatedAt: '2026-08-29T10:00:00.000Z'
18
+ };
19
+
20
+ describe('ExpenseSchema', () => {
21
+ it('accepts a valid equal-split expense', () => {
22
+ expect(ExpenseSchema.parse(baseExpense)).toEqual(baseExpense);
23
+ });
24
+
25
+ it('rejects non-integer or non-positive amounts', () => {
26
+ expect(ExpenseSchema.safeParse({ ...baseExpense, amountCents: 12.5 }).success).toBe(false);
27
+ expect(ExpenseSchema.safeParse({ ...baseExpense, amountCents: 0 }).success).toBe(false);
28
+ });
29
+
30
+ it('rejects malformed dates', () => {
31
+ expect(ExpenseSchema.safeParse({ ...baseExpense, date: '2026-13-01' }).success).toBe(false);
32
+ expect(ExpenseSchema.safeParse({ ...baseExpense, date: '29/08/2026' }).success).toBe(false);
33
+ });
34
+
35
+ it('accepts percent and exact splits', () => {
36
+ const pct = { ...baseExpense, split: { mode: 'percent', bp: { u_a: 5000, u_b: 5000 } } };
37
+ const exact = { ...baseExpense, split: { mode: 'exact', cents: { u_a: 1000, u_b: 2000 } } };
38
+ expect(ExpenseSchema.safeParse(pct).success).toBe(true);
39
+ expect(ExpenseSchema.safeParse(exact).success).toBe(true);
40
+ });
41
+
42
+ it('rejects unknown split modes', () => {
43
+ expect(ExpenseSchema.safeParse({ ...baseExpense, split: { mode: 'random' } }).success).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe('SettlementSchema', () => {
48
+ it('rejects from === to', () => {
49
+ const s = {
50
+ id: 's_1', version: 1, from: 'u_a', to: 'u_a', amountCents: 100, date: '2026-08-29',
51
+ createdBy: 'u_a', createdAt: '2026-08-29T10:00:00.000Z', updatedBy: 'u_a', updatedAt: '2026-08-29T10:00:00.000Z'
52
+ };
53
+ expect(SettlementSchema.safeParse(s).success).toBe(false);
54
+ expect(SettlementSchema.safeParse({ ...s, to: 'u_b' }).success).toBe(true);
55
+ });
56
+ });
57
+
58
+ describe('UserSchema', () => {
59
+ it('enforces username charset and length', () => {
60
+ const u = {
61
+ id: 'u_1', username: 'bob', displayName: 'Bob', passwordHash: 'x', role: 'member', active: true,
62
+ mustChangePassword: false, sessionVersion: 1, createdAt: '2026-08-29T10:00:00.000Z', updatedAt: '2026-08-29T10:00:00.000Z'
63
+ };
64
+ expect(UserSchema.safeParse(u).success).toBe(true);
65
+ expect(UserSchema.safeParse({ ...u, username: 'Bo' }).success).toBe(false);
66
+ expect(UserSchema.safeParse({ ...u, username: 'bob smith' }).success).toBe(false);
67
+ });
68
+ });