[Unreleased]
Bug Fixes
- 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.
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.
Full Changelog: v1.3.2...