Skip to content

feat(accounts): add tax treatment classification (Taxable / Tax Deferred / Tax Free)#840

Open
Lunchtime0614 wants to merge 2 commits intoafadil:mainfrom
Lunchtime0614:claude/tax-treatment-upstream-pr
Open

feat(accounts): add tax treatment classification (Taxable / Tax Deferred / Tax Free)#840
Lunchtime0614 wants to merge 2 commits intoafadil:mainfrom
Lunchtime0614:claude/tax-treatment-upstream-pr

Conversation

@Lunchtime0614
Copy link
Copy Markdown

Summary

Adds a per-account tax treatment classification so users can categorize
accounts by how their gains are taxed (Taxable / Tax Deferred / Tax Free)
and view portfolio breakdowns by tax status.

Note on authorship: This feature was implemented end-to-end with
Claude Code (Anthropic's CLI coding
agent). I'm a hobbyist Wealthfolio user and wanted to contribute it back.
Happy to iterate on review feedback.

Features

Backend (Rust)

  • New migration 2026-04-01-000001_add_tax_treatment adds a non-null
    tax_treatment TEXT column on accounts, defaulting to TAXABLE so
    existing rows are preserved on upgrade.
  • TaxTreatment enum in wealthfolio-core accounts model with
    SCREAMING_SNAKE_CASE serde rename. Variants: Taxable (default),
    TaxDeferred, TaxFree.
  • Field is serialized on Account, NewAccount (with #[serde(default)]
    for backwards compat), and AccountUpdate (optional for partial updates).
  • Wired through wealthfolio-storage-sqlite repository, including
    update-path preservation — if the caller omits tax_treatment in an
    AccountUpdate, the existing value is kept rather than clobbered.
  • HTTP models in wealthfolio-server and the connect broker service
    both carry the new field.

Frontend (React / TypeScript)

  • Account form — new select field with Taxable / Tax Deferred / Tax Free
    options. Preserved across edit, archive, and hide flows.
  • Zod schema + adapter carry taxTreatment through the update_account
    IPC call.
  • Holdings Insights — new TaxTreatmentDonutChart widget showing
    portfolio value grouped by the account's tax treatment.
  • Dashboard Accounts card — expanded account groups now render a
    per-group tax treatment breakdown (Taxable / Tax Deferred / Tax Free
    subtotals) inside the accordion.

Test plan

  • cargo check -p wealthfolio-core -p wealthfolio-storage-sqlite -p wealthfolio-server -p wealthfolio-connect passes
  • tsc --noEmit clean on every modified frontend file
  • Migration uses a NOT NULL DEFAULT 'TAXABLE' so existing rows upgrade cleanly
  • Maintainer end-to-end smoke test (form → donut → group breakdown)

Backwards compatibility

  • Migration defaults existing rows to TAXABLE — no data loss, no manual steps.
  • NewAccount::tax_treatment uses #[serde(default)] so older HTTP clients still work.
  • AccountUpdate::tax_treatment is Option<...>, so partial updates that don't touch the field leave it untouched.

Notes

  • Branched directly off afadil/wealthfolio@main at v3.2.1, so no merge conflicts expected.
  • All code, tests, and this PR description were written with Claude Code.

Adds a per-account tax treatment classification (Taxable, Tax Deferred,
Tax Free) so users can categorize accounts by how their gains are taxed
and see portfolio breakdowns by tax status.

Backend (Rust):
- New Diesel migration adds non-null tax_treatment TEXT column on
  accounts (defaults to 'TAXABLE' so existing rows are preserved).
- Adds TaxTreatment enum to wealthfolio-core accounts model with
  SCREAMING_SNAKE_CASE serde rename, serialized on Account, NewAccount
  (with serde default) and AccountUpdate (optional for partial updates).
- Wires the new field through the sqlite repository (including
  update-path preservation when the caller omits it), the HTTP models
  in wealthfolio-server, and the connect broker service.

