Ledger Entry Accounting Model#
Sure uses a dual-entry accounting system built on three core models: Entry, Transaction, and Transfer. Every monetary event — income, expense, or movement between accounts — is stored as one or more Entry rows, each carrying a signed amount that encodes direction.
Amount Sign Convention#
The Entry#classification method encodes the rule directly :
- Negative amount →
"income"(inflow / credit) - Positive amount →
"expense"(outflow / debit)
This convention is enforced at the controller layer: TransactionsController#entry_params converts the unsigned form value to a signed amount based on the hidden nature field — "inflow" maps to negative, "outflow" to positive .
Entry Structure#
Entry belongs to an account and delegates its domain type to one of three entryable types via Rails delegated_type:
| Entryable type | Purpose |
|---|---|
Transaction | Income, expense, or transfer leg |
Valuation | Point-in-time balance snapshot |
Trade | Investment buy/sell event |
Each Entry holds amount, currency, date, name, and optional transfer (via belongs_to :transfer) and parent_entry (for splits) associations .
How Transfers Pair Two Entries#
A Transfer is a join record linking exactly two Transaction rows: an outflow (positive amount) on the source account and an inflow (negative amount) on the destination account .
Transfer
├── outflow_transaction → Entry (amount > 0) on source account
└── inflow_transaction → Entry (amount < 0) on destination account
The model enforces balance via transfer_has_opposite_amounts: the inflow must be negative, the outflow positive, and for same-currency transfers they must sum to zero .
Additional constraints :
- Accounts must be different and belong to the same family
- Entry dates must be within 4 days (pending) or 30 days (confirmed) of each other
Creating a Transfer: Transfer::Creator#
Transfer::Creator is the sole factory for new transfers. It accepts family, source_account_id, destination_account_id, date, amount, and optional exchange_rate, source_fee_amount, and destination_fee_amount.
Calling #create inside a single DB transaction:
- Builds the outflow transaction with
amount(positive) on the source account - Builds the inflow transaction with
net_inflow * -1(negative) on the destination account, currency-converting if needed - Appends optional
standard-kind fee transactions to either side - Saves the
Transfer, then callssync_lateron both accounts
Outflow kind Assignment#
The outflow transaction's kind controls how the transfer leg flows through budget and income-statement analytics. Transfer::Creator#outflow_transaction_kind picks the kind based on the destination account type:
| Destination account | kind |
|---|---|
loan? | loan_payment |
liability? (credit card, etc.) | cc_payment |
investment? or crypto? — and source is not | investment_contribution |
| everything else | funds_movement |
The inflow transaction always receives kind: "funds_movement" regardless of account type .
When kind == "investment_contribution", the outflow is also auto-assigned the family's Investment Contributions category .
The class-level Transfer.kind_for_account mirrors this routing for display and matching use cases, but only accepts a single account — it lacks the !source_is_investment? guard present in Creator, which causes a known misclassification bug for investment-to-investment transfers .
Budget & Reporting Exclusion#
Two constants on Transaction determine analytics inclusion :
TRANSFER_KINDS=[funds_movement, cc_payment, loan_payment, investment_contribution]— identifies all transfer legs for search filter splits.BUDGET_EXCLUDED_KINDS=[funds_movement, one_time, cc_payment]— dropped from budget/income-statement queries.
loan_payment and investment_contribution are intentionally absent from BUDGET_EXCLUDED_KINDS — they represent real cash outflow and appear as expenses in Reports.
Destroying a Transfer#
Transfer#destroy! resets both linked transactions' kind back to "standard" before destroying the join record, preventing orphaned transactions from retaining transfer-specific kinds. reject! additionally creates a RejectedTransfer record to suppress re-detection of the same pair.
Key Files#
| File | Purpose |
|---|---|
app/models/entry.rb | Core ledger record; amount sign convention, split logic, classification |
app/models/transfer.rb | Transfer join record; validations, kind_for_account, destroy!, reject! |
app/models/transfer/creator.rb | Service object for building paired dual entries |