Data model
The whole plan is one coherent object (in cloud mode stored as a single jsonb
record per household). The key pieces:
Portfolio positionsโ
Investments and debt (a loan). Each position has, among other things: current
value, target contribution, rate (interest/return), goal, instalment/term/maturity
date, and a category (categoryId).
Optionally: account kind (accountKind โ IKE/IKZE/PPK/PPE/OIPE/standard/bank,
a tax wrapper), instrument (instrumentType โ ETF/stocks/bonds/โฆ), a JDG
flag (higher IKZE limit), ticker/ISIN/target allocation, owner and beneficiary.
The fields are backward-compatible โ no account kind = behaviour as before.
Off-portfolio assetsโ
Real estate, land, cash, vehicles โ a value and an optional growth rate. Each
asset has a class (assetClass): cash (cash/savings), real_estate
(property; realEstateUsage: primary/rental/land) or physical_asset (tangible:
physicalKind car/equipment/collection). No class โ it is inferred (cash by name,
otherwise real estate), so older data works without migration. The
includeInInvestable / liquid flags drive the wealth lenses (defaults based on
class), while growth describes the behaviour in the projection (see
How we calculate).
Goalsโ
Larger expenses with an amount, a deadline and saving progress. Optional: a linked source (holding or asset id; its name also stays in the text field, readable by older app versions), a "count Saved from the source" flag, and a completed flag with a date. The plan profile also records who changed the shared monthly savings amount, and when.
Monthly entriesโ
Per month: income (per person), contributions/overpayments split by person (the sum feeds the calculations), a savings budget, loan instalment, cost-of-living rating, a note, a marker for "who in the household did the review", and how much has already been applied to portfolio value (so applying is incremental, never double-counted).
Since October 2026 an entry also holds withdrawals / sales from accounts
(withdrawals, one amount for the household) and accounts closed that month
(closedPositionIds). As with contributions, the entry remembers how much of the
withdrawals was already subtracted from values (appliedWithdrawals; for
foreign-currency accounts also the amount in the currency) and the account value
at the moment of closing (appliedClosures). For foreign-currency accounts it
also remembers how many currency units contributions added
(appliedContributionsFx). So a re-save never subtracts anything twice, and
deleting the entry or unticking the closure restores the account exactly as it
was. Selling units (crypto, gold, ETFs with a quantity) is an automatic
auto-sell-YYYY-MM transaction in the position's history.
Closed accounts live separately (closedPositions, each with closedMonth) โ
outside the list of positions, so no current calculation counts them; they stay
for history and "Restore". When a couple syncs, an account present on both sides
is decided by the newer version (an account closed on one device doesn't come
back as active from the other).
Accounts and operationsโ
Since October 2026: accounts (accounts โ name, institution, account type,
owner, free-cash id, closing month) and operations (operations โ account,
date, type, instrument, units, price and currency, amount in PLN, commission, who
paid in, whether a deposit/withdrawal is a flow or a transfer). An account's
instruments are ordinary positions with accountId; free cash is a cash position
with a base (cashBase) for the opening balance and corrections. An instrument's
units and cost come from init-* (opening position) and op-* (from operations)
transactions. Flows (deposits/withdrawals) from operations are written into the
month's entry as amounts on the account's free cash.
Free cash in other currencies: an account has an fxCashIds map (currency โ
position id), and that position holds the balance in the currency (fxAmount,
with the cashBase base also in the currency) and its PLN value. An operation
with cashCurrency and cashAmount (amount in the currency) changes that cash
instead of the zลoty cash. The operation's rate is amount รท cashAmount. The
month's flow is keyed to the cash that received the money. The account also
remembers the total entered in step 3 (brokerValues, month โ amount in PLN). When a couple syncs,
accounts and operations are merged by id (operations from both devices survive),
and balances and flows are recomputed from the merged log.
Categoriesโ
User-defined grouping of positions (categories: Category[] in the state). Each
category has an id, a name, a colour (from the palette) and an optional emoji;
its order in the list = the display order. The default set is Retirement,
Investments, Loan, Bonds, Savings and Crypto (Polish: "Krypto") โ but you can
freely rename, add and remove them (Settings โ Categories). Migration from the
old "pillars" model (the fixed IโV) maps them onto categories with fixed ids
(Bitcoin โ Crypto), without losing any position assignments.
Which category a new position goes toโ
A catalogue template and an import row point to a default category (e.g.
Bitcoin โ crypto, ROS/EDO bonds โ obligacje, gold and other metals โ
inwestycje). Your plan may not have that category, or it may have your own
with the same meaning, e.g. a "Krypto" category you created by hand. So the app
looks, in order, for:
- a category with the same
id, - a category with the same meaning: it compares the name and
idignoring case and Polish diacritics against a PL/EN/DE word list (e.g. krypto, crypto, kryptowaluty, bitcoin; obligacje, bonds, Anleihen; emerytura, retirement, Rente; oszczฤdnoลci, savings; kredyt, mortgage, loan), - if there is none โ it adds the missing default category (name in the app's language, a free palette colour) and puts the position there.
Only a position whose category can't be recognised goes to the first category in the list. Broker import picks the category by instrument: crypto โ Crypto, retail bonds โ Bonds, savings account and deposit โ Savings, ETFs, stocks, funds and metals โ Investments.
When a plan is loaded (from the device, from a backup, and from the cloud โ after
decryption), a position with an unknown category id also first looks for a
category with that meaning, then for one matching its instrument, and only then
goes to the first category in the list.
One-time repair (schema 9). Plans saved before this change get two fixes on their first load:
- crypto positions (instrument type "crypto") sitting in another category, e.g. Investments, move to the plan's crypto category โ the default one or your own with that meaning. Other positions stay where they were. If the plan has no crypto category, nothing changes (no categories are created on load). If you later deliberately move Bitcoin back, it stays where you put it โ the repair runs once;
- the default
cryptocategory with the old name exactly "Crypto" gets today's name "Krypto" โ only in plans whose country is Poland or Germany. A name you chose yourself is never changed.
The repair doesn't touch financial data: it only changes category assignment (Dashboard, monthly entry), so net worth, FIRE and projections are computed the same way.
Profile and preferencesโ
- Plan profile (shared in Duo): people (
members), country, main goal. Each person has a name, emoji, date of birth and โ for adults โ a JDG (self-employed) flag that raises the IKZE limit. Older plans kept the birth date only in the profile (profile.birthDate); for the main person we read it from there when the person has none โ no data migration. New writes go to the person. - Device preferences (NOT synced): language, theme, role in the plan.
Net-worth snapshotsโ
A lightweight record of net worth per month โ it powers the wealth trend (30 days / 12 months).
Every read and write of the data goes through validation (Zod) โ see Security.