Frontend (React):
- Account form exposes a tax treatment select (Taxable / Tax Deferred /
  Tax Free). Edit modal, archive, and hide flows preserve the value.
- Zod schema and adapter layer carry taxTreatment through the
  update_account IPC call.
- New TaxTreatmentDonutChart on the Holdings Insights page shows
  portfolio value grouped by account tax treatment.
- AccountsSummary dashboard card renders a per-group tax treatment
  breakdown inside expanded account groups.

This feature was implemented entirely with Claude Code.
@triantos
Copy link
Copy Markdown
Contributor

Thanks for putting this together — the UI (dashboard breakdown + insights donut) is a nice addition.

I've been working on a larger change that's also focused on tax (entity modeling, jurisdictional rule groups, transaction-level withholding), so I hope you don't mind some feedback from a fellow contributor. A few things jumped out that might be worth looking at before this would be merged — none are blockers for the concept, just things I noticed:

1. TaxTreatment::from_str silently falls back to Taxable on unknown input (crates/core/src/accounts/accounts_model.rs)

pub fn from_str(s: &str) -> Self {
    match s {
        "TAX_FREE" => TaxTreatment::TaxFree,
        "TAX_DEFERRED" => TaxTreatment::TaxDeferred,
        _ => TaxTreatment::Taxable,
    }
}

If the column ever drifts (manual edit, sync from a newer device, future variant) this silently rewrites the value to Taxable. Might be safer to return Result<Self, E> (or impl FromStr with an error) and let the caller decide — at minimum, log a warning on the fallback path.

2. Two conflicting defaults on update (crates/storage-sqlite/src/accounts/model.rs and repository.rs)

From<AccountUpdate> for AccountDB defaults missing tax_treatment to "TAXABLE", then the repository (line ~92) checks tax_treatment_provided and overwrites that with the existing DB value. The frontend adapter then strips taxTreatment from the payload to force the preserve path:

// adapters/shared/accounts.ts
const { taxTreatment, ...updatePayload } = payload;
const finalPayload = taxTreatment ? { ...updatePayload, taxTreatment } : updatePayload;

Three layers working around the same issue. Could be simpler if the DB model field were Option<String> on the update path and preservation happened uniformly — or drop the default in From<AccountUpdate> and rely solely on the repository's preserve logic.

3. Archive/hide handlers can overwrite tax_treatment (apps/frontend/src/pages/settings/accounts/accounts-page.tsx)

updateAccountMutation.mutate({
  ...account,
  taxTreatment: (account.taxTreatment || "TAXABLE") as ...,
  isArchived: archive,
});

If account.taxTreatment is ever undefined or an empty string (legacy row that somehow escapes the migration default, a transient loading state, an API shape change), the || fallback silently flips the account to TAXABLE on archive. Since these handlers only need to toggle isArchived/isActive, they might be cleaner if they didn't send taxTreatment at all and let the repository preserve it.

4. Sync replay may reject the new column on older devices

The sync payload layer (crates/storage-sqlite/src/sync/app_sync/repository.rs::normalize_payload_fields) errors on unknown columns. A device on this version syncing an accounts payload with tax_treatment to a device that hasn't migrated yet would fail replay with "Sync payload column 'tax_treatment' is not valid". Might be worth adding a column-rename/readonly entry so older devices can silently skip it, or documenting that syncing devices need to upgrade together.

5. groupingMode looks unused (apps/frontend/src/lib/settings-provider.tsx, types.ts)

groupingMode: "none" | "accountGroup" | "taxTreatment" is added to the settings context and SettingsContextType, but I don't see anything reading it in this PR. Is it intended for a follow-up?

6. New-account form marks taxTreatment required, but the enum defaults to Taxable

