Domain Model¶
The domain layer (src/domain/) is pure TypeScript — no framework, no I/O, no npm packages. It defines what the system is, independently of how it communicates or where it stores data.
Entities¶
Transaction¶
The central aggregate. Represents a single financial movement.
interface Transaction {
id: string; // UUID, generated at creation
date: string; // ISO date (YYYY-MM-DD)
description: string; // free text label
category: string; // from user's Budgets sheet
type: TransactionType;
amount: Money; // always correctly signed
account: string; // one of the valid accounts
status: TransactionStatus;
notes: string;
}
Amount signing convention enforced by AddTransactionUseCase:
- Income / Retrieval → positive
- Expense / Investment → negative
User¶
Represents a registered Zentr user. Stored in D1.
interface User {
chatId: string; // Telegram chat ID (primary key)
sheetId: string; // Google Sheets spreadsheet ID
refreshToken: string; // Google OAuth2 refresh token
plan: 'free' | 'pro';
locale: Locale; // preferred language for bot messages
createdAt: string; // ISO datetime
}
Value Objects¶
Money¶
interface Money {
amount: number; // signed (negative = outflow)
currency: string; // always 'EUR' today
}
TransactionType¶
| Type | Amount sign | Semantic |
|---|---|---|
Income |
positive | Money received |
Expense |
negative | Money spent |
Investment |
negative | Money blocked (still yours, not liquid) |
Retrieval |
positive | Blocked money recovered |
Investment / Retrieval are excluded from per-diem calculation but count toward net worth.
TransactionStatus¶
| Status | Meaning |
|---|---|
Paid / Received |
Settled |
Pending |
Agreed, not yet moved |
Scheduled |
Confirmed future transaction |
Planned |
Tentative future |
Deferred |
Real expense pushed to next period; auto-settled by cron |
Default status from defaultStatusForType():
- Income / Retrieval → Received
- Expense / Investment → Paid
Digest Value Objects¶
Read-only projections passed from infrastructure to application to ports.
DailyDigest¶
interface DailyDigest {
date: string;
spent: DailyDigestEntry[]; // { description, amount, category }
income: DailyDigestEntry[];
netBalance: number;
perDiemReal: number; // actual spend rate for the period so far
perDiemIdeal: number; // target rate based on available budget
availablePeriod: number; // remaining budget for current pay period
daysToPayday: number;
}
WeeklyDigest¶
interface WeeklyDigest {
weekStart: string;
weekEnd: string;
totalSpent: number;
totalIncome: number;
balance: number;
byCategory: Record<string, number>;
byDayOfWeek: Record<string, number>;
perDiemRealAvg: number;
perDiemIdeal: number;
desiredSavings: number; // from _Cobros sheet, matched by next payday month
aiAnalysis: string; // Gemini output
}
Repository Ports¶
Interfaces defined in domain/ that infrastructure adapters must implement.
TransactionRepository¶
interface TransactionRepository {
add(tx: Transaction): Promise<void>;
findAll(): Promise<Transaction[]>;
findOpen(): Promise<Transaction[]>; // Pending | Scheduled | Planned | Deferred
findDeferred(): Promise<Transaction[]>;
settle(id: string, date: string): Promise<void>;
}
UserRepository¶
interface UserRepository {
findById(chatId: string): Promise<User | null>;
save(user: User): Promise<void>;
all(): Promise<User[]>;
}
Net Worth Philosophy¶
Defined in domain terms (drives Sheets formulas):
- Liquid: Revolut + Cuenta + Ventus + Efectivo
- Net worth base: Liquid + Joint (50%) + Indexed
- Excluded from per-diem: Volatile (speculative)
- Blocked capital: Joint + Indexed + Volatile (total non-liquid patrimony)
The Joint account tracks a shared account with a partner; only 50% counts as the user's net worth.