Skip to content

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 / RetrievalReceived - Expense / InvestmentPaid

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.