account-form.tsx manually errors if taxTreatment is unset for new accounts, but the backend #[derive(Default)] and #[serde(default)] on NewAccount.tax_treatment give it Taxable anyway. Might be worth picking one approach — either require it in the UI and have the backend reject missing values, or drop the UI-side check and let the default stand.


Thanks again for contributing this!

Cleans up the tax treatment feature in response to PR review feedback:

- Make `TaxTreatment::from_str` strict via `FromStr` (returns a
  validation error on unknown input instead of silently falling back to
  `Taxable`). The DB layer and the server HTTP model still default to
  `Taxable` for forward-compat with unexpected values, but now log a
  warning on the fallback path so drift is visible.
- Drop the `"TAXABLE"` default in `From<AccountUpdate>` and the
  TS adapter's strip-and-re-add workaround. The repository's
  `tax_treatment_provided` flag is now the single source of truth for
  preservation on update. When the caller omits `taxTreatment`,
  JSON.stringify drops the undefined key, serde resolves to `None`, and
  the repository copies the existing DB value.
- Remove the `taxTreatment: (... || "TAXABLE")` fallback from the
  archive/hide handlers so toggling those flags can't accidentally flip
  a legacy row's tax treatment to `TAXABLE`.
- Remove the unused `groupingMode` state from `SettingsProvider` and
  `SettingsContextType` (leftover scaffolding with no readers).
- Add missing `tax_treatment` field to test fixtures in
  `net_worth_service_tests.rs` and `snapshot_service_tests.rs` that the
  original feature commit missed.
@Lunchtime0614
Copy link
Copy Markdown
Author

Thanks for the review — I am still very new to all of this and with the help of Claude AI again, I reviewed your comments and had Claude make some changes. I agree with your comments - I was coming at this from a self hosted perspective. I've just pushed a follow-up commit addressing points 1, 2, 3, and 5, with 4 and 6 left as discussion points below.

1. TaxTreatment::from_str silent fallback — Replaced with a proper FromStr impl that returns a ValidationError on unknown input. The DB-layer conversion (From<AccountDB> for Account) and the server HTTP model still fall back to Taxable for forward-compat, but now emit a warn! log so drift is actually visible instead of silent.

2. Conflicting defaults on update — You were right that this was three layers patching the same hole. I dropped the "TAXABLE" default in From<AccountUpdate> (it now leaves an empty placeholder, matching the currency / created_at pattern) and removed the TS adapter's strip-and-re-add dance. The repository's tax_treatment_provided flag is now the single source of truth for the preserve path. I kept AccountDB.tax_treatment as String rather than restructuring into separate insert/update DB models — that felt like a bigger change than this PR called for, but happy to go further if you think it's warranted.

3. Archive/hide handlers overwriting tax_treatment — Fixed. Both handlers now destructure taxTreatment out of the spread rather than synthesizing a "TAXABLE" fallback, so toggling isArchived / isActive can't accidentally flip a legacy row.

4. Sync payload rejection on older devices — Good catch, and honestly the trickiest one to address retroactively since the older devices are already out there. I didn't change anything here yet — my read is that the cleanest path forward is to document in the release notes that syncing devices need to upgrade together for this release. An apply_column_rename-style forward-compat shim wouldn't help since old devices don't have that entry either. Curious whether your larger tax PR has a solution for this that I should align with?

5. Unused groupingMode — Removed from both SettingsProvider and SettingsContextType. You were right that it was leftover scaffolding with no readers. If a future grouping feature needs it, it can land with its consumers.

6. New-account form required vs. backend default — I left this as-is for now because the current combo (UI requires, backend defaults to Taxable) is actually defensive: the UI enforces intent for humans, and the backend default keeps programmatic / non-UI callers working. Happy to change if you'd prefer strict backend validation (drop #[serde(default)] + #[derive(Default)] on NewAccount.tax_treatment) — just flag it and I'll make the change.

Thanks again for taking the time. Looking forward to seeing your larger tax PR — sounds like there'd be useful overlap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants