Commit Graph
75 Commits
Author SHA1 Message Date
Brandon 686205c0ff feat(bills): schema and domain core for the bills subsystem (#3201)
* feat(bills): schema and domain core for the bills subsystem

First of three chunks carved out of #3083. This one carries the schema and the
domain layer: no bills pages, no calendar feed, no assistant tools. Nothing here
is reachable from the UI yet, so it changes no user-visible behavior on its own.

Schema, in a single migration with a full down:

- recurrence_rules, recurring_occurrences, recurring_allocations,
  recurring_price_changes and recurring_match_rejections
- bill columns on recurring_transactions (bill_type, payment_url, autopay,
  notes, anchor and end conditions, weekend adjustment, dedup scope)
- the four data backfills, in their original order

Domain layer:

- Schedule, the pure date PORO every cadence resolves through, and
  FrequencyPreset for the labels
- OccurrenceGenerator, Matcher, Allocator, PriceChangeDetector, Classifier,
  DeclaredBill, HistoryBackfiller and PaycheckPlanner
- Pipeline, tying detection to generation, plus the nightly job and rake task

Existing detection code changed in three places, each a bug this schema exposes:

- Cleaner used a flat two-month staleness threshold, which silently retired
  every quarterly and annual series
- SubscriptionAuditGenerator used a flat 45-day overdue threshold, meaningless
  at both ends of the frequency range
- CashFlowWarningGenerator read one projected entry per series, which only
  equalled the monthly amount because every series was monthly; weekly bills
  were under-counted fourfold in its 30-day projection

The JSON API travels with the model rather than the UI, because the status enum
widens here. The API accepts only active and inactive on write; suggested,
paused and ended are lifecycle states owned by detection, so the documented
enum stays truthful.

Uniqueness keys gain dedup_scope alongside amount, never instead of it: a
series that is not price-forked carries a blank scope, so amount is what keeps
two different prices apart.

Suite 7,550 runs, 0 failures. Rubocop and brakeman clean. Eager loading
verified, and the migration reverses and re-applies. Includes the first review round: orphan repair matches income and refuses coincidental twins, session imports persist occurrence mappings across chunks, semimonthly anchors canonicalize, classifier keywords match whole words, and the down refuses rather than failing when price-forked rows exist.

* Address second review round

Bound the cross-currency default allocation by the entry leftover and the
occurrence remainder, matching the same-currency path. Let keyword stems
carry a suffix again after the word-boundary fix silenced them. Skip an
incoherent recurrence rule row instead of rolling back the whole import.
Check rollback collisions per restored index so a refusal cannot land
after the bills tables are dropped. Replay the closed_at test through a
real second import. Preload the orphan repair associations and move the
allocator errors to locale keys.

* Match index NULL semantics in the rollback collision checks

GROUP BY treats NULLs as equal but the restored unique indexes do not:
account_id is nullable and indexed, so two accountless rows can never
collide under any of them. Excluding NULL accounts keeps the guard from
refusing a rollback PostgreSQL can perform. Verified live both ways:
accountless duplicates roll back, a real collision still refuses.

* Address maintainer review

Scope the payable debt-destination subquery to the row and its family
instead of scanning every account in the installation. Batch the cash
flow generator remaining-amount sums into one grouped query, matching
the two sibling sites. Enforce both window bounds in the after_count
branch so a future-anchored plan cannot leak past the requested end
date. Skip the explicit regeneration when the day column change will
fire the model callback anyway. Add the missing locale entry for the
allocation currency validation.
2026-08-31 23:41:38 +02:00
3d6a8d8b6e feat(budgets): move money between envelopes in one gesture (#3164)
* feat(budgets): carry a category's unspent budget into the next month

A budget category resets to zero every month, so anything non-monthly
(annual insurance, a holiday fund, car servicing) has no place to
accumulate. Two columns on budget_categories turn a category into a real
envelope: `rollover_enabled`, opt-in per category and off by default, and
`rolled_over_amount`, the surplus carried in from the previous month.

  rolled_over(n) = rollover_enabled
                   ? max(0, budgeted(n-1) + rolled_over(n-1) - actual(n-1))
                   : 0

v1 floors at zero: only a surplus carries, never an overspend.

The amount is materialized, not derived. March depends on February which
depends on January, so computing it on read would walk the whole chain on
every budget render. Budget::RolloverCalculator recomputes it in a single
forward pass and writes once via upsert_all, from Budget.find_or_bootstrap
and from BudgetCategoriesController#update -- allocations and the toggle
being the only inputs. No Transaction hook: a past month's actuals can
change after the fact, and the page load is a fine moment to catch up.

Scope kept deliberately narrow. `Budget#budgeted_spending`,
`#allocated_spending` and `#available_to_allocate` are untouched -- the top
of the budget page still answers "I planned to spend X, I've allocated Y".
The carry is per-envelope information, surfaced as `Budget#total_rolled_over`
and never folded into those totals.

What the carry does change is consumption: `available_to_spend`,
`percent_of_budget_spent` and `budgeted?` all count it, or a category funded
entirely by rollover would read as unbudgeted and get an alert pill while it
still had money left. `display_budgeted_spending` stays the month's
allocation alone -- the card shows the two figures side by side.

Details worth knowing:

- A parent's carry is net of its ring-fenced subcategories'. A parent's
  allocation already contains theirs and its actuals already contain their
  spending; those subcategories carry their own surplus, so counting the
  parent's raw leftover would roll the same money over twice.
- Chains never mix: household with household, a member's personal budgets
  with their own. A missing month is a gap the carry crosses, not a month
  budgeted at zero.
- The carry stops at a currency change. sync_budget_categories stamps
  categories with family.currency at sync time while a budget freezes its
  own at creation, so the guard is on budget_category.currency -- the unit
  the amount is actually denominated in.
- upsert_all writes with `update_only`, so a concurrent request that moves
  an allocation between our read and our write doesn't get it clobbered by
  the stale value we loaded.
- copy_from! copies the toggle, never the amount.

Cost for families that never turn it on: one EXISTS query per budget page
load, measured, including on the reports page which also bootstraps a
budget. With rollover on, the walk starts at the first month that uses it
rather than at the two-year history bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): pin the household rollover chain to a viewer-independent scope

Addresses review feedback on #3143.

The household budget (user_id NULL) has no owner to scope actuals by, and
`IncomeStatement` falls back to `Current.user` when nobody says otherwise.
The calculator therefore computed one shared `rolled_over_amount` through
whichever member happened to load the page, and each viewer overwrote the
other's number -- last one wins, and a member could infer spending in
accounts they cannot see. `Budget#income_statement_accounts` can now be
overridden, and the calculator pins the household chain to the whole
family so the shared row holds one number. Personal chains are untouched:
they already scope to their owner's accounts and were always deterministic.

`copy_from!` runs after `find_or_bootstrap` has already recomputed the
chain, so copying `rollover_enabled` left the target sitting on a zero carry
until the next page load. It now recomputes before its transaction commits.

The toggle tooltip described the wrong direction. `incoming_carry` checks
the flag of the month being computed, so the toggle governs what that month
*receives* from the previous one, not what it sends forward. Reworded in
English and French.

The concurrency regression test now drives its concurrent write through
`Budget#budget_category_actual_spending`, a public seam, instead of stubbing
a private method of the calculator from another class's test suite.

Each guard was confirmed load-bearing by reverting it and watching its test
fail. bin/rails test: 6939 runs, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): let the rollover choice stand instead of resetting each month

`rollover_enabled` lives on budget_categories, one row per (budget,
category), so a month created by `find_or_bootstrap` was born with the flag
off. Switching rollover on for Vacations in January and simply opening
February dropped January's surplus on the floor -- the user had to re-arm
the toggle every month, or go through "copy from previous budget". The
feature's headline case, a category funded 50/month accumulating over a
year, did not work as shipped.

New rows now inherit the flag from the last initialized budget of the same
owner, the same chain the carry itself walks. Turning the toggle off on a
given month still overrides it from there on, so the per-month escape hatch
survives.

