[1.4.0] - 2026-10-29
Bug Fixes
- Ship the maintained shoutrrr fork and stop leaking tokens by @John710
containrrr/shoutrrr 0.8.0 rejects Gotify 3.x application tokens (gtfya.…),
so every Gotify notification failed. Switch the image to
nicholas-fedor/shoutrrr 0.22.1 with per-arch sha256 pinned in the
Dockerfile, and point the settings docs links at its documentation.
Error messages and logs carried the full target URL, including tokens in
the path; they now show only scheme://host and a redacted stderr. - Keep installment schedules, receipts and paid status consistent by @John710
Code-review fixes for the debt-installments backend:- PUT /debts/:id took a new amount or currency without touching the
schedule, so the installments stopped adding up to the debt. It now
accepts installments: { rows } and replaces the schedule in the same
transaction under the debt row lock; an amount/currency change on a debt
with a schedule and no new rows is 409 installments_need_update, and any
paid or skipped installment makes it 409 installments_locked. - The "untouched transaction" test ignored attachments, so undoing a
payment or deleting a debt deleted a transaction with receipts (they
cascade). Such transactions, and creation bookings with receipts, are now
kept like hand-edited ones. - Undoing an installment payment whose transaction was deleted or moved by
hand answered 409 payment_edited forever; the payment row is now removed
without reversing anything. 409 stays for a transaction that still exists
but was edited. - DELETE /debts reports booked payments without a matching transaction in
unmatched_payments instead of dropping them silently. - Undo reopened a debt the owner had closed by hand. Migration 39 adds
debts.auto_paid (set when payments close the debt, cleared when the owner
changes the status), and undo only reopens automatically closed debts. - Cross-currency payments could leave dust (99.999999999999 of 100), so the
debt never closed: coverage now allows half a minor unit of the debt
currency. - Calendar rows carry can_edit: owner or an 'edit' share for installments;
the owner only for subscriptions, whose pay/skip routes are owner-only. - iCal escapeText left a lone CR raw; all line breaks become \n.
- PUT /debts/:id took a new amount or currency without touching the
- Review fixes for installments UI (single PUT, safe undo, row actions) by @John710
Save the debt and its schedule in one PUT /debts/:id so a refused schedule
cannot leave a debt whose total no longer matches it. Offer undo after a
delete only when the debt had no payments and the server kept or failed to
match nothing, since recreating it books a second creation transaction and
cannot restore payments. Use the backend's currency digit rule (ISO minor
units, else 8) for crypto schedules. Add per-installment pay/skip/undo on
the debt card so a paid last installment can be cancelled after the debt
auto-closed. Keep debt amounts as the API's decimal strings in the editor.
Calendar: honour can_edit and mark debt installment chips with a violet edge
so the legend means something. - Label form controls, name icon buttons and make clickable cards keyboard-reachable by @John710
A static scan found that no input/select/textarea was tied to its label,
about 15 icon-only buttons had no accessible name, and several card-likeelements could not be reached from the keyboard. - Tie every control to its label (for/id via useId, or aria-label when there is no visible label); colour/icon pickers and type toggles became labelled groups with aria-pressed - Add aria-labels to icon-only buttons (toast close, undo dismiss, back, quick-add, help close); recurring active toggle is now a role=switch - Account, tag and (admin) category cards get role/tabindex and Enter/Space handlers; the SVG upload drop area is a real button - Icon.vue renders aria-hidden so decorative icons stay out of the tree - Add a static test that fails when a form control has no accessible name - New i18n keys: common.back, sharing.permission, sharing.permission_for, recurring.toggle_active, recurring.sorts.label - Round money totals once from Decimal, never through floats by @John710
Report and dashboard totals were already summed with decimal.js but left
through parseFloat(decimal.toNumber().toFixed(2)): the float detour rounds
1.005 down to 1.00, the trend net subtracted two floats, and a crypto base
currency collapsed 0.00000001 BTC to 0. services/currency.js now has
toDisplayNumber(), which rounds the exact Decimal once (half-up) at the
serialisation boundary: 2 decimals for fiat codes, 8 for anything else.
Field names and JSON types are unchanged; by-category rows are ordered by
the exact totals before rounding.
The cron reminders printed amounts via Number(), which shows 0.00000001 as
"1e-8" and cuts digits past ~17; they now use Decimal#toFixed(). Demo data
wrote Math.random() floats and float balance deltas to numeric columns; the
amounts are now decimal strings (cents for fiat, 8 decimals for crypto
rewards) and the crypto purchase leg converts with convertAmountDecimal. - Render calendar days in any timezone and parse grouped amounts by @John710
formatDate passed 'YYYY-MM-DD' to new Date(), which reads it as UTC
midnight, so every browser west of UTC showed due dates, transaction
dates and next payments one day early. Date-only strings are now built
from their parts at local midnight; ISO timestamps keep the Date parser.
parseAmount replaced only the first comma, so "1,234.56" became 1.234.
It now follows the backend CSV import rule: spaces, NBSP and apostrophes
group digits, the last of ','/'.' is the decimal separator unless a lone
separator repeats, and grouping has to come in threes; anything else is
NaN instead of a silently truncated number.
ReportsView formatted debts' due dates and recurring next dates with
new Date(), and filtered upcoming payments by comparing that UTC instant
with the current time, which dropped today's payments east of UTC. It
now uses formatDate and compares the day strings. - Return DATE columns as YYYY-MM-DD text and harden the pool by @John710
pg has no override for DATE (oid 1082), so it built a Date at local
midnight of the Node process. With the container on Asia/Vladivostok,
DATE '2026-10-29' reached clients as "2026-10-28T14:00:00.000Z"; the
transaction edit form sliced that to 2026-10-28 and saving moved the
transaction back a day. Every DATE column in every response (transactions,
budgets, debts, recurring rules/exceptions/history) had the same shift,
and budget edits sent the timestamp back where the schema expects a date.
The db plugin now keeps DATE as the text PostgreSQL sends; TIMESTAMP is
unchanged. The pool also logs idle-client errors instead of crashing the
process on an unhandled 'error' event, sets a server-side
statement_timeout next to the client query_timeout, and closes idle
clients.
Calendar days still taken from the Node clock now come from
CURRENT_DATE: the net-worth report's month list, the demo data dates
(local-midnight Dates through toISOString() landed a day early) and the
CSV export filename. shiftMonth moves to the shared calendar helpers and
replaces the reports-only nextMonthKey. - Take a rule's currency from its account in the form by @John710
The form defaulted the currency to the base currency and let the user pick
any other, but a rule is booked in its account's currency and the API now
rejects a mismatch. The currency is shown read-only and follows the selected
account; opening an older rule whose currency differed relabels it with the
account currency it was always booked in. - Close sharing, undo, currency and feed-limit holes by @John710
- DELETE /recurring/:id removed entity_shares for the id before checking
ownership and without owner_id, so any user could strip another user's
shares. The owned rule delete now runs first, and only the owner's shares
go with it, in one transaction. - Undoing a payment read the transaction outside the transaction and deleted
it without checking the row count, so two concurrent undos reverted the
balance twice. DELETE ... RETURNING inside the transaction is now the check. - Every booking path moves the account balance by the rule amount as-is,
while the plan report converts by the rule currency: a 10 USD rule on a RUB
account debited 10 RUB. Create now stores the account currency and rejects
any other (400 errors.rule_currency_mismatch); PATCH/PUT that touch the
account or the currency are held to the same match. Existing mismatched
rules keep booking as before and are relabelled on their next edit. - Shared rules exposed the owner's account name even when the account itself
was not shared; account_name is now null unless the account is visible. - The calendar feed rate limit was keyed by the token, so every guessed
token got a fresh bucket; it is now keyed by client address.
- DELETE /recurring/:id removed entity_shares for the id before checking
- Honour CSV options and validate every row before writing by @John710
Multipart is registered without attachFieldsToBody, so request.body was
undefined and the options were never read: "Preview" ran a real import. The
handler now iterates request.parts(), accepts the options field before or
after the file, and only writes when the options say previewOnly: false.
Rows are validated before the insert transaction opens, and each bad row is
reported in stats.errors as { row, error } instead of aborting or vanishing:- amounts are parsed locale-aware into decimal.js (spaces/NBSP/apostrophes
are grouping, the last '.' or ',' is the decimal point, groups of three),
garbage is a row error; - dates honour dateFormat (MM/DD/YYYY was unreachable), impossible days are
row errors, an unsupported dateFormat is a 400; - a currency column that differs from the account currency, a transfer row
(no target account) and a named account the user does not have are row
errors; - categories are only matched to existing ones by name and type; unknown
names stay uncategorized (stats.uncategorized) instead of creating a global
category every user would see; - balance deltas are summed in decimal.js.
Also adds the backend error strings for the recurring currency check.
- amounts are parsed locale-aware into decimal.js (spaces/NBSP/apostrophes
- Clean up recurring links before migration 32's constraints by @John710
v1.3.2 deleted rules without clearing transactions.recurring_rule_id and
could book an occurrence twice, so upgrading a real 1.3.x database failed on
the validated FK and on the unique (recurring_rule_id, date) index, and the
container crash-looped (docker-entrypoint runs with set -e).
up() now first unlinks transactions that point at deleted rules and, for
duplicate (rule, date) pairs, keeps the lowest id linked and unlinks the rest.
Nothing is deleted and no amount or balance changes; migration 35 recounts
occurrences_paid afterwards. Editing this applied migration is a deliberate
exception: databases where it already ran never re-run it, and for them the
clean-up would match no rows anyway. - Book due rules by the database clock and isolate failures by @John710
The hourly job selected rules with SQLnext_date <= CURRENT_DATE(UTC in
PostgreSQL) but booked them up to a Node-side today (Asia/Vladivostok in the
container). When the two calendars disagreed a rule was selected, owed
nothing, kept its cursor and was selected again forever; therunningguard
then skipped every later hourly run. One failing rule also rethrew out of the
per-user loop and stopped the job for every user, every hour.
The per-user loop now lives in services/recurring-run.js (bookDueRules): it
reads today fromSELECT CURRENT_DATE::textin the same transaction as the
selection, sets aside a rule whose state did not advance, and catches,
logs and sets aside a rule whose booking failed so the user's other rules and
the other users still run. - Ignore protocol-relative redirect targets by @John710
A ?redirect=//host value passed the leading-slash check. - Address audit findings in saving, undo, charts, auth and modals by @John710
Users could create duplicate transactions by double-clicking or pressing
Ctrl+Enter, and API validation errors were silently swallowed. Several
other paths failed quietly or showed wrong data.- guard save/delete handlers with a busy flag and toast API errors in
Transactions, Budgets, Debts and Accounts; add a global Vue errorHandler - undo bar follows the current route; drop undo for account delete (it only
recreated an empty account); transaction undo keeps tags and splits and is
replaced by a stronger confirm when attachments exist - escape user strings in ECharts tooltips; give sankey nodes unique ids so a
shared income/expense category name no longer breaks the DAG - unsubscribe web push on logout; sync base currency in checkAuth
- ConfirmModal: localized defaults, focus Cancel, danger on delete confirms
- CSV export goes through api() (401 refresh, blob support) with error toast
- ImportCsvModal: options before file, real tab delimiter, stats.errors,
emit imported only on success, handle profile load failure - command palette: unique keys, open the add modal via ?new=1 after routing
- focus trap: no scroll unlock from closed modals, stacked modals, cancel
pending frame - sanitizeSvg forbids style and a; service worker caches only ok shells and
tolerates non-JSON push payloads - api(): keep ?redirect= on session loss, localized messages
- AccountDetail uses its own list, refetches on :id change, load more
- notifications rows are buttons, store calls are caught
- rates: show unconvertible amounts in their own currency
- i18n: totp_required, KB, TOTP QR alt and the new strings above
- guard save/delete handlers with a busy flag and toast API errors in
- Keep personal API tokens away from password and 2FA routes by @John710
A leaked API token could change the password, disable 2FA or sign out
every session, locking the owner out of their own account. - Close token, session, 2FA and SSRF gaps found in the audit by @John710- Repair dashboard, reports, budgets, debts, export and rate refresh by @John710- Convert USD into crypto currencies by @John710
Rates are stored as USD->fiat and crypto->USD. A USD source has no
USD->USD row, so convertAmountDecimal found neither the via-USD nor the
inverse path and USD->BTC returned null while EUR->BTC worked. The USD
leg of a USD source is now the identity. - Repair category binds and lock down global catalog writes by @John710
Creating or updating a transaction with a category, splits and category
merge all sent more bind values than placeholders, which PostgreSQL rejects
with 08P01, so they answered 500 in production. The budget alert joined the
categories.user_id column dropped by migration 29 and never fired.
Categories and tags are global since migration 29, yet any user could
rename, delete or merge them, rewriting every user's transactions, rules
and budgets (a category delete cascades into everyone's budgets). Update,
delete and merge are now admin-only (403 catalog_admin_only); creating
stays open. Tag usage counts only cover the caller's visible accounts.
Editing or deleting a transfer rewrote the paired leg and the counter
account's balance with access checked on the source account only; write
access to the counter account is now required (403 otherwise).
Account deletion reversed transfers with UPDATE ... FROM over raw rows,
which applies a single joined row per account, so only one of N transfers
to the same counterparty was undone; the amounts are summed per account
first. The account list returned a shared account twice when it had both
a family and a personal grant; one grant (the most permissive) is picked,
the same rule accountForWrite/accountForRead now follow. Visibility
helpers ignore grants whose owner no longer owns the account.
Balance arithmetic and the split-sum check use decimal.js instead of float
math and a half-cent tolerance; the transaction count matches the rows the
list pages through (split children included).
The test stub db now fails any statement whose bind array does not match
its highest $n placeholder, so this class of bug cannot pass tests again. - Harden compose secrets, image init, SSL migrations and CI tests by @John710
Ship no usable default secrets: .env.example leaves POSTGRES_PASSWORD,
JWT_SECRET and ENCRYPTION_KEY empty and compose requires them (${VAR:?}),
so a copied example can no longer yield forgeable admin tokens or a
known DB password. Forward SHOUTRRR_BLOCK_PRIVATE_TARGETS, COINGECKO_API_KEY,
LOG_LEVEL and PUSH_SEND_TIMEOUT, which the app reads but compose dropped.
Run the image under tini with a HEALTHCHECK start-period, drop tests and
tooling from the build context, and switch su-exec to uid:gid so PGID is
honoured when PUID is an existing user. Map DATABASE_SSL* to PGSSLMODE for
node-pg-migrate, run backend/frontend vitest in the CI lint job, add
dependabot docker-compose and minor/patch grouping, and fix README quick
start, env tables and API list (EN and RU). - Keep the recurring section named Регулярные everywhere by @John710
The nav label and the dashboard cards already said Регулярные; the page heading
still carried the older Повторяющиеся. - Rebuild the schedule floor from booked occurrences by @John710
Migration 31 seeded start_date and billing_anchor_day from next_date, the cursor
the old engine had already advanced, so every rule created before 1.4 lost the
occurrences it had already billed. The projection never reaches before
start_date, which emptied the current and past months of the payment calendar,
the dashboard and the iCal feed.
Reconcile both columns with the transactions ledger and recount occurrences_paid,
touching only the rows the ledger contradicts. - Qualify history columns against the joined users row by @John710
recurring_history and users both have id and created_at, so the unqualified
select list raised "column reference is ambiguous" and the endpoint answered
500. CI's blackbox run caught it; the unit suite cannot, because its stub
never executes SQL.
CI/CD
- Use a high-entropy JWT secret for the blackbox stack by @John710
The startup check now rejects low-entropy secrets; the repeated
ci-jwt-secret string would stop the CI container from booting.
Documentation
- Mirror the calendar feed section in the Russian README by @John710- Describe the Traefik router the feed actually needs by @John710
The first draft told readers to set tls on the router and kept the app's
forward-auth chain — with Tinyauth in front, a calendar client never gets past
the gate, and TLS already comes from the websecure entrypoint. - Warn that Shoutrrr webhook tokens leak over plain HTTP by @John710
The last open item from the v1.2.4 audit. Also records why a dead refresh
token reloads the whole SPA instead of surfacing an in-app error.
Features
- Installment schedule editor and debt installments in the payment calendar by @John710
Debts with a repayment schedule were invisible in the UI: the calendar showed
only subscriptions and the debt form had no way to create the schedule. Add the
schedule editor (equal parts / manual rows, exact-sum check in integer minor
units), the pay/skip/unpay actions on calendar chips, installment rows on the
dashboard, and handle the new DELETE /debts/:id answer (reversed/kept
transactions). Debt PUT now re-reads the list so installments and paid_amount
survive an edit. - Debt installments in the calendar, dashboard and iCal feed by @John710
Installment schedules only reached GET /debts, so the calendar, the
dashboard card and the feed kept showing subscriptions alone. All three now
read loadInstallmentOccurrences() and merge it with the subscription
projection through one mergeOccurrences() (date, kind, id), so they agree.
Subscription rows gain kind 'subscription' and is_owner. The feed gives
installments a finapp-debt- UID namespace and names the debt account only
when the feed owner can see it; its calendar name no longer says
"subscriptions".
Two audit leftovers: monthlyEquivalent() cut every rule to cents in its own
currency, so a small crypto rule was 0 before conversion; it now returns the
value at the numeric scale, the dashboard sums that exactly and GET /recurring
shows it at the currency precision. Rule reads joined entity_shares, listing a
rule shared to the family and to the user directly twice; visibility is an
EXISTS now. - Installment schedules and reversal of booked money on delete by @John710
A debt can now carry a finite payment schedule (debt_installments,
migration 38) so it can show up in the recurring calendar. Each
installment is due, paid or skipped; the rows must add up to the debt
amount exactly (422 installments_sum_mismatch), and a schedule with a
paid or skipped row can no longer be replaced (409 installments_locked).
New endpoints: PUT /debts/:id/installments (rows or an equal split at the
currency precision), POST|DELETE /debts/installments/:id/pay,
POST|DELETE /debts/installments/:id/skip, PATCH /debts/installments/:id.
GET /debts returns each debt's installments.
The payment logic (payment row, transaction, balance, automatic 'paid')
moves out of the route into recordDebtPayment so POST /debts/:id/payments
and paying an installment book money the same way; a statement-level test
pins that the payment route writes exactly what it wrote before.
Undoing an installment payment reverses its transaction only while it is
untouched (409 payment_edited otherwise).
DELETE /debts/:id used to leave the creation booking and the payment
transactions on the accounts, so balances kept the effect of a debt that
no longer existed. They are now deleted and reversed in the same
transaction; transactions edited by hand or on accounts the owner cannot
write are kept and listed in the response (kept_transactions), which is
now 200 with a JSON body instead of 204.
The month-anchor helpers of recurring-schedule.js are exported so
installment dates follow the same 31 -> 28 -> 31 rule as subscriptions. - Allow-list trusted hosts for the private-target guard by @John710
A self-hosted Gotify behind split-horizon DNS resolves to a LAN address
and would be blocked now that the guard is on by default.
SHOUTRRR_ALLOWED_HOSTS exempts exact hostnames while every other target
stays checked. - Block private-network Shoutrrr targets by default by @John710
Any user of a shared instance could point a webhook at the host's LAN or
loopback services. The guard is now on unless the admin sets
SHOUTRRR_BLOCK_PRIVATE_TARGETS=false to allow local services such as a
self-hosted Gotify. - Follow up on backend audit fixes in admin, catalog, debts and import UIs by @John710
The backend now resets 2FA for admins, makes categories and tags admin-only, locks debt booking fields once payments exist, and returns richer CSV import results. The UI had to catch up so users see the right controls and server messages instead of silent 403/409 failures.- admin users list shows 2FA status and a danger-confirmed Reset 2FA action
- hide category edit/merge and tag edit/delete for non-admins
- recurring: danger delete confirm, saving/deleting guards, error toasts
- debts: edit form (locked amount/currency/type/account when payments exist), empty due_date no longer sent
- api(): keep a route's own localized 429 message (rates refresh throttle) while still replacing framework rate-limit text
- CSV import: date format select defaulting to the profile, uncategorized count, per-row error list
- Publish subscriptions as an iCal feed by @John710
Calendar clients can only be given a URL, so the feed carries a dedicated
token in the query string instead of an API bearer token: it is hashed at
rest, shown once, and revoked without deleting the row. The plan is a
projection of the next 180 days, never the ledger.
A token in a query string is also something a proxy logs, so the README
documents the Traefik router that turns access logging off for this path. - Recurring commitments and plan-vs-actual by @John710
The dashboard could only list rules whose stale next_date fell inside five
days, so a subscription that is actually owed showed nothing about what the
user is signed up to pay. It now carries the monthly total the active schedules
are worth and the next outstanding occurrences from the projection, and reports
gain a plan-vs-actual chart: the plan side is what the schedules ask for, the
actual side is what the ledger booked under those rules, so a payment made at a
different amount is visible as variance. - Recurring subscriptions UI with calendar and history by @John710
Expose the backend occurrence contract where users act on it: the recurring
list shows the occurrence actually owed (pay/skip targets it), a month
calendar projects occurrences across a window, and a history modal renders
the audit trail with amounts converted through the rates store.
Calendar pay/skip is click-based rather than drag-based: the API only
accepts exact scheduled occurrence dates, so dropping a payment on an
arbitrary day would be rejected. - Expose the owed occurrence the pay/skip actions target by @John710
The row buttons and run-now act on the first unpaid occurrence from the
ledger cursor, which points backwards from today for overdue rules while
next_occurrence always looks forwards. - Anchor-based recurring cron with reminders and overdue alerts by @John710
The hourly job drove generation from next_date + INTERVAL, which ratchets a
31st-of-month anchor backwards and can only emit one occurrence per run, so a
rule that fell behind stayed behind for weeks.
Occurrence dates now come from the schedule service: the cron asks for the
dates owed up to today (bounded catch-up, rewound ones recorded as skipped in
history) and writes them through one transaction per rule with
FOR UPDATE SKIP LOCKED, so a crash rolls back cleanly and two instances cannot
double-book. Balance moves use numeric strings, notifications fire only after
COMMIT, and the legacy frequency column stays in sync so the image can roll
back. Adds daily due-soon and overdue reminders, deduplicated per rule. - Anchor-based schedule API with pay/skip and history by @John710
The recurring endpoints only stored next_date and a fixed frequency, so the
list could not show what a rule will actually ask for, and PUT rebound every
column (currency included) whether the client sent it or not.
Adds PATCH with a column whitelist built by the schedule service, an occurrence
projection window, per-occurrence pay/unpay and skip, run-now, and a change
history read — all with dates cast to text so the container timezone cannot move
a calendar day. PUT stays for one release behind a Deprecation header.
Editing an auto-transaction onto a date its rule already paid now hits the new
(recurring_rule_id, date) unique index; the route maps that conflict to a
localized 400 instead of a 500. - Recurring schedule fields, occurrence integrity, history and exceptions by @John710
Schedules need real state: interval + anchor fields so the next date is derived
instead of nudged, a FK and a partial unique index so one occurrence can only
ever be one transaction, and tables for skips and price/schedule changes.
frequency stays populated so the container can roll back to finapp:1.3.2. - Recurring schedule service with anchor-based occurrence math by @John710
Performance
- Add the indexes the report and sharing queries need by @John710
Adds (IF NOT EXISTS) indexes no earlier migration covers:
transactions(account_id, date DESC), transactions(transfer_to_account_id),
exchange_rates(from_currency, to_currency, updated_at DESC),
notifications(user_id, created_at DESC), debt_payments(debt_id),
budgets(category_id), budget_tags(tag_id), recurring_rules(account_id),
debts(account_id).
exchange_rates.rate is widened from numeric(15,6) to numeric(30,18):
six decimals flatten small crypto prices. down() narrows it back and
drops the indexes.
Refactor
- Load rules and project occurrences from one place by @John710
The REST routes, the dashboard and the reports each rebuilt the same
family-sharing join, the same paid/skipped exception query and the same
flattened occurrence list, which is three chances for them to disagree about
what is owed. Move the reads into services/recurring-rules.js and the
projection into recurring-schedule.projectOccurrences().
Testing
- Match the UID the feed actually emits by @John710
The blackbox assertion assumed a compact date in the UID while both the
service and its unit test pin the dashed occurrence date, so the shape check
rejected a valid feed. It now reads the UID line it is talking about. - Cover the calendar feed end to end by @John710
The feed is the only recurring surface a browser never calls, so nothing else
proves the token round-trip, the all-day DTSTART shape or that a revoked link
stops serving. Runs inside the container against a live instance. - Cover recurring projection, pay/skip cycle and run-now in blackbox by @John710
Runs against a live container, so it pins what the unit suites cannot: the
server-side date casting, the partial unique index and the balance effects.
Appended after the net-worth section on purpose — it writes transactions and
that assertion expects exact balances. Fixtures start in 2027 so the expected
occurrence dates do not follow the container clock.
Full Changelog: v1.3.2...v1.4.0