The main entity that represents a financial event. Transactions are built using a hierarchical structure that follows double-entry bookkeeping principles.
Transaction (financial event)
└── Operation (individual account posting)
Note: The
Entryentity has been removed. Operations now belong directly to a transaction viatransactionId.
- Contains from
MIN_TRANSACTION_OPERATIONStoMAX_TRANSACTION_OPERATIONSactive operations (currently 2 to 1000); the count does not have to be a pair or a multiple of two - The transaction is balanced when the sum of all operation
valuefields equals zero - Supports split transactions
- Contains metadata (date, description, etc.)
Represents a single financial posting affecting an account.
- Links to a transaction (
transactionId) and an account (accountId) - Belongs to a user (
userId) amount— signed integer in the account's Commodity minor unitsvalue— signed integer in the transaction Commodity minor units; used for balance validationamountandvaluemust be valid integer minor-unit values.NaN,Infinity, missing values, and decimal/floating-point values are invalid. Zero is allowed and is not rejected by the domain model.- For same-currency transactions
amount === value - Positive = debit, Negative = credit
- May be soft-deleted in persistence via
isTombstone - Tombstone operations remain part of the raw transaction aggregate state for persistence, but are excluded from active domain accessors and read API responses
- Has optional description field
Temporarily deprecated: Trading operations are not created at this time. The system currently validates balance by summing
valueacross all operations of a transaction (must equal 0). Automated reconciliation postings may be introduced later for full multi-currency reconciliation.
Represents different financial accounts with unified structure for all account types.
- Type: Asset, Liability, Income, Expense
- Asset: Real money (wallet, card, bank account)
- Liability: Debts, loans, credit
- Income: Revenue sources (salary, interest)
- Expense: Spending categories
- Has a designated Commodity
- Balance tracking:
- For Asset/Liability: real balance comes from ledger operations/read models
- For Income/Expense: reporting metric (sum over period), calculated from operations
- Balance is calculated from operations
- Reversible account close state is represented by
isClosed - Terminal account deletion is represented by
isTombstone GET /accountsreturns open accounts by default and supportsstatus=open|closed|all;allincludes open and closed non-tombstoned accounts, and all normal list filters exclude tombstoned accounts- Closed accounts keep history and reports but cannot receive new operations
- Closed accounts can still have descriptive fields edited through the regular
account update flow, but account
typecannot be changed while closed - Account
typecan be changed only while the account has no active operations DELETE /accounts/:idmeans terminal tombstone delete and is rejected while active operations still reference the accountDELETE /accounts/:idis idempotent for an account that already belongs to the user and already hasisTombstone = true. Delete uses the lifecycle-awaregetByIdForLifecycle(...)lookup throughensureOwnedSnapshotso repeated deletes can see tombstoned accounts. Normal reads, list filters, update, close and open still exclude tombstoned accounts.
Represents a user-owned monetary unit used in the system. Commodity identity is
a stable id, not code; display codes such as USD, EUR, or RUB are
metadata owned by each user. See
ADR 0006.
- Each account has a designated Commodity
- Account Commodity is immutable after account creation
- Transactions have
commodityId, the transaction Commodity that determinesvaluedenomination - Operations always store
amountin the account's Commodity - Commodity
code,name, andsymbolare display metadata, not identity - Commodity
precisiondefines integer minor-unit interpretation - Reversible Commodity close state is represented by
isClosed - Terminal Commodity deletion is represented by
isTombstone GET /commoditiesreturns open Commodities by default and supportsstatus=open|closed|all; all normal list filters exclude tombstoned commoditiesGET /commodities/:idreturns non-tombstoned Commodities owned by the authenticated user- Closed Commodities remain readable and editable, but cannot be selected for new account references until opened again
DELETE /commodities/:idmeans terminal tombstone delete and is idempotent for a Commodity that already belongs to the user and already hasisTombstone = true
Domain entities use one public API pattern for creation, restoration and plain
state export. The current reference implementation is Transaction together
with application response mappers and infrastructure persistence mappers.
static create(...)creates a new entity and generates a new identity, timestamps and other behavior state.static restore(snapshot)restores an entity from a plain domain snapshot.toSnapshot()returns the entity's full plain domain state. It must not silently filter child state, soft-deleted state or raw aggregate members.- Domain entities do not import DB schema types, application DTO, shared request/response DTO, or HTTP-specific types.
- Domain entities do not expose
toPersistence(),toResponseDTO()orfromPersistence(...). - DB and API transformations live in boundary-specific mappers. Persistence
mapping belongs at the infrastructure boundary, while response output
mapping belongs in application or presentation code. Both should be derived
from domain snapshots, not from entity-owned persistence/DTO methods. For
example,
AccountPersistenceMapper.toDBRowFromSnapshot(snapshot)andAccountMapper.toResponseDTOFromSnapshot(snapshot)are outside the domain entity.
Entity timestamps are domain state. Repositories must not generate entity
id, createdAt or updatedAt values. create(...) creates identity and
initial timestamps, and behavior methods such as update(...) or delete()
update updatedAt when they change entity state.
Repositories persist timestamps received through snapshots/mappers.
Soft-delete is a domain state transition, not an implicit repository command.
The shared SoftDelete.markAsDeleted() behavior rejects repeated low-level
deletion, while entity-level delete() methods may expose an idempotent
business command by returning unchanged without mutating updatedAt again.
Repository APIs that persist this transition must accept the domain timestamp
produced by the entity.
Application/use case APIs may use business-facing lifecycle names such as
close... and open..., and HTTP routes may still expose
DELETE /resource/:id commands. Current account and commodity repositories use
delete(...) as the terminal tombstone persistence hook; it must not be used as
a normal read/update path and should not perform physical row deletion.
Snapshot types live next to the entity in domain/<module>/types.ts. They use
primitive/domain-safe fields and must not be aliases for DB rows or response
DTOs.
Filtered snapshots or projections must use explicit names that describe the
filter, for example toActiveSnapshot() for an aggregate view containing only
active child entities. These projection methods are domain-specific and are not
required for every entity.
getId(): Id is the canonical domain identity accessor for entities. New
domain code should use getId() while inside domain/application boundaries and
convert to primitive UUID only at mapper, repository, HTTP or shared DTO
boundaries through getId().valueOf().
User.id currently returns a primitive UUID as a legacy convenience for
existing application, HTTP and test call sites. Do not copy this pattern to new
entities or new code. Removing this compatibility getter and normalizing entity
identity access is tracked by Jira
LED-82.
export class ExampleEntity {
static create(props: CreateExampleProps): ExampleEntity {
// Generate identity, timestamps and domain behavior state.
}
static restore(snapshot: ExampleSnapshot): ExampleEntity {
// Rebuild value objects and behaviors from plain domain state.
}
toSnapshot(): ExampleSnapshot {
// Return the full primitive/domain-safe state.
}
}See ADR 0011 for the architectural decision and rationale.
Value objects use one public API pattern for validated construction, restoration from plain state and comparison.
static create(...)creates a value object from new user/application input and applies input normalization when needed.static restore(...)restores a value object from already persisted or plain domain state. It must preserve the stored value semantics and should not apply user-input-only normalization unless that normalization is part of the persisted invariant.equals(other)is the single public value equality method. Value objects must not expose alternate equality aliases.valueOf()returns the primitive/domain-safe value used in snapshots and mapper boundaries.- Domain value objects and behaviors do not expose
fromPersistence(...). Domain code restores persisted/plain state throughrestore(...)and exports primitive/domain-safe values throughvalueOf(). Mapper or repository classes outside the domain layer may still usefromPersistence(...)as a method name when their input shape is explicitly persistence-specific, for example DB row to read model mapping. - Immutable value objects are frozen at runtime with
Object.freeze(this)after constructor state is initialized.create(...),restore(...)and non-mutating operations such asadd(...),subtract(...)orincrement()must return frozen instances. Domain entities are not frozen by this rule because entity lifecycle changes are modeled through explicit domain methods.
See ADR 0015 for the restoration naming decision.
HTTP endpoints use route -> controller -> use case -> repository/domain as
the canonical backend request flow.
Routes own Fastify registration, transport URL shape, authenticated request
context extraction, params/query parsing where appropriate and simple transport
statuses such as 201 or 204. Controllers orchestrate request/response
concerns that need Fastify objects, including body validation where that is the
chosen local pattern and JWT signing for auth endpoints. Use cases own
application operations, authorization/ownership checks, domain coordination,
repository interfaces and transaction boundaries. Mappers own conversion
between domain snapshots, read models, response DTOs and persistence shapes.
Repositories own persistence access.
Commodity-backed account and transaction writes split validation by concern: application policies enforce business lifecycle rules, while repositories and database constraints protect persistence integrity. See ADR 0021.
New endpoint operations should be implemented as application use cases.
apps/backend/src/application/services/* is reserved for helper orchestration
used by use cases. Root-level apps/backend/src/services/* is legacy and must
not be used for new HTTP endpoint operations.
See ADR 0016 for the full decision, validation/JWT/transaction-boundary guidance and follow-up migration tasks.
- Each transaction must contain from
MIN_TRANSACTION_OPERATIONStoMAX_TRANSACTION_OPERATIONSactive operations (currently 2 to 1000; seetransactions.ts) - The operation count does not have to be a pair or a multiple of two
- Balance rule: sum of
valueacross all operations in a transaction must equal zero - Positive amount = debit, Negative amount = credit
- Monetary fields (
amountandvalue) are integer minor-units and must be finite valid values.0is allowed. - System-wide balance: sum of all operations across all accounts must equal zero
- There is no minimum number of distinct accounts per transaction. A transaction may be economically meaningless but still valid when it is balanced and does not violate the base invariants.
- Reusing the same account within one transaction is allowed even when the
account-level sum of
amountis zero. Example:Cash -100andCash +100can be a valid transaction when the transaction-level balance rule is satisfied; there is no separate "non-zero net effect per account" invariant.
- Each operation carries both
amount(account Commodity) andvalue(transaction Commodity) - For same-currency operations
amount === value - Trading operations are not currently implemented; they are reserved for a future multi-currency reconciliation phase
- Asset/Liability accounts: Balance is stored and must match real-world balance
- Income/Expense accounts: Balance is calculated as sum of operations, used only for reporting
- Global balance rule: sum of all operations across all accounts = 0
- Transactions use soft delete via
isTombstone - Tombstone transactions must not appear in normal read API responses
- Operations may also be marked with
isTombstonein persistence - Transaction repositories restore the full raw aggregate state, including tombstone operations
- The
Transactionaggregate separates raw and active operation access:toSnapshot()returns the full aggregate snapshot, including tombstone operationstoActiveSnapshot()returns an active-only snapshot when a snapshot-shaped projection is neededgetAllOperations()returns all known operations for persistencegetOperations()returns active operations only for domain logic- tombstone operations are not returned to clients and are ignored by normal read flows
- Tombstone operations cannot be updated after they are restored into the aggregate
Transactionis the aggregate root and owns the concurrency boundary for its operations- Every transaction update supplies the expected
Transaction.version - Changes to transaction metadata or operations increment
Transaction.versiononce per aggregate update - The repository updates the transaction with compare-and-update semantics before saving its operations in the same database transaction
Operationdoes not have a separate version because it has no independent write API or use case- Operation-level versioning should be introduced only if operations become independently mutable outside the
Transactionaggregate
Transactionis the only application write boundary for its operations- Operations are created, updated, and deleted only through transaction use cases
Operationhas no independent write API or public application use cases- Operation mappers, domain entities, and persistence collaborators are internal details of the transaction flow
- This boundary should be reconsidered only if operations gain an independent lifecycle, authorization model, version, API, or background processing
- See ADR 0002: Operation application boundary
accountIdselects transactions containing at least one active operation for the account, while the response includes all active operations of each matching transactiondateFromanddateTofiltertransactionDateinclusively- Pagination is page-based with defaults
page=1andpageSize=20;pageSizecannot exceed 100 - Results are sorted by
transactionDate DESCby default - Clients may sort by
transactionDateorpostingDatein ascending or descending order createdAtandidare deterministic tie-breakers for pagination- Tombstone transactions and operations are always excluded; the list API does not support
includeTombstone
Scenario: Buy groceries for 10000 kopeks (100₽) in cash
Transaction
| id | description | transactionDate | postingDate | userId |
|---|---|---|---|---|
| T1 | Buy groceries | 2025-09-17 | 2025-09-17 | U1 |
Operations
| id | transactionId | accountId | account | amount (kopeks) | value (kopeks) |
|---|---|---|---|---|---|
| O1 | T1 | A1 | Asset:Cash | -10000 | -10000 |
| O2 | T1 | A2 | Expense:Food | +10000 | +10000 |
Balance: sum(value) = -10000 + 10000 = 0 ✓
Note: Amounts are stored as integers (kopeks/cents) to avoid floating-point precision issues.
Scenario: Buy goods for 9 EUR, pay with cash in USD (10 USD = 1000 cents)
When trading accounts are introduced, the transaction will look like:
Operations
| id | transactionId | accountId | account | amount (cents) | value (USD cents) |
|---|---|---|---|---|---|
| O3 | T2 | A3 | Asset:Cash USD | -1000 | -1000 |
| O4 | T2 | A4 | System:Trading:USD | +1000 | +1000 |
| O5 | T2 | A5 | System:Trading:EUR | -900 | +900 |
| O6 | T2 | A6 | Expense:Goods EUR | +900 | -900 |
Balance: sum(value) = 0 ✓ — currently this phase is not implemented.
- Backend: Node.js 22.14.0
- API Server: Fastify
- Database: SQLite
- ORM: Drizzle
- Package Manager: pnpm 10.10.0
- Validation: Zod
Transaction
- id: UUID
- сommodityId: UUID (FK) -- denominates operation value fields
- description: string
- transactionDate: date (ISO string)
- postingDate: date (ISO string)
- version: integer -- optimistic concurrency token for the aggregate
- isTombstone: boolean
- userId: UUID (FK)
- createdAt: timestamp
- updatedAt: timestamp
Operation
- id: UUID
- transactionId: UUID (FK) -- directly linked to Transaction (Entry removed)
- accountId: UUID (FK)
- amount: integer -- in account Commodity minor units
- value: integer -- in transaction Commodity minor units; used for balance validation
- description: string (optional)
- isTombstone: boolean
- userId: UUID (FK)
- createdAt: timestamp
- updatedAt: timestamp
Account
- id: UUID
- name: string
- type: enum (Asset, Liability, Income, Expense)
- commodityId: UUID (FK)
- description: string
- isTombstone: boolean
- userId: UUID (FK)
- createdAt: timestamp
- updatedAt: timestamp
Commodity
- id: UUID
- userId: UUID (FK)
- code: string -- display/search value, unique per user
- name: string
- symbol: string (optional)
- precision: integer -- immutable after creation in the MVP
- isTombstone: boolean
- createdAt: timestamp
- updatedAt: timestamp
Settings
- userId: UUID (FK)
- createdAt: timestamp
- updatedAt: timestamp
- Money amounts: Stored as integers (cents/kopeks) to avoid floating-point precision issues
- Dates: ISO date strings for transactionDate and postingDate
- Timestamps: ISO datetime strings with branded types (
IsoDatetimeString) - IDs: UUIDs for all entities
- Soft deletion: Uses
isTombstoneflag instead of hard deletes - MVP operation deletion rule: deleted operations may remain in raw aggregate and storage state, but are excluded from active domain/read behavior
- Budget tracking
- Currency rate caching and historical tracking
- Automatic currency conversion through trading entries
- Account-based reports (balance sheets, income statements)
- Balance forecasting
- Recurring transactions
- Transaction templates
- Enhanced validation layers:
- Schema validation (Zod)
- Domain validation (business rules)
- Database constraints (Drizzle)
- Branded types for Commodity codes, Commodity precision, and other primitive domain values where useful
- Operation hash-based idempotent updates
- Enhanced error handling with domain-specific errors
- Domain-Driven Design (DDD):
- Transaction as Aggregate Root containing Operations
- Strong business invariants enforcement
- Clean Architecture principles:
- Domain layer (entities + business rules)
- Application layer (use cases)
- Infrastructure layer (repositories, database)
- Presentation layer (API/controllers)
- Validation at multiple levels:
- Schema validation (Zod)
- Domain validation (business rules, double-entry balance)
- Database constraints (foreign keys, unique constraints)
- Data layer separation:
- DbRow (database representation)
- Repository DTOs (data transfer)
- Domain entities (business logic)
- Service DTOs (application layer)
- Repository patterns:
- Minimal business logic in repositories
- Idempotent operations (return
undefinedinstead of throwing errors) - User ownership checks in service layer
- Read-side returns active data only
- Write-side repositories restore and persist raw aggregate state, including tombstone operations
- Domain business accessors expose active operations by default
- Type safety:
- Branded types for dates (
IsoDatetimeString) - Branded types for Commodity codes, Commodity precision, dates, and other primitive domain values where useful
- Strict TypeScript configuration
- Error handling
- Response serialization
- Branded types for dates (