The flag stays on budget_categories rather than moving to Category, which is
where comparable products (Monarch, Copilot, Lunch Money) put it. Categories
here are family-wide while budgets are per owner, so a category-level flag
would force one member's rollover choice onto everyone's personal budget and
onto the household budget. budget_categories is the only table carrying both
the category and the owner. A regression test covers that isolation.

Naming follows the same products: the toggle reads "Rollover", the noun, not
"Roll over", the verb -- which also matches `rollover_enabled` and the
calculator. Both tooltips now describe the property rather than a direction
("keep this category's unspent money from one month to the next"). The
previous wording named the direction the flag actually gates, incoming,
which is accurate but the opposite of the mental model every comparable
product installs; describing the property is true under either reading. The
French card string switched to "+%{amount} de report" so it no longer has to
agree in number with a currency noun it cannot see.

bin/rails test: 6942 runs, 0 failures. The inheritance was confirmed
load-bearing by removing it and watching its tests fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): make a rollover opt-out stop the money in both directions

`incoming_carry` gates what a month receives, but `leftover_for` computed
what it sends regardless of the toggle. So switching rollover off for one
month and back on the next handed the opted-out month's whole allocation to
the month after: the surplus the user meant to forfeit reappeared a month
later. Reproduced at 100, where 0 was expected.

The outgoing carry is now gated on the same flag, which also skips the
actuals lookup for opted-out rows. "Off" now means this envelope does not
roll over, in either direction -- the reading the standing toggle and the
tooltip both promise.

Found by CodeRabbit on #3143. It only became wrong with the standing-choice
inheritance in 2b1cff5a: while the flag was per-month, "off" plausibly meant
"do not accept", and the previous month's surplus reaching a re-armed month
was defensible. Once the flag reads as a property of the envelope, it isn't.

bin/rails test: 6943 runs, 0 failures. Confirmed load-bearing by removing
the guard and watching the new three-month test fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): serialize rollover recomputes for a chain with an advisory lock

`recompute!` reads the whole chain into memory, walks it, then upserts.
Nothing made that atomic: two overlapping recomputes for the same
(family, owner) chain could both load it, and the one that started first
could land its now-stale `rolled_over_amount` on top of the other's.
`update_only` keeps an upsert off allocations, but the carry is the very
column this writes, so nothing protected it. The wrong value survived until
the next page load recomputed it.

The read-then-write now runs inside a transaction holding
`pg_advisory_xact_lock` keyed on the chain, and the walk was extracted so
the guard is legible. The cheap `first_relevant_budget_date` check still
runs first and unlocked, so families that never enabled rollover pay one
query and never contend; the date is re-read under the lock because the
chain may have moved while waiting. The key names the (family, owner) pair,
so a household recompute and a member's personal recompute don't queue
behind each other.

This reverses the spec's "no advisory lock" guidance, at the request of an
upstream maintainer reviewing #3143.

On the test: under transactional fixtures a second connection cannot see the
data, so a true two-connection interleaving test isn't practical here. The
regression test asserts what is observable in-process -- the lock is taken,
it is taken before the write, and two chains produce different keys.
Removing `lock_chain!` makes it fail.

bin/rails test: 6944 runs, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* feat(api): expose the rollover toggle and carried amount on budget categories

`available_to_spend` started counting the carry in this branch, so an API
client could receive a category budgeted at 500 with 700 available and
nothing in the payload to account for the difference. The two fields that
explain it are now serialized.

`rollover_enabled` ships with the stored fields, so the summary rendered by
the index action carries it. `rolled_over_amount` sits with the derived
amounts behind `include_derived_amounts`, next to the `available_to_spend`
it accounts for -- the index deliberately omits both, unchanged.

Schemas updated in spec/swagger_helper.rb (BudgetCategory and
BudgetCategorySummary), docs regenerated with rswag, and behavioural
coverage added to the Minitest controller test: the show action returns the
toggle and the carry, and the index returns the toggle without the derived
amount.

Note on docs/api/openapi.yaml: 64 of the 72 added lines are not from this
change. The committed file had drifted from what rswag generates -- specs
for the merchant CSV import and transfer source fees had been added without
regenerating -- and the mandated `rake rswag:specs:swaggerize` picks them up.
Verified by regenerating on a clean tree, where those 64 lines appear on
their own. Hand-trimming them back out would leave the generated file not
matching its generator, so they are included; happy to split them into their
own commit if a maintainer prefers.

bin/rails test: 6945 runs, 0 failures.
ruby test/support/verify_api_endpoint_consistency.rb: OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* feat(budgets): move money between envelopes in one gesture

Overspending one category and covering it from another meant editing two
allocations by hand, with no atomicity: the budget could sit
over-allocated between the two saves, and a failure left it there.

`BudgetCategory.move_allocation!` does both sides in one transaction.
Deliberately no new table — v1 stores the resulting allocations and keeps
no history of the move itself.

Refused, each with its own localized message: an amount at or below zero,
more than the source has, two categories from different budgets, a
category and itself, "Uncategorized" (synthesized on read, it has no row),
and — the one that is not obvious — a category and its own direct parent
or child. `sync_parent_budgeted_spending!` rebuilds a parent from the sum
of its children plus its reserve, so money moved across that boundary
would be re-derived away and the total would not be conserved.

Lock order is the delicate part. `update_budgeted_spending!` locks its own
row and, for a subcategory, its parent, so two simultaneous moves in
opposite directions could each hold what the other needs. Every row the
operation will touch — both ends and their parents — is locked up front by
ascending id.

The rollover chain is recomputed by the caller AFTER the move commits,
never inside it. `Budget::RolloverCalculator` takes a transaction-scoped
advisory lock, and taking it while these row locks are held would invert
the order `#update` already established: one request holding rows and
waiting for the advisory lock, another holding the advisory lock and
waiting for those rows. A model test pins that `move_allocation!` never
recomputes on its own.

The recompute is not optional. A move is neutral for
`Budget#allocated_spending`, but not for the carry: `leftover_for` is
budgeted + rolled_over − actual, so moving money changes what both
envelopes hand to the next month.

UI is one native `<dialog>` shared by the page rather than one per row,
opened from a discreet button on each envelope that has something to give.
The Stimulus controller has 6 targets and disables the options the server
would refuse anyway, so an impossible move is never offered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4

* fix(budgets): use the design-system dialog, and stop a parent lending its children's money

Addresses review feedback on #3164.

**The move dialog was hand-rolled.** `DS::Dialog` already exists and already
carries focus trapping, Escape, click-outside, focus restore and the
design-system chrome; rewriting those by hand is how they end up subtly wrong,
and the guidelines say to reach for the primitive first. It keeps the
one-dialog-for-the-page shape — the list holds dozens of rows and a per-row
dialog would be dozens of copies of the same markup — via `auto_open: false`
and `disable_frame: true`.

**It also stayed open after a successful move,** still showing the previous
source and amount. It now closes on `turbo:submit-end`, and only when Turbo
reports success: closing on submit alone would hide the reason a move was
refused.

**Submit was enabled with nowhere to send.** A lone envelope, or one whose only
peers are its own parent and children, offered a button whose only outcome was
a server error. The form now says so and disables itself.

**A parent could send away its children's money.** `budgeted_spending` on a
parent already contains its individually funded subcategories' allocations, so
comparing against the gross figure let a move spend what a child had
ring-fenced. The parent dropped below the sum of its children, and the next
edit to any child rebuilt it — the money appeared to teleport back. The
movable amount for a parent is now its own reserve.

`test "moving the whole allocation is allowed, moving one cent more is not"`
moved a parent's gross amount and passed: it encoded that bug. It now uses a
leaf as its source, where "the whole allocation" is the whole of it, and the
parent boundary gets its own pair of tests.

**A negative carry could be written.** The calculator floors it at zero but
writes through `upsert_all`, and a negative `rolled_over_amount` would quietly
subtract from `available_to_spend`. Now a CHECK constraint, verified by
replaying the migration on a throwaway database.

bin/rails test: 6967 runs, 28020 assertions, 0 failures. RuboCop, erb_lint,
Brakeman and biome clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(budgets): carry the rollover choice into months already open, and mend the API schema

Addresses the remaining review feedback on #3164.

**Enabling rollover skipped months that already existed.** Inheritance runs
when `sync_budget_categories` creates a missing row, so it only ever reaches
months that do not exist yet. A user who opened March, then went back to
January and switched rollover on, left March sitting at `false` — created
before the choice was made, so it had nothing to inherit — and the chain died
there.

The toggle is a standing choice about the envelope, which is what
`inherited_rollover_flags` already says: "turning it off on a given month still
overrides it from there on." Applying the choice forward closes the hole
without a tri-state column. Later months take the most recent decision, which
is the one the user just made; earlier months keep theirs.

**`rollover_enabled` was emitted but not required.** The shared partial always
sends it in both list and detail responses. Added to the `required` list of
`BudgetCategorySummary` and `BudgetCategory` in `spec/swagger_helper.rb`, then
regenerated.

**`type: file` is not valid OpenAPI 3.0.3.** A Swagger 2.0 leftover in the
merchants import spec, which generated clients that send nothing the controller
can read. It surfaced now because this branch is the first to regenerate
`openapi.yaml` since it was written — `origin/main` has no occurrence of it.
Spelled as a string with `format: binary` instead.

Regeneration produced a four-line diff, so the checked-in document was already
in sync otherwise.

bin/rails test: 6969 runs, 28023 assertions, 0 failures. RuboCop and Brakeman
clean; 324 rswag examples pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(budgets): stop the API serving a carry the web pages would have refreshed

Addresses the remaining P1 on #3164, and its duplicate on #3143.

The objection was that nothing recomputes when a sync, an edit or a
recategorisation changes spending in an earlier month. On the web that is by
design and measured: every surface showing the carry goes through
`Budget.find_or_bootstrap`, so it recomputes on the way in, and the alternative
— recomputing on every transaction write — buys nothing a page load does not
already give.

The API is the case that argument does not cover, and the review was right
about it. `Api::V1::BudgetCategoriesController` reads `rolled_over_amount`
straight off the column, so it was the one surface that could serve a stale
carry indefinitely, until somebody happened to open the budget page.

It now recomputes the chains it is about to read. A read that writes is a
smell, but it is the same bargain the budget page already makes, applied to the
surface that was missed: the walk is per family, and the calculator's leading
EXISTS makes it a single query that writes nothing for a family that never
turned rollover on.

Also from review: the move dialog's amount field allowed `min: 0` while
`move_allocation!` rejects zero as non-positive. Browser validation now matches
the server contract, at the currency step.

bin/rails test: 6970 runs, 28025 assertions, 0 failures. Confirmed load-bearing
by removing the callback and watching the new API test fail. RuboCop, erb_lint
and Brakeman clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* Fix budget rollover schema delta

* Remove duplicate rollover test class

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-26 07:01:12 +02:00
buzzromainandClaude Opus 5 1fddb4d97c feat(budgets): carry a category's unspent budget into the next month (#3143)
* feat(budgets): carry a category's unspent budget into the next month

A budget category resets to zero every month, so anything non-monthly
(annual insurance, a holiday fund, car servicing) has no place to
accumulate. Two columns on budget_categories turn a category into a real
envelope: `rollover_enabled`, opt-in per category and off by default, and
`rolled_over_amount`, the surplus carried in from the previous month.

  rolled_over(n) = rollover_enabled
                   ? max(0, budgeted(n-1) + rolled_over(n-1) - actual(n-1))
                   : 0

v1 floors at zero: only a surplus carries, never an overspend.

The amount is materialized, not derived. March depends on February which
depends on January, so computing it on read would walk the whole chain on
every budget render. Budget::RolloverCalculator recomputes it in a single
forward pass and writes once via upsert_all, from Budget.find_or_bootstrap
and from BudgetCategoriesController#update -- allocations and the toggle
being the only inputs. No Transaction hook: a past month's actuals can
change after the fact, and the page load is a fine moment to catch up.

Scope kept deliberately narrow. `Budget#budgeted_spending`,
`#allocated_spending` and `#available_to_allocate` are untouched -- the top
of the budget page still answers "I planned to spend X, I've allocated Y".
The carry is per-envelope information, surfaced as `Budget#total_rolled_over`
and never folded into those totals.

What the carry does change is consumption: `available_to_spend`,
`percent_of_budget_spent` and `budgeted?` all count it, or a category funded
entirely by rollover would read as unbudgeted and get an alert pill while it
still had money left. `display_budgeted_spending` stays the month's
allocation alone -- the card shows the two figures side by side.

Details worth knowing:

- A parent's carry is net of its ring-fenced subcategories'. A parent's
  allocation already contains theirs and its actuals already contain their
  spending; those subcategories carry their own surplus, so counting the
  parent's raw leftover would roll the same money over twice.
- Chains never mix: household with household, a member's personal budgets
  with their own. A missing month is a gap the carry crosses, not a month
  budgeted at zero.
- The carry stops at a currency change. sync_budget_categories stamps
  categories with family.currency at sync time while a budget freezes its
  own at creation, so the guard is on budget_category.currency -- the unit
  the amount is actually denominated in.
- upsert_all writes with `update_only`, so a concurrent request that moves
  an allocation between our read and our write doesn't get it clobbered by
  the stale value we loaded.
- copy_from! copies the toggle, never the amount.

Cost for families that never turn it on: one EXISTS query per budget page
load, measured, including on the reports page which also bootstraps a
budget. With rollover on, the walk starts at the first month that uses it
rather than at the two-year history bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): pin the household rollover chain to a viewer-independent scope

Addresses review feedback on #3143.

The household budget (user_id NULL) has no owner to scope actuals by, and
`IncomeStatement` falls back to `Current.user` when nobody says otherwise.
The calculator therefore computed one shared `rolled_over_amount` through
whichever member happened to load the page, and each viewer overwrote the
other's number -- last one wins, and a member could infer spending in
accounts they cannot see. `Budget#income_statement_accounts` can now be
overridden, and the calculator pins the household chain to the whole
family so the shared row holds one number. Personal chains are untouched:
they already scope to their owner's accounts and were always deterministic.

`copy_from!` runs after `find_or_bootstrap` has already recomputed the
chain, so copying `rollover_enabled` left the target sitting on a zero carry
until the next page load. It now recomputes before its transaction commits.

The toggle tooltip described the wrong direction. `incoming_carry` checks
the flag of the month being computed, so the toggle governs what that month
*receives* from the previous one, not what it sends forward. Reworded in
English and French.

The concurrency regression test now drives its concurrent write through
`Budget#budget_category_actual_spending`, a public seam, instead of stubbing
a private method of the calculator from another class's test suite.

Each guard was confirmed load-bearing by reverting it and watching its test
fail. bin/rails test: 6939 runs, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): let the rollover choice stand instead of resetting each month

`rollover_enabled` lives on budget_categories, one row per (budget,
category), so a month created by `find_or_bootstrap` was born with the flag
off. Switching rollover on for Vacations in January and simply opening
February dropped January's surplus on the floor -- the user had to re-arm
the toggle every month, or go through "copy from previous budget". The
feature's headline case, a category funded 50/month accumulating over a
year, did not work as shipped.

New rows now inherit the flag from the last initialized budget of the same
owner, the same chain the carry itself walks. Turning the toggle off on a
given month still overrides it from there on, so the per-month escape hatch
survives.

The flag stays on budget_categories rather than moving to Category, which is
where comparable products (Monarch, Copilot, Lunch Money) put it. Categories
here are family-wide while budgets are per owner, so a category-level flag
would force one member's rollover choice onto everyone's personal budget and
onto the household budget. budget_categories is the only table carrying both
the category and the owner. A regression test covers that isolation.

Naming follows the same products: the toggle reads "Rollover", the noun, not
"Roll over", the verb -- which also matches `rollover_enabled` and the
calculator. Both tooltips now describe the property rather than a direction
("keep this category's unspent money from one month to the next"). The
previous wording named the direction the flag actually gates, incoming,
which is accurate but the opposite of the mental model every comparable
product installs; describing the property is true under either reading. The
French card string switched to "+%{amount} de report" so it no longer has to
agree in number with a currency noun it cannot see.

bin/rails test: 6942 runs, 0 failures. The inheritance was confirmed
load-bearing by removing it and watching its tests fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): make a rollover opt-out stop the money in both directions

`incoming_carry` gates what a month receives, but `leftover_for` computed
what it sends regardless of the toggle. So switching rollover off for one
month and back on the next handed the opted-out month's whole allocation to
the month after: the surplus the user meant to forfeit reappeared a month
later. Reproduced at 100, where 0 was expected.

The outgoing carry is now gated on the same flag, which also skips the
actuals lookup for opted-out rows. "Off" now means this envelope does not
roll over, in either direction -- the reading the standing toggle and the
tooltip both promise.

Found by CodeRabbit on #3143. It only became wrong with the standing-choice
inheritance in 2b1cff5a: while the flag was per-month, "off" plausibly meant
"do not accept", and the previous month's surplus reaching a re-armed month
was defensible. Once the flag reads as a property of the envelope, it isn't.

bin/rails test: 6943 runs, 0 failures. Confirmed load-bearing by removing
the guard and watching the new three-month test fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* fix(budgets): serialize rollover recomputes for a chain with an advisory lock

`recompute!` reads the whole chain into memory, walks it, then upserts.
Nothing made that atomic: two overlapping recomputes for the same
(family, owner) chain could both load it, and the one that started first
could land its now-stale `rolled_over_amount` on top of the other's.
`update_only` keeps an upsert off allocations, but the carry is the very
column this writes, so nothing protected it. The wrong value survived until
the next page load recomputed it.

The read-then-write now runs inside a transaction holding
`pg_advisory_xact_lock` keyed on the chain, and the walk was extracted so
the guard is legible. The cheap `first_relevant_budget_date` check still
runs first and unlocked, so families that never enabled rollover pay one
query and never contend; the date is re-read under the lock because the
chain may have moved while waiting. The key names the (family, owner) pair,
so a household recompute and a member's personal recompute don't queue
behind each other.

This reverses the spec's "no advisory lock" guidance, at the request of an
upstream maintainer reviewing #3143.

On the test: under transactional fixtures a second connection cannot see the
data, so a true two-connection interleaving test isn't practical here. The
regression test asserts what is observable in-process -- the lock is taken,
it is taken before the write, and two chains produce different keys.
Removing `lock_chain!` makes it fail.

bin/rails test: 6944 runs, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

* feat(api): expose the rollover toggle and carried amount on budget categories

`available_to_spend` started counting the carry in this branch, so an API
client could receive a category budgeted at 500 with 700 available and
nothing in the payload to account for the difference. The two fields that
explain it are now serialized.

`rollover_enabled` ships with the stored fields, so the summary rendered by
the index action carries it. `rolled_over_amount` sits with the derived
amounts behind `include_derived_amounts`, next to the `available_to_spend`
it accounts for -- the index deliberately omits both, unchanged.

Schemas updated in spec/swagger_helper.rb (BudgetCategory and
BudgetCategorySummary), docs regenerated with rswag, and behavioural
coverage added to the Minitest controller test: the show action returns the
toggle and the carry, and the index returns the toggle without the derived
amount.

Note on docs/api/openapi.yaml: 64 of the 72 added lines are not from this
change. The committed file had drifted from what rswag generates -- specs
for the merchant CSV import and transfer source fees had been added without
regenerating -- and the mandated `rake rswag:specs:swaggerize` picks them up.
Verified by regenerating on a clean tree, where those 64 lines appear on
their own. Hand-trimming them back out would leave the generated file not
matching its generator, so they are included; happy to split them into their
own commit if a maintainer prefers.

bin/rails test: 6945 runs, 0 failures.
ruby test/support/verify_api_endpoint_consistency.rb: OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyD26wsXjsYfpTgAGL1n1Z

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 06:29:15 +02:00
colinedwardwood b1df16b0a7 Allow API transaction create to opt into sync protection (user_modified) (#3162)
* Allow API transaction create to opt into sync protection (user_modified)

The transactions API has no way to mark a newly-created transaction
user_modified, which is the only thing that protects an entry from a
later provider sync (Plaid/SimpleFin/etc.) silently overwriting its
category or name - Account::ProviderImportAdapter#import_transaction
claims any entry matching on date/amount/currency with no external_id
yet, then enriches unlocked fields from the sync payload.

This matters for any API client that owns writes into an account also
linked to a bank-sync provider: without a way to protect its own
entries, the client's data can be silently overwritten the first time
the linked provider happens to sync a matching transaction.

Adds an optional `user_modified` param to POST /api/v1/transactions,
reusing the existing Entry#mark_user_modified! (added for #1977, so far
only wired into the merchant merge/convert/unlink flows) rather than
mass-assigning the column directly. Exposes user_modified in the
transaction JSON response, matching how external_id/source already are.

Scoped to create only, matching the concrete need; happy to extend to
update in a follow-up if that's wanted too.

* fix: mark entry user_modified before enqueueing account sync

sync_account_later enqueued the background sync job before
mark_user_modified! ran, leaving a window where a fast-running job
could read and overwrite the entry before the protection flag was
set. Move the mark_user_modified! call ahead of the sync enqueue so
the flag is always in place first.
2026-08-25 07:31:40 +02:00
0aa43de10a Add experimental Swift-native Sure Insights app (#3134)
* Add Swift-native Sure app

* Fix push subscriptions schema for CI

* Address native app review feedback

* Address remaining native app review feedback

* Use Flutter app logo for native icon

* Honor insight notification preferences and locale

---------

Co-authored-by: Juan Jose Mata <2v8shcb6pz@privaterelay.appleid.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-25 04:16:24 +02:00
Josh ca66346dc5 feat: add safe admin user removal (#3131)
* feat: add safe admin user removal

* fix: address user removal review findings

* fix: close remaining user removal review gaps

* fix: handle deleted users during session creation

* fix: fail closed when session creation fails

* fix: reject token issuance for inactive users
2026-08-22 21:54:32 +02:00
e5750a6c09 feat(budgets): add per-user personal budgets with strict isolation (#2891)
* feat(budgets): add per-user personal budgets with strict isolation

Families can now opt into personal budgets (toggleable via family
settings): each family member gets their own budget for a given
period instead of sharing a single family-wide budget.

- Add families.personal_budgets flag and budgets.user_id, with
  partial unique indexes so shared budgets (user_id IS NULL) and
  personal budgets (user_id IS NOT NULL) can't collide.
- Budget.find_or_bootstrap scopes lookup/creation by user when the
  family has personal_budgets enabled.
- Scope most_recent_initialized_budget (used to seed a new budget
  from the prior period) by user_id so one user's copy-forward never
  bleeds into another user's budget.
- budgets.user_id cascades on user deletion so personal budgets don't
  outlive their owner.

* feat(budgets): enforce user-specific budget ownership and cascade deletion

* feat(budgets): display user name for personal budgets in budget card on the plan section

* feat(budgets): enhance personal budgets display for admins with preview feature indication

* feat(budgets): enforce user-specific budget and category visibility for personal budgets

* feat(budgets): create budget section titles and add translations notice in preferences

* feat(budgets): let household and personal budgets coexist with sharing

Previously enabling personal_budgets made the shared household budget
unreachable. Budget.find_or_bootstrap now takes an explicit household:
flag so both can be resolved independently for the same period, with a
new household_budget_enabled family setting to opt out of the household
side and keep personal budgets only.

Adds a BudgetShare model (read_only/read_write) so a member can grant
another family member access to their personal budget, enforced via
Budget#viewable_by?/editable_by? across BudgetsController,
BudgetCategoriesController, PlansController, and the read-only API.
Preferences gains a Budget sharing card (gated on preview access like
the rest of the personal budgets UI) and an owner switcher pill (
Household / mine / shared-with-me) appears on the budget page and the
Plan hub card.

Also fixes personal budgets showing the same "actual spending" as the
household budget: actual spending/income now scope to the budget
owner's own accounts instead of the viewer's full accessible set,
via a new accounts: override on IncomeStatement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(budgets): enhance budget switcher with icons and improved styling

* feat(budgets): remove user name display from budget card and header

* feat(budgets): remove unique index on taggable_type and taggable_id in taggings

* feat(budgets): enhance budget sharing functionality and improve UI elements

* Collapse personal budget migrations

---------

Signed-off-by: JulienGourmet <69808509+jubbakka@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-18 08:36:46 +02:00
Sure Admin (bot) 73d43bc1d0 Fix SSO JIT new family creator role (#3024)
* Fix SSO JIT new family creator role

* Preserve super admin SSO creator defaults

* Update new family creator role test
2026-08-14 03:59:02 +02:00
William Wei MingandCursor 34dd5fbc62 update transactions_controller (#1953)
* update transactions_controller

* fix FEEDBACK - Remove DISTINCT that breaks tag-filtered index queries

* fix FEEBACK from jjmata

* Ignore Brakeman EOLRails warning for Rails 7.2
Restore fingerprint-scoped ignore lost during merge from main.

* update transactions_controller

* fix FEEDBACK - Remove DISTINCT that breaks tag-filtered index queries

* fix FEEBACK from jjmata

* Ignore Brakeman EOLRails warning for Rails 7.2
Restore fingerprint-scoped ignore lost during merge from main.

* resolve review - Add .distinct to the tag filtering subquery

* fix(ci): skip scheduled preview cleanup on forks
Only run the hourly Cloudflare preview cleanup on we-promise/sure,
where the required secrets exist.

* Drop obsolete Rails EOL note and Brakeman EOLRails ignore

The EOLRails ignore and the accompanying migration-rule note were only
needed while the app ran Rails 7.2.3.1, whose support window closed
2026-08-09. Main has since moved to Rails 8.1.3, so the check no longer
warns and both changes are dead weight that only widen this PR's diff.

Keeps the PR focused on the transactions controller query optimization.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 01:42:18 +02:00
pro3958andagentloop 8b2d792dab fix(api): scope holdings API to accessible accounts (#2706)
Api::V1::HoldingsController built both the index and show queries from
current_resource_owner.family.holdings, filtering only on account status.
A read-scoped API key could therefore read holdings (account id/name,
quantity, price, market value) for accounts owned by other family members
that were never shared with the key owner, and the account_id / account_ids
filters made targeted enumeration of those accounts trivial.

Scope the query through accounts.accessible_by(current_resource_owner) so
results are limited to accounts the token owner owns or has been granted
access to. Route both index and set_holding through the new
accessible_holdings scope. This matches Api::V1::BalancesController and
the web HoldingsController, which already scope the same way.

Fixes #2467

Co-authored-by: agentloop <agentloop@localhost>
2026-07-25 04:44:27 +02:00
Max BarbareandClaude Sonnet 5 51c93649da feat(snaptrade): replace device-flow OAuth with authorization-code + PKCE flow (#2747)
* feat(snaptrade): replace device-flow OAuth with authorization-code + PKCE flow

Squashed from 16 commits on snaptrade-oauth-apps for a clean rebase onto
current upstream/main ahead of opening a PR.

* fix(snaptrade): address PR #2747 review feedback on OAuth PKCE flow

- Remove unreachable dead-code guard in import_latest_snaptrade_data
- Guard apply_oauth_tokens! against a malformed payload missing access_token
- Wrap token endpoint network errors in ApiError and retry like data calls
- Remove unused Provider::Snaptrade#revoke_token! instance method
- Preserve return_to/accountable_type through the SnapTrade portal callback
  so the account-linking flow no longer drops users back to accounts_path
- Show the real absolute OAuth callback URL in self-hosted setup instructions
- Refresh brakeman.ignore fingerprint for the connect redirect after the
  return_to/accountable_type params were added

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8SCCmKX6RphB5E73WSUQQ

* fix(snaptrade): don't retry non-idempotent OAuth/API requests

CodeRabbit flagged that Provider::Snaptrade retried OAuth token
exchanges/refreshes and all API POST/DELETE calls (get_connection_url,
delete_connection) after timeouts/connection failures. If the response
is lost after SnapTrade already consumed a single-use auth code,
rotated the refresh token, or applied a POST/DELETE, replaying the
request either fails with invalid_grant on a token that actually
succeeded, or risks duplicate side effects. Retries are now limited to
GET requests; OAuth token requests and non-GET API calls translate a
network failure straight into an ApiError without replay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL

* fix(snaptrade): stop querying non-deterministically encrypted token via empty-string compare

CodeRabbit flagged that the syncable scope's where.not(oauth_access_token:
[nil, ""]) re-encrypts "" with a random IV on every query, so the ""
comparison can never match a stored ciphertext and is a silent no-op.
No code path ever persists oauth_access_token as "" (only nil or a real
token via apply_oauth_tokens!), so the exclusion is unnecessary --
narrowed the scope to a plain NULL check, which encryption handles
transparently since nil is never encrypted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 22:45:44 +02:00
Josh 97391a27ba Share family accounts with invited members on every sign-up path (#2650)
* fix: share family accounts with invited members on every sign-up path

A user who joined an existing family through an invitation was added to the
family but saw none of its accounts, even when the family's default sharing is
"share with all members". Only Invitation#accept_for created the AccountShare
records; the OIDC just-in-time sign-up, invite-token registration, and mobile
SSO onboarding paths all skipped it, so invitees landed in the family with an
empty account list. Signups routed into an invite-only default family had the
same gap.

Extract the sharing into Family#auto_share_existing_accounts_with, the single
entry point for "a member just joined, apply the family's sharing policy". It
honors default_account_sharing, only shares with a persisted member of the
family, grants read_only to guests and read_write to everyone else, excludes
accounts the user already owns, and is idempotent. accept_for and every
sign-up path call it, so no current or future join path can reintroduce the
empty-account bug or share accounts across families.

Also wrap the two SSO account-creation paths (OIDC JIT and mobile SSO) in a
transaction covering the user save, invitation acceptance, account sharing, and
identity creation, so a failure partway through can no longer leave a
half-onboarded user with no linked identity.

* address review: guest read_only symmetric in auto_share_with_family!, load-bearing guard comment, mobile SSO private-no-op test
2026-07-14 01:39:26 +02:00
DataEnginr 9b6a966ddb fix: derive amount_abs from inflow entry to avoid $0.00 regression on auto-matched transfers
- amount_abs now uses inflow_transaction.entry.amount_money.abs
  instead of the amount column, which is never set by the auto-matcher
- Rename transfer_has_opposite_amounts_or_fees to
  transfer_has_opposite_amounts (validation no longer checks fees)
- Remove unused calculate_rate_tab / convert_tab i18n keys
- Add fee-field assertions to API controller test and rswag spec
2026-06-30 18:00:13 +00:00
Orange🍊 6e5f35b306 fix(api): prevent API auth from inheriting impersonation (#2405)
Build fresh API session contexts instead of reusing persisted web sessions that may carry impersonation state.

Reject deactivated report export API key owners and strengthen regression coverage for API key, OAuth, and report export authentication paths.
2026-06-19 17:14:43 +02:00
ghost 4224ff717c perf(api): avoid transfer lookups in transaction index (#2127) 2026-06-16 09:54:57 +02:00
thebandit 64b4c0fee4 Add support for dividend, deposit, withdrawal, and interest trade types to Trades API (#1761)
* Update trades api with support for additional types

* rubocop fixes

* fix missing amount validation for interest type

* define missing schema reference

* fix test api_headers to use display_key per guidelines

* expand test coverage

* replaced duplicate JSON response blocks with helper method

* Add DB assertions to linked transfer test and fix invalid date test

* update brakeman.ignore fingerpint for refactored code

* Update the Brakeman ignore note to document validation for newly permitted keys

* fix API key auth in Minitest test to follow correct pattern

* update required in trades rswag spec to match the minimum fields that apply to all types

* extract dividend handling from build_investment_trade_params to dedicated method

* adjust response format to use the existing jbuilder views for Transfers and Transactions

* normalize type before passing to create form

* validate amount as a positive numeric value + tests

* rubocop fixes

* Add missing Trades API test coverage and docs

- Add Minitest tests for withdrawal (422, transfer linking), interest
  (explicit ticker), and dividend update
- Add rswag 401/403/404/422 response docs for create, update, destroy
- Regenerate docs/api/openapi.yaml

* Update Security.find line reference in brakeman.ignore note

* Mark TransactionResponse account_type as nullable in rswag docs
2026-06-13 11:55:16 +02:00
Blaž Dular 94422955f8 feat(merchants): add raw data import (csv) for merchants (#1992)
* feat(merchants): add csv import endpoint for merchants

* docs: update endpoint docs

* fix(merchant): recommended ai fixes
2026-06-06 16:33:32 +02:00
ghost 6e04c6927d feat(imports): add SureImport session batches (#1785)
* feat(imports): add SureImport session batches

Add first-class SureImport sessions for ordered multi-file NDJSON imports.

Persist source mappings across chunks, make session/chunk processing idempotent, expose progress readback, and keep existing single-file import behavior compatible.

Includes the devcontainer libvips runtime dependency needed by ActiveStorage variant tests.

Addresses #1610.

Related to #1458.

* fix(imports): avoid scanner-like API key test data

* test(imports): assert skipped balances are not persisted

* fix(imports): harden session publish retries

Validate expected import chunk sequences exactly before publish, and restore session state with error details when enqueueing the publish job fails.

* fix(imports): close session retry edge cases

Backfill expected chunk counts after client-session insert races and enqueue import-session jobs after the status transition commits. Persist a safe enqueue failure body so API readback does not expose raw queue errors.

* fix(imports): address session publish review gaps

Remove dead transaction external-id assignment, harden session publish retry/sync behavior, align session chunk status docs, and add regression coverage for partial retries and safe enqueue error readback.

* fix(imports): include sessions in family reset

Clear import sessions through the family reset job so chunk imports and source mappings do not survive a reset.

Expose import session and source mapping counts in the reset status response and regenerated OpenAPI schema so polling reflects the full reset surface.

* test(imports): cover split import mapping invariants

* test(imports): cover session verification invariants

* fix(imports): scope SureImport session reimports

* Tighten SureImport session batching

* fix(imports): export rule source ids for sessions

* test(imports): stabilize rule id export assertion

* test(imports): restore reset status session fixture
2026-06-04 11:48:44 +02:00
ghost cb660ebf65 refactor(imports): focus Sure preflight scope (#1833) 2026-06-03 00:37:21 +02:00
ghost 7580325418 fix(reset): scope family financial data resets (#1835)
Centralize family financial reset cleanup behind an explicitly scoped service, update reset status docs, and add two-family regression coverage for destructive reset behavior.
2026-05-31 00:21:34 +02:00
ghost 655895341d feat(imports): verify Sure NDJSON import readback (#1869)
* feat(imports): verify Sure NDJSON readback

* fix(imports): tighten Sure readback verification

* fix(imports): polish Sure verification review nits
2026-05-20 21:35:22 +02:00
Sure Admin (bot) 4fd460d551 Add Actual Budget CSV import flow (#1830)
* Add Actual Budget CSV import flow

* Address Actual import review feedback
2026-05-18 18:38:53 +02:00
ghostandJuan José Mata 95f6451b39 feat(sync): add Brex provider connections (#1752)
* feat(sync): add Brex provider schema

Adds Brex item and account tables with per-family credentials, scoped upstream account uniqueness, encrypted token storage, and sanitized provider payload columns.

* feat(sync): add Brex provider core

Adds Brex item/account models, provider client and adapter support, family connection helpers, and provider enum registration for read-only Brex cash and card data.

* feat(sync): add Brex import pipeline

Adds Brex account discovery, linked-account sync, cash/card balance processors, transaction import, sanitized metadata handling, and idempotent provider entry processing.

* feat(sync): add Brex connection flows

Adds Mercury-style Brex connection management, explicit item-scoped account selection and linking, settings provider UI, account index visibility, localized copy, and per-item cache handling.

* test(sync): cover Brex provider workflows

Adds targeted coverage for Brex provider requests, adapter config, item/account guards, importer behavior, entry processing, and Mercury-style controller flows.

* fix(sync): align Brex API edge cases

Tightens Brex account fetching against the official card-account response shape, sends transaction start filters as RFC3339 date-times, and keeps provider error bodies out of user-facing messages while expanding provider client guard coverage.

* fix(sync): harden Brex provider integration

Restrict Brex API base URLs to official hosts, tighten account-selection UI behavior, and add tests for invalid credentials, cache scoping, and provider setup edge cases.

* test(sync): avoid Brex secret-shaped fixtures

* refactor(sync): extract Brex account flows

* fix(sync): address Brex provider review feedback

* fix(sync): address Brex review follow-ups

Move remaining Brex review cleanup into focused model behavior, tighten link/setup edge cases, localize summaries, and add regression coverage from CodeRabbit feedback.

Also records the security-review pass as no-findings after diff-scoped inspection and Brakeman validation.

* refactor(sync): split Brex account flow controllers

Route Brex account selection and setup actions through small namespaced controllers while keeping existing URLs and helpers stable.

Business flow remains in BrexItem::AccountFlow; the main Brex item controller now only handles connection CRUD, provider-panel rendering, destroy, and sync.

* fix(sync): address Brex CodeRabbit review

* fix(sync): address Brex follow-up review

* fix(sync): address Brex review follow-ups

* fix(sync): address Brex sync review findings

* fix(sync): polish Brex review copy and errors

* fix(sync): register Brex provider health

* fix(sync): polish Brex bank sync presentation

* fix(sync): address Brex review follow-ups

* fix(sync): tighten Brex setup params

* test(api): stabilize usage rate-limit window

* fix(sync): polish Brex setup flow nits

* fix(sync): harden Brex setup params

* fix(sync): finalize Brex review cleanup

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 18:13:48 +02:00
ghost be598aecf0 feat(providers): add Kraken exchange sync (#1759)
* feat(providers): add Kraken exchange sync

Adds family-scoped Kraken API-key connections, read-only balance and trade import, account setup/linking flows, provider status wiring, and focused test coverage.

Closes #1758

* test(providers): avoid Kraken sample secret false positive

* fix(providers): address Kraken review findings

* fix(providers): address Kraken review cleanup

* test(imports): stabilize transaction import ordering
2026-05-12 00:22:37 +02:00
ghost 325084e342 fix(api): include disabled-account transaction history (#1723)
* fix(api): include disabled-account transaction history

* fix(api): hide pending deletion transaction history
2026-05-12 00:14:13 +02:00
ghost 1fedc43f68 feat(api): add import preflight validation (#1755)
* feat(api): add import preflight validation

* fix(api): harden import preflight validation
2026-05-12 00:00:49 +02:00
ghost 974f55e2d5 feat(api): add transaction idempotency keys (#1729)
* feat(api): add transaction idempotency keys

* fix(api): validate transaction idempotency source

* fix(api): tighten transaction idempotency params
2026-05-11 23:13:24 +02:00
Juan José Mata b74014ab42 Reject revoked OAuth tokens in API auth (#1711) 2026-05-09 01:39:10 +02:00
ghost 8abecf8a8d feat(exports): preserve transfer decisions (#1639)
* feat(exports): preserve transfer decisions

* fix(api): apply transfer date filters to both sides

* fix(api): refine transfer decision handling

* fix(api): align transfer decision schemas

* fix(api): use current context for transfer filters

* fix(api): include either side in transfer date filters

* fix(api): deduplicate transfer decision filters

* fix(api): guard transfer decision exports
2026-05-08 23:03:57 +02:00
ghost 45c5284148 feat(api): expose provider connection health (#1636)
* feat(api): expose provider connection health

* fix(api): harden provider health review paths

* fix(api): refine provider health responses

* test(api): align provider health docs key scope

* fix(api): clarify provider connection status

* fix(api): batch provider connection sync status

* fix(api): polish provider connection status review feedback

* fix(api): correct provider connection summaries
2026-05-07 00:42:32 +02:00
d1081547ec feat(api): allow creating categories via API (#1676)
* feat(api): allow creating categories via API

Adds POST /api/v1/categories so external integrations (e.g. bulk
classification scripts that import already-categorized data from
another system) can create categories without going through the web UI.
Mirrors the existing tags create endpoint: requires the read_write
scope, accepts name/color/icon/parent_id, auto-suggests an icon when
omitted, and rejects parent_ids from other families.

Also adds Minitest behavioural coverage, an rswag docs spec, a
CategoryCreateRequest schema, and regenerates docs/api/openapi.yaml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): address review feedback on POST /api/v1/categories

- Re-raise ActionController::ParameterMissing in #create so the
  BaseController rescue_from handles it as a 400 instead of the
  generic 500 from the broad rescue inside the action.
- Add a 403 'insufficient scope' response block to the rswag POST
  example so the generated OpenAPI documents read-only key rejection.
- Switch the new create-action Minitest cases to API key auth via
  X-Api-Key + api_headers (using the existing api_keys fixtures),
  matching the project's API endpoint consistency rule.
- Add Minitest coverage for two more 4xx paths: rejecting third-level
  nesting (parent_id pointing at a depth-2 subcategory) and rejecting
  requests without the category payload (400).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(test): migrate categories API index/show tests to X-Api-Key

The pre-existing index and show tests in this file authenticated via
Doorkeeper bearer tokens. Per the project's API endpoint consistency
rule (CLAUDE.md, .cursor/rules/api-endpoint-consistency.mdc) Minitest
controller tests under test/controllers/api/v1/ must use ApiKey +
X-Api-Key auth. Drops the Doorkeeper application/access-token setup
and routes every request through the existing api_keys fixtures and
the api_headers helper, matching the create-action tests already in
this file (and the pattern used in sync/users/family_settings tests).

No behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): address second-round review on POST /api/v1/categories

- Add a 400 response block to the POST rswag example so the generated
  OpenAPI documents the missing-category-payload contract that
  BaseController#handle_bad_request already returns. Regenerate
  docs/api/openapi.yaml.
- Replace fixture-backed read_write_api_key / read_only_api_key
  helpers with explicit ApiKey.create! calls (matching the pattern in
  sync_controller_test, users_controller_test, and
  family_settings_controller_test). Setup now destroys active keys for
  the test user so the one-active-key-per-source validation does not
  collide with fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(api): tighten 422 create-category cases

- Pass color and icon explicitly in the duplicate-name and
  third-level-nesting tests so each case is self-documenting about
  which validation it isolates (the model's color presence check is
  satisfied by the column default today, but reviewers — human and
  bot — flagged the implicit reliance).
- Assert the JSON error envelope (error key + present message) on every
  422 path so the response shape stays consistent and a regression in
  the rendered error body is caught uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): tighten POST /api/v1/categories per review

- Drop the no-op `rescue ActionController::ParameterMissing; raise` and
  the broad `rescue => e` from the create action. The BaseController
  already has rescue_from ActionController::ParameterMissing → 400, and
  unexpected exceptions are best left to Rails' default 500 handling
  (which logs identically). Keeps the action focused on its happy path
  and the two real error branches.
- Stop accepting `lucide_icon` as a request key. The OpenAPI schema
  documents only `icon`; the dual permit was undocumented and pointless.
  `icon` is now the single canonical request key, mapped to
  `lucide_icon` on the model in category_params.
- Migrate the Minitest helpers to the project's documented API key
  pattern: ApiKey.generate_secure_key + api_key.plain_key in the
  X-Api-Key header (matching the rswag spec in this PR and the rule in
  .cursor/rules/api-endpoint-consistency.mdc), instead of hand-built
  display_key strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Botched conflict merge

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-06 22:59:55 +02:00
ghost 9e369831ce feat(api): expose sync status (#1635)
* feat(api): expose sync status

* fix(api): harden sync status review paths

* fix(api): address sync status review

* fix(api): tighten sync status review fixes

* fix(api): address sync status review

* test(api): avoid secret-like sync fixture key

* test(api): reuse sync status fixture key

* fix(api): align sync route helpers

* fix(api): tighten sync status scoping

* fix(api): make sync status schema nullable-compliant
2026-05-06 22:02:21 +02:00
ghost 2d38cfb011 feat(api): expose budget state (#1640)
* feat(api): expose budget state

* fix(api): guard malformed budget ids

* fix(api): address budget state review

* fix(api): address budget state review

* fix(api): document budget id formats

* fix(api): align budget category docs auth

* fix(api): lighten budget category index payload

* fix(api): use shared pagination clamp

* fix(api): centralize budget filter handling
2026-05-06 20:50:46 +02:00
ghost 41339b0494 feat(api): expose balance history (#1641)
* feat(api): expose balance history

* fix(api): address balance history review

* fix(api): address balance history review

* fix(api): tighten balance history docs

* fix(exports): preserve balance chronology

* fix(api): guard nullable balance account type

* test(api): align balances api key helper

* fix(api): use shared pagination clamp

* test(export): set explicit balance flows factor
2026-05-05 19:09:36 +02:00
ghost 1ec8bd90b7 feat(api): expose import row diagnostics (#1644)
* feat(api): expose import row diagnostics

* fix(api): stabilize import row diagnostics

* fix(api): harden import row diagnostics

* fix(api): number Mint import diagnostics rows

* fix(api): enforce unique import row diagnostics

* fix(api): address import row diagnostics review
2026-05-05 01:12:48 +02:00
ghost a48f264799 feat(api): expose securities and price history (#1642)
* feat(api): expose securities and prices

* fix(api): stabilize security price filters

* fix(api): cap security pagination limits

* fix(api): preserve security price decimal scale

* fix(api): validate securities boolean filters

* fix(api): reject blank securities boolean filters

* fix(api): trim security exchange filter

* fix(api): tighten security price filters

* fix(api): tighten security resource filters

* fix(api): tighten securities docs fixtures
2026-05-05 01:08:43 +02:00
ghost 05ef8bd9e7 feat(api): support idempotent valuation writes (#1637)
* feat(api): support idempotent valuation writes

* fix(api): clarify valuation upsert status

* docs(api): document nested valuation upserts

* docs(api): clarify valuation upsert semantics

* docs(api): clarify valuation upsert signaling
2026-05-04 18:51:48 +02:00
ghostandJuan José Mata 9cb3b8e05c feat(api): expose rule run history (#1646)
* feat(api): expose rule run history

* fix(api): address rule run review

* fix(api): complete rule run review

* test(api): cover unauthenticated rule run show

* test(api): align rule run api key helper

* Small Sonnet nit-pick

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-03 23:33:35 +02:00
ghost e93b1f1fd7 feat(api): expose family settings (#1645)
* feat(api): expose family settings

* test(api): assert family settings moniker

* test(api): align family settings api key helper

* fix(api): tighten family settings schema
2026-05-03 23:10:46 +02:00
Michal TajchertandClaude Opus 4.7 ccd6a53071 fix(chat): eager pending AssistantMessage to fix Turbo subscribe race (#1657) (#1658)
* fix(chat): persist eager pending assistant message to fix subscribe race

When the LLM replies in ~1-2s the assistant message broadcast could
fire before the client's Turbo stream subscription was established,
leaving the UI stuck on the thinking indicator while the response was
already persisted.

Create the AssistantMessage as `pending` synchronously in
`Chat#ask_assistant_later`, so it is rendered server-side on the chat
show page with a "Thinking ..." inline placeholder. The worker then
finds and updates the existing row via `append_text!`, which flips the
status to `complete` and broadcasts updates against a DOM id that is
already in the page — no race possible. On error, the placeholder is
destroyed if no content streamed, otherwise demoted to `failed`.

Replaces the standalone thinking indicator partial and the
`Assistant::Broadcastable` thinking helpers, both now redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): bind each assistant job to its specific pending placeholder

Addressing review feedback on #1658:

1. The pending placeholder lookup based on `last pending` was racy —
   back-to-back user messages would let one job fill another job's
   placeholder. Pass the placeholder through the job arguments
   (`AssistantResponseJob.perform_later(user_message, pending)`) so
   each turn is bound to its own row.

2. In `Assistant::External#respond_to`, the configured/authorized
   guards raise before the local was bound, leaving rescue cleanup
   with `nil` and the placeholder visible forever. Bind the parameter
   first so cleanup can destroy it on the misconfigured path.

The kwarg defaults to nil so the API#retry path
(`AssistantResponseJob.perform_later(new_message)`) and the model-level
test calls continue to work — they fall back to an in-memory new
message, restoring the original test count assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): i18n the pending assistant placeholder string

Move the hardcoded "Thinking ..." indicator into the locale file per
CLAUDE.md i18n guidelines. With i18n.fallbacks enabled, non-en locales
fall back to English until translated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add thinking label translations

* Fix chat pending assistant expectations

* Fix external assistant pending test lookup

* Scope chat stream targets per chat

* Update message broadcast target tests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:33:29 +02:00
ghost 50936000e7 feat(api): expose family exports (#1632)
* feat(api): expose family exports

* fix(api): harden family export review paths

* fix(api): tighten family export review paths

* fix(api): reject invalid family export params

* fix(api): address family export review

* fix(api): share uuid guard for exports
2026-05-03 11:29:29 +02:00
ghost a8425a2488 feat(api): expose reset status polling (#1598)
* feat(api): expose reset status polling

* fix(api): hide reset enqueue exception details

* fix(api): use stable reset authorization message

* fix(api): narrow reset enqueue error handling

* fix(api): document reset enqueue failures

* docs(api): regenerate reset status OpenAPI

* fix(api): address reset polling review feedback
2026-05-02 22:56:42 +02:00
ghost c4414c4fbb feat(api): expose import status details (#1599)
* feat(api): expose import status details

* fix(api): reuse import status validation counts

* fix(api): cache Sure import status reads

* fix(imports): invalidate cached Sure import blobs

* docs(api): split import status schemas

* fix(api): refine import status detail contract
2026-05-01 22:59:32 +02:00
ghost da42423475 feat(api): accept Sure NDJSON imports (#1601)
* feat(api): accept Sure NDJSON imports

* fix(api): preserve uploaded Sure imports on publish errors

* fix(api): reset preserved Sure imports after enqueue failure

* fix(api): tighten Sure import upload handling

* test(api): align import API key fixtures

* docs(api): document import publish failure IDs
2026-05-01 22:56:18 +02:00
ghost b710b55124 feat(api): add recurring transaction endpoints (#1600)
* feat(api): add recurring transaction endpoints

* fix(api): return validation errors for recurring writes

* fix(api): harden recurring transaction request handling

* fix(api): require writable recurring account access

* fix(api): default null recurring manual flag

* fix(api): tighten recurring transaction contracts

* test(api): align recurring transaction fixtures

* docs(api): regenerate recurring transaction OpenAPI
2026-05-01 21:21:34 +02:00
ghostcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Juan José MataJuan José Mata
783309188f feat(api): expose rule export endpoints (#1602)
* feat(api): expose rule export endpoints

* fix(api): tighten rule export contracts

* fix(api): document balance sheet auth errors

* test(api): align rule API key fixtures

* Update docs/api/openapi.yaml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

* Quick win

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Signed-off-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-01 19:47:06 +02:00
352c301e4b feat(api): expose valuation history index (#1596)
* feat(api): expose valuation history index

* fix(api): hide valuation exception details

* fix(api): reuse eager-loaded valuation entries

* fix(api): tighten valuation index contracts

* fix(api): scope valuation filter errors

* docs(api): nest valuation account filter format

* Fix merge conflict mistakes

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-01 19:09:56 +02:00
ghost cc043b5caf feat(api): expose complete account export state (#1597)
* feat(api): expose complete account export state

* fix(api): handle malformed account identifiers

* fix(api): tighten account export contracts

* fix(api): correct account id OpenAPI format

* fix(api): tighten account docs auth contracts

* docs(api): document balance sheet auth errors

* docs(api): clarify account scope fixture
2026-05-01 15:22:28 +02:00
David Gil 7f17fbf6da security: sanitize exception messages in v1 API responses (FIX-11) (#1521)
* fix(security): sanitize exception messages in API responses (FIX-11)

Replace raw e.message/error.message interpolations in response bodies
with generic error strings, and log class+message server-side. Prevents
leaking internal exception details (stack traces, SQL fragments, record
data) to API clients.

Covers:
- API v1 accounts, categories (index/show), holdings, sync, trades,
  transactions (index/show/create/update/destroy), valuations
  (show/create/update): replace "Error: #{e.message}" with
  "An unexpected error occurred".
- API v1 auth: device-registration rescue paths now log
  "[Auth] Device registration failed: ..." and respond with
  "Failed to register device".
- WebhooksController#plaid and #plaid_eu: log full error and respond
  with "Invalid webhook".
- Settings::ProvidersController: generic user-facing flash alert,
  detailed log line with error class + message.

Updates providers_controller_test assertion to match sanitized flash.

* fix(security): address CodeRabbit review

Major — partial-commit on device registration failure:
- Strengthened valid_device_info? to also run MobileDevice's model
  validations up-front (device_type inclusion, attribute presence), not
  just a flat "are the keys present?" check. A client that sends a bad
  device_type ("windows", etc.) is now rejected at the API boundary
  BEFORE signup commits any user/family/invite state.
- Wrapped the signup path (user.save + InviteCode.claim + MobileDevice
  upsert + token issuance) in ActiveRecord::Base.transaction. A
  post-save RecordInvalid from device registration (e.g., racing
  uniqueness on device_id) now rolls back the user/invite/family so
  clients don't see a partial-account state.
- Rescue branch logs the exception class + message ("#{e.class} - #{e.message}")
  for better postmortem debugging, matching the providers controller
  pattern.

Nit:
- Tightened providers_controller_test log expectation regex to assert on
  both the exception class name AND the message ("StandardError - Database
  error"), so a regression that drops either still fails the test.

Tests:
- New: "should reject signup with invalid device_type before committing
  any state" — POST /api/v1/auth/signup with device_type="windows"
  returns 400 AND asserts no User, MobileDevice, or Doorkeeper::AccessToken
  row was created.

Note on SSO path (sso_exchange → issue_mobile_tokens, lines 173/225): the
device_info in those flows comes from Rails.cache (populated by an earlier
request that already passed valid_device_info?), so the pre-validation
covers it indirectly. Wrapping the full SSO account creation (user +
invitation + OidcIdentity + issue_mobile_tokens) in one transaction would
be a meaningful architectural cleanup but is out of scope for this
error-hygiene PR — filed it as a mental note for a follow-up.
2026-04-19 18:38:23 +02:00
Tomer Horowitz d6183be1ae fix: instantiate RuleImport before generating rows (#1354)
* fix: instantiate RuleImport before generating rows

* test: use API keys in imports controller tests
2026-04-03 01:33:11 +02:00