Compare commits

...

29 Commits

Author SHA1 Message Date
Nikhil Verma
e3952d2a24 [AI] Add option to 'Copy [budget] to future months' (#7420)
* [AI] Add 'Copy to future months' budget option

Adds a new per-category budget menu option that copies the current
month's budgeted amount to all future months that already exist in
the budget. Works for both envelope and tracking budget types, and
on both desktop (inline popover) and mobile (modal) views.

* [AI] Rename release note to PR #7420

* [AI] Skip empty budget months in copyToFutureMonths

Only copy the budget value to future months that already have a
non-zero budget set for the category. Months with no budget (zero)
are left untouched.

* [AI] Rename to 'Copy until year end', limit to tracking budget, add year-end cap

* [AI] Address CodeRabbit feedback: i18n undo notification, clarify docs

* [AI] Fix typecheck: branch dispatch by budgetType for proper modal narrowing

* [AI] Fix lint: add t to useCallback deps
2026-05-06 18:19:40 +00:00
Michael Clark
fea36466d2 fix rss feed (#7732) 2026-05-06 18:18:30 +00:00
youngcw
1fadfa4e9b [AI] Fix #2155: duplicated transactions are marked as uncleared (#7723)
* init

* note
2026-05-06 17:43:30 +00:00
Aaron
6ead7ea42c docs: add Enable Actual to community repos (#7697)
Co-authored-by: Aaron <aaron@noreply.squaresine.com>
2026-05-06 00:31:41 +00:00
Matt Fiddaman
d6fc3212b9 re-sort preview transactions after rule application (#7691)
* resort schedules after rule application

* note
2026-05-05 23:48:26 +00:00
Alec Bakholdin
071611fcc5 Cannot read properties of null (reading 'toLowerCase') (#7704)
* added null safety in throwIfNot200

* release notes

* updated release notes

---------

Co-authored-by: Alec Bakholdin <alecbakholdin.com>
2026-05-05 23:03:02 +00:00
Matt Fiddaman
263358b5cf fix vrt update workflow (#7699)
* fix vrt update workflow

* note
2026-05-05 20:26:48 +00:00
Michael Clark
44fc959ed8 :electron: Fix electron dev mode not starting (#7712)
* fix electron dev mode

* release notes
2026-05-05 08:08:49 +00:00
Dan Hopkins
d787d0ce43 fix: only count failed attempts against auth rate limit (#7707)
* fix: only count failed attempts against auth rate limit

Add skipSuccessfulRequests: true to authRateLimiter so that successful
logins do not consume quota. This fixes breakage for API clients
(actual-cli, actual-mcp, custom scripts) that re-authenticate per
operation — they always provide the correct password, so they should
never be rate-limited.

Brute-force attackers generate repeated failures and still hit the wall.

Fixes #7706

* Update upcoming-release-notes/7706.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* fix: rename release note to match PR number

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-05-04 22:27:18 +00:00
Aurora-Flipped
2c3e2a34fd Fix/spending report date range save (#7672)
* Fix saved spending report date range

* Add release note
2026-05-04 20:28:19 +00:00
lelemm
78d533c800 Bank sync page refactor (#7449)
* Bank sync refactor extracted from plugins

* code review

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7449

* [AI] Resolve bank sync PR conflicts

Co-authored-by: lelemm <lelemm@users.noreply.github.com>

* Change author name in 7449 release notes

Updated author name in release notes.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: lelemm <lelemm@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-05-04 14:51:33 +00:00
Juulz
49f6b21f2c [Bugfix]🐛 Fix refresh icon (sync) centering in Titlebar (#7674)
* Fix svg rendering in Titlebar component

* Fix conditional rendering of syncState text

* Fix string interpolation in Titlebar component

* Add release notes for bugfix on refresh icon centering

* Clarify refresh icon centering fix in Titlebar
2026-05-04 14:49:33 +00:00
Emil Tveden Bjerglund
9f05207fe8 Fix Cover Overspending menu closing when view too narrow (#7687)
* Fix Cover Overspending menu closing when view too narrow

* Update upcoming-release-notes/7687.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Increase min width

* Set minWidth instead

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-04 14:48:30 +00:00
Matt Fiddaman
8366c442a2 automation UI: seperate percentage sources in error logic (#7694)
* seperate percentage sources in error logic

* note

* restructure and add test

* fix income category resolution and add regression test
2026-05-04 13:59:55 +00:00
Matt Fiddaman
4b73fd7e45 link budget automation UI experimental feature to a feedback issue (#7693)
* link to feedback issue

* note
2026-05-03 22:46:10 +00:00
Julian Dominguez-Schatz
c593bda145 Update release docs to reflect latest process (#7690)
* Update release docs to reflect latest process

* Add missed space

* GitHub doesn't like non-American spelling

* PR feedback
2026-05-03 22:02:08 +00:00
Andreas Offenhaeuser
1b86bba2cd [Bugfix] Disable 2-day lookback for automatic transactions (#7299)
* disable 2-day lookback for automatic transactions

* add PR release notes
2026-05-03 21:44:28 +00:00
Matiss Janis Aboltins
6c2c96e826 🔖 (26.5.0) (#7621)
* 🔖 (26.5.0)

* fix release note generation script (#7635)

* fix release note generation script

* note

* fix cherrypicked commits not being respected and lint race in release note generation workflow (#7640)

* fix cherrypicked commits not being respected and lint race

* note

* coderabbit suggestions

* fix lint

* make double restore possibility safe

* fix lint (#7643)

* Generate release notes for v26.5.0

* add release note highlights

* Fix Sankey income bug, when payee it not set (#7632)

* Ensure income categories are shown correct, even if payee is not set

* Add release note

* Generate release notes for v26.5.0

* increase test coverage for budget templates (#7620)

* [AI] cover existing template engine logic with regression tests

Adds tests for goal template behavior that predates this PR so the
suite can be cherry-picked onto master to confirm no regressions. No
production code changes.

Covers:
- init() validation: schedule names, by/schedule priority match, past
  by-target with and without annual/repeat, percentage source not
  found, special source aliases, duplicate limit/spend/goal
  directives, weekly limit missing start date, invalid limit period,
  unrecognized periodic period
- runRemainder cap clamping and hideDecimal fraction removal
- Income-category branch in runTemplatesForPriority
- getLimitExcess against an aggregate weekly cap
- Past by-target rolling forward via the annual period
- runSchedule full=true (no sinking accumulation), percent and fixed
  adjustments, completed-schedule filtering, past-date error for
  non-repeating schedules, monthly/weekly/daily sinking contribution
  branches when interval exceeds the pay-month-of cap, surplus
  absorption when last-month balance exceeds the target, and
  tracking-budget mode forcing all schedules pay-month-of
- applyMultipleCategoryTemplates orchestration: per-category writes,
  cross-category priority clamping when funds run out, error
  notification path
- applyTemplate force=false skipping already-budgeted categories

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

* note

---------

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

* fix infinite loop when remainder is impossible to solve (#7623)

* fix infinite loop when remainder is impossible to solve

* note

* Generate release notes for v26.5.0

* Update author

Updated author information in the release notes.

* Fix shared worker resumption after tab suspend (#7656)

* [AI] Fix SharedWorker tab resume recovery

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* [AI] Fix SharedWorker reload readiness

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add release notes

* Update packages/desktop-client/src/shared-browser-server-core.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* Update docs release date

* Empty commit to bump CI

* Generate release notes for v26.5.0

* Revert "Generate release notes for v26.5.0"

This reverts commit b42c48bed5.

---------

Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Emil Tveden Bjerglund <emilbp@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 17:41:17 +00:00
Matt Fiddaman
6298f6a324 improve experimental budget automation UI (#7597)
* [AI] initial

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

* [AI] review pass 1

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

* [AI] review pass 2

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

* [AI] review pass 3

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

* [AI] review pass 4

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

* remove dev localstorage gate

* [AI] block migration for #goal and #cleanup directives

Detect goal templates (`type: 'goal'`) and `#cleanup` lines in the
category notes before rendering the editor. When either is present,
show an unsupported-directive notice instead of the modal body so the
migration helper never runs and the Save flow can't silently overwrite
`template_settings.source` to `'ui'` (which would stop the engine from
reading the notes for those directives).

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

* note

* [autofix.ci] apply automated fixes

* [AI] don't synthesise refill for monthly+limit simple templates

A `#template 50 up to 200` line parses to a simple template with both
monthly and limit set. The migration helper previously expanded that
into limit + refill + periodic, but the engine's runSimple just budgets
the monthly amount and clamps to the cap — there is no implicit refill
to undo.

Only synthesise the refill for the limit-only form
(`#template up to 200`), where runSimple's fallback returns
limitAmount - fromLastMonth. Update the migration test to match.

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

* [AI] coderabbit fixes

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

* [AI] coderabbit fixes v2

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

* [AI] attribute schedule batch contributions per template

Replace the equal-split fallback with each schedule's actual monthly
contribution (computed inside runSchedule) so the per-row UI projection
reflects real cost rather than total / count.

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

* [AI] coderabbit v3

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

* [AI] coderabbit v4

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

* [AI] fix unclearable integer inputs in automation editors

GenericInput type=number fired onChange on every keystroke and the
dispatcher coerced empty/0 back to 1, so the field snapped back before
the user could retype. Switch to a local string state that commits and
clamps on blur, with min/step constraints on the native input.

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

* [AI] coderabbit v5

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

* Update packages/desktop-client/src/components/budget/goals/templateHelpers.tsx

Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>

* Update packages/desktop-client/src/components/budget/goals/templateHelpers.tsx

Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>

* [autofix.ci] apply automated fixes

* remove comment

* s/rule/automation

* update note wording

* tweak hold checkbox wording

* deduplicate translation strings

* rename buildPresetSeeds

* deduplicate example text

* update wording about how easy automations are

* s/P/Priority

* reuse week template

* replace week displayType with fixed

* lodash debounce

* [autofix.ci] apply automated fixes

* more graceful error handling

* change default priority to 1

* align ui and text language for available funds & all income

* fix tests

* coderabbit v{lost_count}

* more coderabbit

* extract isValidYearMonth

* extract automationExamples

* split out into multiple files

* split down templateHelpers

* [AI] cover PR additions to template engine

Tests targeting code introduced in this PR:
- per-template attribution map (perTemplateContribution) on
  CategoryTemplateContext for sibling periodic templates, batched `by`
  templates, limit-clamped totals, and remainder weights
- runBy now returns { toBudget, perTemplateNeed }
- runSchedule now returns perScheduleMonthly keyed by trimmed name,
  including a multi-schedule pay-month-of + sinking-fund mix
- dryRunCategoryTemplate end-to-end through computeTemplates, in both
  narrow-scope and wide-scope (remainder / available funds) branches
- checkPercentage now accepts category ids (UI form), not just names

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

* fix repeat every month error

* coderabbit again.

* Update packages/desktop-client/src/components/budget/goals/automationMessages.tsx

Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>

* Update packages/desktop-client/src/components/budget/goals/automationMessages.tsx

Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>

* [autofix.ci] apply automated fixes

* plural aware translation strings

* move + out of translation string

* fix types

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
2026-05-01 10:33:02 +00:00
Matiss Janis Aboltins
1afe7c9a1e Enable auto enrichment in coderabbit configuration (#7664)
* Enable auto enrichment in coderabbit configuration

* Add release notes for PR #7664

* Change category to Maintenance and update description

Updated the category from 'Features' to 'Maintenance' and revised the description.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-30 17:02:52 +00:00
Julian Dominguez-Schatz
24279264da Fix shared worker resumption after tab suspend (#7656)
* [AI] Fix SharedWorker tab resume recovery

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* [AI] Fix SharedWorker reload readiness

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add release notes

* Update packages/desktop-client/src/shared-browser-server-core.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-04-30 07:37:02 +00:00
Matiss Janis Aboltins
4a5ee9c2dc [AI] Make TypeScript work in test files across packages (#7642)
* [AI] Make TypeScript work in test files across packages

Match the CRDT package's tsconfig pattern in loot-core, desktop-client,
api, and desktop-electron so test files participate in the project graph
(IDE intellisense, project-wide typecheck) while production builds still
emit clean declaration files.

- Remove test-file exclusions from each package's main tsconfig
- Add tsconfig.build.json for loot-core and api with test exclusions,
  used by the build scripts
- Add e2e/tsconfig.json for desktop-client and desktop-electron with
  Playwright types
- Fix latent type errors in test files now caught by typecheck
- Disable typescript/unbound-method for test files (mock matcher pattern)

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

* Release notes

* [AI] Address review feedback on test type fixes

- goal-template.test.ts: extract amounts to typed locals so single-value
  assertions no longer compare against unknown
- category-template-context.test.ts: replace `as unknown as DbCategory`
  double-cast with a fully-typed object using `satisfies DbCategory`
  (the previous mock had `is_income: true` which doesn't match the
  `1 | 0` shape the cast was hiding)
- api/tsconfig.build.json: broaden test exclude pattern to `**/*.test.ts`

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

* [AI] Widen toMatchThemeScreenshots matcher to accept Page

The matcher's runtime impl already handled both Page and Locator
(via `typeof locator.page === 'function'` branch), but the type only
declared Locator. Call sites pass a Page (`expect(page).toMatchThemeScreenshots()`),
which now compiles cleanly.

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

* [AI] Type window.Actual in e2e fixtures and refactor matcher

- Pull loot-core/typings/window.ts into the e2e tsconfig include so
  the ambient `window.Actual` augmentation is visible.
- Refactor toMatchThemeScreenshots to derive a Page once via
  `'page' in target`, then call evaluate on the page consistently.
  The previous union-typed access (locator.evaluate, locator.page)
  didn't typecheck on Locator | Page.

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

* [AI] Drop tsconfig.build.json for loot-core and api

The build-config indirection was incomplete protection: typecheck
(`tsgo -b` against the main tsconfig, which now includes test files)
already emits `*.test.d.ts` into `@types/`, and the build step does
not clean before re-emitting. The same is observable in crdt's
`dist/`, which currently contains test declarations on disk.

What actually keeps test declarations out of the npm tarball is the
`files` field in package.json — and loot-core already uses that
mechanism for source files (`\!src/**/*.test.ts`). Extending the same
pattern to `@types/` is more direct than maintaining a duplicate
tsconfig that doesn't reliably do its job.

- Delete loot-core/tsconfig.build.json; revert build to `tsgo -b`;
  add `\!@types/**/*.test.d.ts*`, `\!@types/**/__tests__/**`,
  `\!@types/**/__mocks__/**` to `files`.
- Delete api/tsconfig.build.json; revert build to
  `vite build && tsgo --emitDeclarationOnly`; add
  `\!@types/**/*.test.d.ts*` to `files`.

Verified: `yarn pack --dry-run` excludes all test declarations from
both packages while production declarations still pack (428 .d.ts
files for loot-core, methods.d.ts for api).

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

* [AI] Standardise crdt to drop tsconfig.build.json

Apply the same simplification as loot-core and api: a single tsconfig
per package, with `files`-field negations preventing test
declarations from being published.

Note: this also fixes a pre-existing issue where crdt was shipping
`crdt/timestamp.test.d.ts` and `crdt/merkle.test.d.ts` to npm. The
old `tsconfig.build.json` excluded test files from declaration emit,
but the `typecheck` script (`tsgo -b` via the main tsconfig) had
already emitted them into `dist/` and the build did not clean
first, so they were packed via `"files": ["dist"]`.

After this change, `yarn pack --dry-run` packs only production
declarations (10 .d.ts files) and excludes the test ones.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:06:58 +00:00
arjunrawal1
a8eb204ce7 Fix release notes link in PR template (#7655)
* Fix release notes link in PR template

* Add release note
2026-04-29 16:39:10 +00:00
rboadu
f68e4fbb2a Updated API Documentation for Splitting Transactions (#7593)
* Updated API Documentation for Splitting Transactions

* Update check-spelling metadata

* Updated category name in example

* Update packages/docs/docs/api/reference.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fixed documentation to CodeRabbits feedback

* Update packages/docs/docs/api/reference.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Updated wording of reference document

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
2026-04-29 16:28:14 +00:00
Matiss Janis Aboltins
dd3b1144d1 [AI] Mobile: iconography, placeholders, and dropdown chevrons for transaction form (#7639)
* [AI] Add iconography, placeholders, and dropdown chevrons to mobile transaction form

Brings the mobile new-transaction screen in line with the design mockup so
each field shows a leading icon, dropdown affordances surface on the right,
and empty pickers display a placeholder hint.

- Extend TapField with optional icon/placeholder props (placeholder uses
  formInputTextPlaceholder when value is empty).
- Extend InputField with an optional icon prop that wraps the input in a
  bordered row when present.
- Wire SvgUser/SvgTag/SvgWallet/SvgCalendar/SvgNotesPaper into Payee,
  Category, Account, Date, and Notes for both the main form and split
  child rows; add a SvgCheveronDown rightContent on Category and Account.

* Add release notes for PR #7639

* [AI] Forward consumer style to inner Input in icon path of InputField

CodeRabbit flagged that consumer-supplied `style` was being applied to the
wrapper View in the icon branch, so input-targeted properties like
`appearance: 'none'` and `minWidth: '150px'` on the Date field never reached
the underlying `<input type="date">`. Move the style spread onto the inner
Input so those styles take effect.

* [AI] Add dropdown chevron to mobile Payee field

Show the chevron on the Payee TapField in both the main form (as the
fallback when neither the Save-location nor Nearby-payee button applies)
and in split child rows, matching Category and Account.

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7639

* [AI] Hide native date picker icon on mobile transaction form

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

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7639

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-29 07:56:05 +00:00
Matiss Janis Aboltins
ff0f5bdb35 [AI] lage - move browser build to using lage (#7602)
* Simplify desktop client browser build

* [AI] Move browser build orchestration into vite config and lage

Moves loot-core worker build, public/ staging (migrations, default-db,
sql-wasm, data-file-index), and build-stats wiring from the deleted
packages/desktop-client/bin/build-browser shell script into a
lootCoreBackend vite plugin in packages/desktop-client/vite.config.mts.

Adds a build:browser target to lage.config.js so bin/package-browser
runs as a single `lage build:browser --to=@actual-app/web` call, with
crdt + loot-core built via lage's ^build dependency before the
desktop-client build.

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

* Refactor e2e-test workflow and update desktop-client configurations

* [AI] Move plugins-service staging into desktop-client vite config

Declares plugins-service as a workspace devDependency of @actual-app/web
so lage's ^build edge picks it up automatically in the build:browser
pipeline, and moves the cross-package file staging (production copy +
dev serving) into vite.config.mts, mirroring the lootCoreBackend
pattern. Drops the plugins-service shell wrapper script and simplifies
its package.json scripts to invoke vite build directly. Updates root
start:browser to run plugins-service watch in parallel with the dev
server instead of pre-building once.

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

* [AI] Sync tsconfig project references for plugins-service edge

Follow-up to the plugins-service workspace edge: adds the
../plugins-service project reference in packages/desktop-client/tsconfig.json
via yarn sync:tsconfig-references.

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

* Release notes

* [AI] Ignore .venv/ so lage's git hasher skips Electron CI's Python venv

Electron CI provisions a Python virtualenv at the repo root for
setuptools. With browser builds now routed through lage, lage's
git hash-object pass walks untracked-not-ignored files and fails on
the venv's broken lib64 symlink ("fatal: Unable to hash .../.venv/lib64").

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

* [AI] Bake Weblate translations back into VRT/e2e bundle

build-web set download-translations: false and relied on bin/package-browser's
ad-hoc git clone + git pull. That path is fragile inside the playwright
container, so vite's import.meta.glob('/locale/*.json') frequently produced an
empty languages map and the bundle shipped with no en.json. VRTs then rendered
source-code English and diffed against snapshots authored from Weblate strings.

Route translation provisioning back through actions/checkout (download-translations: true)
in build-web and vrt-update-generate, and add --skip-translations to bin/package-browser
(mirroring bin/package-electron) so the in-script git pull is bypassed when CI
has already staged the locale dir.

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

* [AI] Skip translation cloning in build-web bundle for VRT determinism

bin/package-browser used to unconditionally clone actualbudget/translations
before vite ran, baking Weblate en.json into the build artifact. With the
e2e-test pipeline now serving that artifact via serve-build.mjs, VRT
screenshots ended up rendering Weblate strings — drifting from the snapshots,
which were authored against source-code English (master VRTs ran on vite dev
without a locale dir).

Pass --skip-translations to bin/package-browser from build-web so the bundle
ships with no locale chunks. download-translations stays 'false' across the
e2e-test and vrt-update-generate workflows, matching the prior behavior.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:20:13 +00:00
Rudra Sarker
11ce29e7fd fix: disable Inter font contextual alternates to prevent x→× substitution (#7375)
* fix: disable contextual alternates in Inter font to prevent unwanted character substitution

Fixes #6351

The Inter font calt feature replaces x between digits with a
multiplication sign. Disable it while preserving ss01 and ss04.

* [autofix.ci] apply automated fixes

* Add release notes for PR #7375

* [autofix.ci] apply automated fixes

* Fix release notes category casing

* Add authors field to release notes

* Update upcoming-release-notes/7375.md

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2026-04-28 10:56:20 +00:00
Trevin Chow
d58c9a9a07 fix(transactions): show 'Date is a required field' error when adding without date (#7511)
* fix: show consistent 'Date is a required field' error in add transaction

When adding a new transaction with a missing date, the UI surfaced the
generic 'Something internally went wrong' error instead of a specific
message, while missing account showed 'Account is a required field'.
Add an analogous date validation mirroring the existing account check,
so both required-field errors are consistent.

Fixes #7424

* fix: prevent empty date from reaching server when clearing date input

When adding a transaction, clearing the date field and clicking Add
caused DateSelect's blur handler to save an empty string as the date
via onSelect(''). The server rejected '' as an invalid date, emitting
a generic "Something internally went wrong" error.

The previous fix (adding a date check in the shouldAdd guard) didn't
work because the cell save fires on blur BEFORE shouldAdd runs — by
the time shouldAdd checks the date, it's either already sent to the
server or reverted by DateSelect's clearOnBlur logic.

Fix: change DateSelect's empty-input blur path to restore the previous
valid date instead of propagating ''. This prevents the invalid value
from ever reaching the cell save handler or the server.

Verified locally: clear date → click Add → date restores to previous
value and transaction saves normally. No generic error.

Fixes #7424
2026-04-28 10:50:54 +00:00
Matiss Janis Aboltins
598e3ec9d8 [AI] Keep mobile transaction amount field at a consistent height when empty (#7638)
* [AI] Keep mobile transaction amount field at a consistent height when empty

When the user backspaced every digit while editing the amount on the
mobile transaction form, the inner <Text> span had no content and
collapsed the pill to zero height. Fall back to a non-breaking space so
the line box keeps its natural font-driven height even when the input
is momentarily empty.

* Add release notes for PR #7638

* [AI] Use amountToCurrency(0) instead of nbsp when amount input is empty

Mirrors the unfocused-zero display rather than rendering a blank pill,
and respects the user's hideFraction pref and locale separators
through amountToCurrency.

* Simplify description of mobile transaction field height

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-28 07:31:57 +00:00
299 changed files with 6719 additions and 2593 deletions

View File

@@ -1,6 +1,6 @@
issue_enrichment:
auto_enrich:
enabled: false
enabled: true
reviews:
request_changes_workflow: true
review_status: false

View File

@@ -1,4 +1,4 @@
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://actualbudget.org/docs/contributing/#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
## Description

View File

@@ -61,6 +61,7 @@ Dockerfiles
Dominguez
DUSSDEDDXXX
DUSSELDORF
ecf
EDATE
ENTERCARD
Entra
@@ -140,8 +141,6 @@ pluggyai
Poste
PPABPLPK
prefs
Primoco
Priotecs
proactively
Qatari
QNTOFRP
@@ -172,7 +171,6 @@ SWEDBANK
SWEDNOKK
Synology
systemctl
tada
taskbar
templating
THB

View File

@@ -46,13 +46,12 @@ jobs:
# via ConfigurationPage.createTestFile()) is still rendered in a
# production build. Without it, e2e tests would time out waiting for
# a button that was tree-shaken out.
# --skip-translations keeps VRT screenshots deterministic by rendering
# source-code English instead of upstream Weblate en.json (which can
# drift between snapshot capture and test runs).
env:
REACT_APP_NETLIFY: 'true'
run: |
yarn workspace plugins-service build
yarn workspace @actual-app/crdt build
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
run: yarn build:browser --skip-translations
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:

View File

@@ -65,6 +65,10 @@ jobs:
ref: ${{ steps.pr.outputs.head_sha }}
persist-credentials: false
- name: Trust workspace directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
shell: bash
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -87,9 +91,6 @@ jobs:
- name: Create patch with PNG changes only
id: create-patch
run: |
# Trust the repository directory (required for container environments)
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"

3
.gitignore vendored
View File

@@ -42,6 +42,9 @@ bundle.desktop.js.map
bundle.mobile.js
bundle.mobile.js.map
# Python virtualenv (Electron CI provisions one at the repo root for setuptools)
.venv/
# Yarn
.pnp.*
.yarn/*

View File

@@ -370,7 +370,8 @@
"files": ["**/*.test.{js,ts,jsx,tsx}", "packages/docs/**/*"],
"rules": {
"actual/no-untranslated-strings": "off",
"actual/prefer-logger-over-console": "off"
"actual/prefer-logger-over-console": "off",
"typescript/unbound-method": "off"
}
},
{

View File

@@ -4,21 +4,30 @@ ROOT=`dirname $0`
cd "$ROOT/.."
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
SKIP_TRANSLATIONS=false
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-translations)
SKIP_TRANSLATIONS=true
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [ "$SKIP_TRANSLATIONS" = false ]; then
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace @actual-app/crdt build
yarn workspace plugins-service build
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
echo "packages/desktop-client/build"
lage build:browser --to=@actual-app/web

View File

@@ -57,8 +57,7 @@ yarn workspace @actual-app/core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
yarn build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build

View File

@@ -25,6 +25,14 @@ module.exports = {
outputGlob: BUILD_OUTPUT_GLOBS,
},
},
// Not cached: the script stages files into public/ and build-stats/ that
// fall outside BUILD_OUTPUT_GLOBS, so a cache hit would skip the side
// effects.
'build:browser': {
type: 'npmScript',
dependsOn: ['^build'],
cache: false,
},
},
cacheOptions: {
cacheStorageConfig: {

View File

@@ -24,18 +24,16 @@
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:plugins-service",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:browser": "npm-run-all --parallel 'start:browser-*' 'start:service-plugins'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"start:storybook": "yarn workspace @actual-app/components start:storybook",
"build": "lage build",
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
@@ -54,7 +52,7 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core && ./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -o better-sqlite3,bcrypt",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",

View File

@@ -6,6 +6,11 @@ import { vi } from 'vitest';
import * as api from './index';
declare global {
var IS_TESTING: boolean;
var currentMonth: string | null;
}
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "26.4.0",
"version": "26.5.0",
"description": "An API for Actual",
"license": "MIT",
"repository": {
@@ -10,7 +10,9 @@
},
"files": [
"@types",
"dist"
"dist",
"!@types/**/*.test.d.ts",
"!@types/**/*.test.d.ts.map"
],
"main": "dist/index.js",
"types": "@types/index.d.ts",

View File

@@ -35,7 +35,6 @@
"**/node_modules/*",
"dist",
"@types",
"*.test.ts",
"*.config.ts",
"*.config.mts"
]

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/cli",
"version": "26.4.0",
"version": "26.5.0",
"description": "CLI for Actual Budget",
"license": "MIT",
"repository": {

View File

@@ -4,7 +4,11 @@
"description": "CRDT layer of Actual",
"license": "MIT",
"files": [
"dist"
"dist",
"!dist/**/*.test.d.ts",
"!dist/**/*.test.d.ts.map",
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -26,7 +30,7 @@
"scripts": {
"build:node": "vite build",
"proto:generate": "./bin/generate-proto",
"build": "yarn run build:node && tsgo -p tsconfig.build.json --emitDeclarationOnly",
"build": "yarn run build:node && tsgo -b",
"test": "vitest --run",
"typecheck": "tsgo -b"
},

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false
},
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -8,6 +8,7 @@ coverage
test-results
playwright-report
blob-report
.playwright-cli
# production
build

View File

@@ -1,17 +0,0 @@
#!/bin/sh -ex
ROOT=`dirname $0`
cd "$ROOT/.."
echo "Building the browser..."
rm -fr build
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
yarn build --mode=browser
rm -fr build-stats
mkdir build-stats
mv build/kcab/stats.json build-stats/loot-core-stats.json
mv ./stats.json build-stats/web-stats.json

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,5 +1,5 @@
import { test as base, expect as baseExpect } from '@playwright/test';
import type { Browser, Locator } from '@playwright/test';
import type { Browser, Locator, Page } from '@playwright/test';
/**
* Disable CSS transitions and animations globally in e2e (non-VRT) runs.
@@ -51,7 +51,7 @@ export const test = process.env.VRT
});
export const expect = baseExpect.extend({
async toMatchThemeScreenshots(locator: Locator) {
async toMatchThemeScreenshots(target: Locator | Page) {
// Disable screenshot assertions in regular e2e tests;
// only enable them when doing VRT tests
if (!process.env.VRT) {
@@ -62,38 +62,33 @@ export const expect = baseExpect.extend({
}
const config = {
mask: [locator.locator('[data-vrt-mask="true"]')],
mask: [target.locator('[data-vrt-mask="true"]')],
maxDiffPixels: 5,
};
// Get the data-theme attribute from page.
// If there is a page() function, it means that the locator
// is not a page object but a locator object.
const dataThemeLocator =
typeof locator.page === 'function'
? locator.page().locator('[data-theme]')
: locator.locator('[data-theme]');
const page: Page = 'page' in target ? target.page() : target;
const dataThemeLocator = page.locator('[data-theme]');
// Check lightmode
await locator.evaluate(() => window.Actual.setTheme('auto'));
await page.evaluate(() => window.Actual.setTheme('auto'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'auto');
await baseExpect(locator).toHaveScreenshot(config);
await baseExpect(target).toHaveScreenshot(config);
// Switch to darkmode and check
await locator.evaluate(() => window.Actual.setTheme('dark'));
await page.evaluate(() => window.Actual.setTheme('dark'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'dark');
await baseExpect(locator).toHaveScreenshot(config);
await baseExpect(target).toHaveScreenshot(config);
// Switch to midnight theme and check
await locator.evaluate(() => window.Actual.setTheme('midnight'));
await page.evaluate(() => window.Actual.setTheme('midnight'));
await baseExpect(dataThemeLocator).toHaveAttribute(
'data-theme',
'midnight',
);
await baseExpect(locator).toHaveScreenshot(config);
await baseExpect(target).toHaveScreenshot(config);
// Switch back to lightmode
await locator.evaluate(() => window.Actual.setTheme('auto'));
await page.evaluate(() => window.Actual.setTheme('auto'));
return {
message: () => 'pass',
pass: true,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": true,
"types": ["@playwright/test", "node"]
},
"include": ["./**/*.ts", "./**/*.tsx", "../../loot-core/typings/window.ts"]
}

View File

@@ -86,7 +86,10 @@
'Arial',
sans-serif
);
font-feature-settings: 'ss01', 'ss04';
font-feature-settings:
'ss01',
'ss04',
'calt' 0;
}
html,

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/web",
"version": "26.4.0",
"version": "26.5.0",
"license": "MIT",
"repository": {
"type": "git",
@@ -34,12 +34,19 @@
"#polyfills": "./src/polyfills.ts",
"#components/forms": "./src/components/forms/index.tsx",
"#components/banksync": "./src/components/banksync/index.tsx",
"#components/banksync/bankSyncUtils": "./src/components/banksync/bankSyncUtils.ts",
"#components/banksync/BuiltInProviders": "./src/components/banksync/BuiltInProviders.tsx",
"#components/banksync/useBuiltInBankSyncProviders": "./src/components/banksync/useBuiltInBankSyncProviders.ts",
"#components/banksync/useBankSyncAccountSettings": "./src/components/banksync/useBankSyncAccountSettings.ts",
"#components/budget": "./src/components/budget/index.tsx",
"#components/budget/goals/actions": "./src/components/budget/goals/actions.ts",
"#components/budget/goals/automationExamples": "./src/components/budget/goals/automationExamples.ts",
"#components/budget/goals/constants": "./src/components/budget/goals/constants.ts",
"#components/budget/goals/displayTemplateMeta": "./src/components/budget/goals/displayTemplateMeta.ts",
"#components/budget/goals/formatMonthLabel": "./src/components/budget/goals/formatMonthLabel.ts",
"#components/budget/goals/reducer": "./src/components/budget/goals/reducer.ts",
"#components/budget/goals/useBudgetAutomationCategories": "./src/components/budget/goals/useBudgetAutomationCategories.ts",
"#components/budget/goals/validateAutomation": "./src/components/budget/goals/validateAutomation.ts",
"#components/budget/util": "./src/components/budget/util.ts",
"#components/codemirror/autocompleteTabAccept": "./src/components/codemirror/autocompleteTabAccept.ts",
"#components/mobile/utils": "./src/components/mobile/utils.ts",
@@ -104,7 +111,7 @@
"start:browser": "cross-env ./bin/watch-browser",
"watch": "cross-env BROWSER=none yarn start",
"build": "vite build",
"build:browser": "cross-env ./bin/build-browser",
"build:browser": "vite build --mode=browser",
"generate:i18n": "i18next",
"test": "vitest --run",
"validate:theme-catalog": "node --experimental-strip-types bin/validate-theme-catalog.mts",
@@ -164,6 +171,7 @@
"mdast-util-newline-to-break": "^2.0.0",
"memoize-one": "^6.0.0",
"pikaday": "1.8.2",
"plugins-service": "workspace:*",
"promise-retry": "^2.0.1",
"re-resizable": "^6.11.2",
"react": "19.2.4",

View File

@@ -27,6 +27,13 @@ let worker = null;
// The regular Worker running the backend, created only on the leader tab
let localBackendWorker = null;
function terminateLocalBackendWorker() {
if (localBackendWorker) {
localBackendWorker.terminate();
localBackendWorker = null;
}
}
/**
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
* (onmessage, postMessage, addEventListener, start) to the connection layer.
@@ -43,9 +50,22 @@ class WorkerBridge {
this._onmessage = null;
this._listeners = [];
this._started = false;
this._isInitialized = false;
this._currentBudgetId = null;
this._wasHidden = document.visibilityState === 'hidden';
this._onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
this._wasHidden = true;
} else if (this._wasHidden) {
this._wasHidden = false;
this._resumeAssociation();
}
};
// Listen for all messages from the SharedWorker port
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
document.addEventListener('visibilitychange', this._onVisibilityChange);
}
set onmessage(handler) {
@@ -109,10 +129,7 @@ class WorkerBridge {
// show-budgets normally.
if (msg && msg.type === '__close-and-transfer') {
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
if (localBackendWorker) {
localBackendWorker.terminate();
localBackendWorker = null;
}
this._applyRole('UNASSIGNED', null);
// Only dispatch a synthetic reply if there's an actual close-budget
// request to complete. When requestId is null the eviction was
// triggered externally (e.g. another tab deleted this budget).
@@ -126,6 +143,7 @@ class WorkerBridge {
// Role change notification
if (msg && msg.type === '__role-change') {
this._applyRole(msg.role, msg.budgetId ?? null);
console.log(
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
);
@@ -146,13 +164,47 @@ class WorkerBridge {
}
// Everything else goes to the connection layer
if (msg && msg.type === 'push' && msg.name === 'show-budgets') {
this._applyRole('UNASSIGNED', null);
}
this._dispatch(event);
}
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
if (localBackendWorker) {
localBackendWorker.terminate();
markInitialized() {
this._isInitialized = true;
}
_normalizeBudgetId(budgetId) {
if (
typeof budgetId === 'string' &&
budgetId.length > 0 &&
!budgetId.startsWith('__')
) {
return budgetId;
}
return null;
}
_applyRole(role, budgetId) {
this._currentBudgetId = this._normalizeBudgetId(budgetId);
if (role !== 'LEADER') {
terminateLocalBackendWorker();
}
}
_resumeAssociation() {
if (!this._isInitialized) {
return;
}
this._sharedPort.postMessage({
type: '__resume-tab',
budgetId: this._currentBudgetId,
});
}
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
terminateLocalBackendWorker();
localBackendWorker = new Worker(backendWorkerUrl);
initSQLBackend(localBackendWorker);
@@ -238,10 +290,12 @@ function createBackendWorker() {
'SharedArrayBufferOverride',
),
});
worker.markInitialized();
window.addEventListener('beforeunload', () => {
const notifyTabClosing = () => {
sharedPort.postMessage({ type: 'tab-closing' });
});
};
window.addEventListener('beforeunload', notifyTabClosing);
return;
} catch (e) {

View File

@@ -647,6 +647,13 @@ type ApplyBudgetActionPayload =
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'copy-until-year-end';
month: string;
args: {
category: CategoryEntity['id'];
};
};
export function useBudgetActions() {
@@ -776,6 +783,12 @@ export function useBudgetActions() {
category: args.category,
});
return null;
case 'copy-until-year-end':
await send('budget/copy-until-year-end', {
month,
category: args.category,
});
return null;
default:
throw new Error(`Unknown budget action type: ${String(type)}`);
}

View File

@@ -243,8 +243,8 @@ function ServerSyncButton({ style, isMobile = false }: ServerSyncButtonProps) {
) : (
<AnimatedRefresh animating={syncing} />
)}
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
{syncState === 'disabled' ? t('Disabled') : null}
<Text style={isMobile ? { ...mobileTextStyle } : null}>
{syncState === 'disabled' ? ` ${t('Disabled')}` : null}
</Text>
</Button>
);

View File

@@ -44,13 +44,19 @@ function makeSchedule(
} satisfies ScheduleEntity;
}
function mockedSchedules(schedules: ScheduleEntity[]) {
return {
isLoading: false,
schedules,
statuses: new Map(),
statusLabels: new Map(),
};
}
describe('SelectedBalance normal transactions', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useCachedSchedules).mockReturnValue({
isLoading: false,
schedules: [],
});
vi.mocked(useCachedSchedules).mockReturnValue(mockedSchedules([]));
});
test('shows balance for selected normal transactions', () => {
@@ -93,10 +99,9 @@ describe('SelectedBalance preview (scheduled) transactions', () => {
vi.mocked(useSelectedItems).mockReturnValue(
new Set([`preview/${scheduleId}/2026-03-24`]),
);
vi.mocked(useCachedSchedules).mockReturnValue({
isLoading: false,
schedules: [makeSchedule(scheduleId, -5000, 'account-1')],
});
vi.mocked(useCachedSchedules).mockReturnValue(
mockedSchedules([makeSchedule(scheduleId, -5000, 'account-1')]),
);
render(
<TestProviders>
@@ -116,10 +121,9 @@ describe('SelectedBalance preview (scheduled) transactions', () => {
const selectedItems = new Set([previewId1, previewId2]);
vi.mocked(useSelectedItems).mockReturnValue(selectedItems);
vi.mocked(useCachedSchedules).mockReturnValue({
isLoading: false,
schedules: [makeSchedule(scheduleId, -5000, 'account-1')],
});
vi.mocked(useCachedSchedules).mockReturnValue(
mockedSchedules([makeSchedule(scheduleId, -5000, 'account-1')]),
);
render(
<TestProviders>

View File

@@ -0,0 +1,213 @@
import { Dialog, DialogTrigger } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Button, ButtonWithLoading } from '@actual-app/components/button';
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
import { Menu } from '@actual-app/components/menu';
import { Paragraph } from '@actual-app/components/paragraph';
import { Popover } from '@actual-app/components/popover';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { Warning } from '#components/alerts';
import { Link } from '#components/common/Link';
import type { BuiltInBankSyncProviderState } from './useBuiltInBankSyncProviders';
type BuiltInProvidersProps = {
providers: BuiltInBankSyncProviderState[];
syncServerStatus: 'offline' | 'no-server' | 'online';
showPermissionWarning: boolean;
providersNeedingConfiguration: BuiltInBankSyncProviderState[];
};
export function BuiltInProviders({
providers,
syncServerStatus,
showPermissionWarning,
providersNeedingConfiguration,
}: BuiltInProvidersProps) {
const { t } = useTranslation();
return (
<View style={{ gap: 12 }}>
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 20, fontWeight: 600 }}>
<Trans>Providers</Trans>
</Text>
<Paragraph style={{ fontSize: 15, color: theme.pageTextSubdued }}>
<Trans>
Set up a bank sync provider, then link new accounts or connect an
existing Actual account.
</Trans>
</Paragraph>
</View>
{syncServerStatus !== 'online' ? (
<View
style={{
border: `1px solid ${theme.tableBorder}`,
borderRadius: 8,
padding: 16,
backgroundColor: theme.tableBackground,
}}
>
<Button isDisabled style={{ padding: '10px 0', fontSize: 15 }}>
<Trans>Set up bank sync</Trans>
</Button>
<Paragraph style={{ fontSize: 15, marginTop: 10 }}>
<Trans>
Connect to an Actual server to set up{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/advanced/bank-sync"
linkColor="muted"
>
automatic syncing
</Link>
.
</Trans>
</Paragraph>
</View>
) : (
<View
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: 12,
}}
>
{providers.map(provider => (
<View
key={provider.id}
data-testid={`bank-sync-provider-${provider.id}`}
style={{
border: `1px solid ${theme.tableBorder}`,
borderRadius: 8,
padding: 16,
backgroundColor: theme.tableBackground,
gap: 16,
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
}}
>
<View
style={{
gap: 6,
flex: 1,
}}
>
<Text style={{ fontSize: 17, fontWeight: 600 }}>
{provider.displayName}
</Text>
<Text
style={{
color: provider.isConfigured
? theme.noticeTextDark
: theme.pageTextSubdued,
fontSize: 13,
fontWeight: 500,
}}
>
{provider.isConfigured ? (
<Trans>Configured</Trans>
) : (
<Trans>Not configured</Trans>
)}
</Text>
</View>
{provider.isConfigured && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('{{provider}} menu', {
provider: provider.displayName,
})}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Dialog>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
void provider.onReset();
}
}}
items={[
{
name: 'reconfigure',
text: t('Reset {{provider}} credentials', {
provider: provider.displayName,
}),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
)}
</View>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
<Button
variant="bare"
isDisabled={!provider.canConfigure}
onPress={() => provider.onConfigure()}
>
{provider.isConfigured ? (
<Trans>Edit setup</Trans>
) : (
<Trans>Set up</Trans>
)}
</Button>
<ButtonWithLoading
variant="primary"
isDisabled={!provider.isConfigured}
isLoading={provider.isLoading}
onPress={() => provider.onLink()}
>
<Trans>Link bank account</Trans>
</ButtonWithLoading>
</View>
</View>
))}
</View>
)}
{showPermissionWarning && (
<Warning>
<Trans>
You don&apos;t have the required permissions to configure bank sync
providers. Please contact an Admin to configure
</Trans>{' '}
{providersNeedingConfiguration
.map(provider => provider.displayName)
.join(' or ')}
.
</Warning>
)}
</View>
);
}

View File

@@ -0,0 +1,53 @@
import { generateAccount } from '@actual-app/core/mocks';
import { describe, expect, it } from 'vitest';
import { getSyncSourceReadable, groupBankSyncAccounts } from './bankSyncUtils';
describe('bankSyncUtils', () => {
it('groups open accounts by provider and leaves unlinked last', () => {
const goCardlessAccount = generateAccount('GoCardless', true, false);
const pluggyAccount = {
...generateAccount('Pluggy', true, false),
account_sync_source: 'pluggyai' as const,
};
const simpleFinAccount = {
...generateAccount('SimpleFIN', true, false),
account_sync_source: 'simpleFin' as const,
};
const unlinkedAccount = generateAccount('Manual', false, false);
const closedAccount = {
...generateAccount('Closed', true, false),
closed: 1 as const,
};
const groupedAccounts = groupBankSyncAccounts([
unlinkedAccount,
simpleFinAccount,
closedAccount,
pluggyAccount,
goCardlessAccount,
]);
expect(Object.keys(groupedAccounts)).toEqual([
'goCardless',
'pluggyai',
'simpleFin',
'unlinked',
]);
expect(groupedAccounts.goCardless).toEqual([goCardlessAccount]);
expect(groupedAccounts.pluggyai).toEqual([pluggyAccount]);
expect(groupedAccounts.simpleFin).toEqual([simpleFinAccount]);
expect(groupedAccounts.unlinked).toEqual([unlinkedAccount]);
});
it('returns stable readable provider labels', () => {
const readable = getSyncSourceReadable(
(key: string) => `translated:${key}`,
);
expect(readable.goCardless).toBe('GoCardless');
expect(readable.simpleFin).toBe('SimpleFIN');
expect(readable.pluggyai).toBe('Pluggy.ai');
expect(readable.unlinked).toBe('translated:Unlinked');
});
});

View File

@@ -0,0 +1,85 @@
import type {
AccountEntity,
BankSyncProviders,
} from '@actual-app/core/types/models';
export type SyncProviders = BankSyncProviders | 'unlinked';
export type GroupedBankSyncAccounts = Partial<
Record<SyncProviders, AccountEntity[]>
>;
export const BUILT_IN_BANK_SYNC_PROVIDERS = [
'goCardless',
'simpleFin',
'pluggyai',
] as const satisfies BankSyncProviders[];
const SYNC_PROVIDER_KEYS = [
...BUILT_IN_BANK_SYNC_PROVIDERS,
'unlinked',
] as const satisfies readonly SyncProviders[];
const syncProviderKeysSet = new Set<string>(SYNC_PROVIDER_KEYS);
function isSyncProvider(value: string): value is SyncProviders {
return syncProviderKeysSet.has(value);
}
export function getSyncSourceReadable(
translate: (key: string) => string,
): Record<SyncProviders, string> {
return {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
unlinked: translate('Unlinked'),
};
}
export function groupBankSyncAccounts(
accounts: AccountEntity[],
): GroupedBankSyncAccounts {
const groupedAccounts: GroupedBankSyncAccounts = {};
for (const account of accounts) {
if (account.closed) {
continue;
}
const syncSource = account.account_sync_source ?? 'unlinked';
const existingAccounts = groupedAccounts[syncSource];
if (existingAccounts) {
existingAccounts.push(account);
} else {
groupedAccounts[syncSource] = [account];
}
}
const sortedEntries = Object.entries(groupedAccounts)
.filter(
(entry): entry is [SyncProviders, AccountEntity[]] =>
isSyncProvider(entry[0]) && entry[1] != null,
)
.sort(([keyA], [keyB]) => {
if (keyA === 'unlinked') return 1;
if (keyB === 'unlinked') return -1;
return keyA.localeCompare(keyB);
});
const sortedAccounts: GroupedBankSyncAccounts = {};
for (const [syncSource, providerAccounts] of sortedEntries) {
sortedAccounts[syncSource] = providerAccounts;
}
return sortedAccounts;
}
export function getGroupedBankSyncEntries(
groupedAccounts: GroupedBankSyncAccounts,
): Array<[SyncProviders, AccountEntity[]]> {
return Object.entries(groupedAccounts).filter(
(entry): entry is [SyncProviders, AccountEntity[]] =>
isSyncProvider(entry[0]) && entry[1] != null,
);
}

View File

@@ -5,10 +5,7 @@ import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import type {
AccountEntity,
BankSyncProviders,
} from '@actual-app/core/types/models';
import type { AccountEntity } from '@actual-app/core/types/models';
import { MOBILE_NAV_HEIGHT } from '#components/mobile/MobileNavTabs';
import { Page } from '#components/Page';
@@ -19,63 +16,44 @@ import { useDispatch } from '#redux';
import { AccountsHeader } from './AccountsHeader';
import { AccountsList } from './AccountsList';
type SyncProviders = BankSyncProviders | 'unlinked';
const useSyncSourceReadable = () => {
const { t } = useTranslation();
const syncSourceReadable: Record<SyncProviders, string> = {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
unlinked: t('Unlinked'),
};
return { syncSourceReadable };
};
import {
getGroupedBankSyncEntries,
getSyncSourceReadable,
groupBankSyncAccounts,
} from './bankSyncUtils';
import { BuiltInProviders } from './BuiltInProviders';
import { useBuiltInBankSyncProviders } from './useBuiltInBankSyncProviders';
export function BankSync() {
const { t } = useTranslation();
const [floatingSidebar] = useGlobalPref('floatingSidebar');
const { syncSourceReadable } = useSyncSourceReadable();
const { data: accounts = [] } = useAccounts();
const dispatch = useDispatch();
const { isNarrowWidth } = useResponsive();
const syncSourceReadable = useMemo(() => getSyncSourceReadable(t), [t]);
const {
providers,
syncServerStatus,
showPermissionWarning,
providersNeedingConfiguration,
} = useBuiltInBankSyncProviders();
const [hoveredAccount, setHoveredAccount] = useState<
AccountEntity['id'] | null
>(null);
const groupedAccounts = useMemo(() => {
const unsorted = accounts
.filter(a => !a.closed)
.reduce(
(acc, a) => {
const syncSource = a.account_sync_source ?? 'unlinked';
acc[syncSource] = acc[syncSource] || [];
acc[syncSource].push(a);
return acc;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
const sortedKeys = Object.keys(unsorted).sort((keyA, keyB) => {
if (keyA === 'unlinked') return 1;
if (keyB === 'unlinked') return -1;
return keyA.localeCompare(keyB);
});
return sortedKeys.reduce(
(sorted, key) => {
sorted[key as SyncProviders] = unsorted[key as SyncProviders];
return sorted;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
}, [accounts]);
const groupedAccounts = useMemo(
() => groupBankSyncAccounts(accounts),
[accounts],
);
const groupedAccountEntries = useMemo(
() => getGroupedBankSyncEntries(groupedAccounts),
[groupedAccounts],
);
const openAccounts = useMemo(
() => accounts.filter(account => !account.closed),
[accounts],
);
const onAction = async (account: AccountEntity, action: 'link' | 'edit') => {
switch (action) {
@@ -119,22 +97,30 @@ export function BankSync() {
paddingBottom: MOBILE_NAV_HEIGHT,
}}
>
<View style={{ marginTop: '1em' }}>
{accounts.length === 0 && (
<View style={{ marginTop: '1em', gap: 24 }}>
<BuiltInProviders
providers={providers}
syncServerStatus={syncServerStatus}
showPermissionWarning={showPermissionWarning}
providersNeedingConfiguration={providersNeedingConfiguration}
/>
{openAccounts.length === 0 && (
<Text style={{ fontSize: '1.1rem' }}>
<Trans>
To use the bank syncing features, you must first add an account.
</Trans>
</Text>
)}
{Object.entries(groupedAccounts).map(([syncProvider, accounts]) => {
{groupedAccountEntries.map(([syncProvider, accounts]) => {
return (
<View key={syncProvider} style={{ minHeight: 'initial' }}>
{Object.keys(groupedAccounts).length > 1 && (
{groupedAccountEntries.length > 1 && (
<Text
style={{ fontWeight: 500, fontSize: 20, margin: '.5em 0' }}
>
{syncSourceReadable[syncProvider as SyncProviders]}
{syncSourceReadable[syncProvider]}
</Text>
)}
<View style={styles.tableContainer}>

View File

@@ -0,0 +1,475 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { send } from '@actual-app/core/platform/client/connection';
import type {
AccountEntity,
BankSyncProviders,
} from '@actual-app/core/types/models';
import type { SyncServerSimpleFinAccount } from '@actual-app/core/types/models/simplefin';
import { useAuth } from '#auth/AuthProvider';
import { Permissions } from '#auth/types';
import { useMultiuserEnabled } from '#components/ServerContext';
import { authorizeBank } from '#gocardless';
import { useGoCardlessStatus } from '#hooks/useGoCardlessStatus';
import { usePluggyAiStatus } from '#hooks/usePluggyAiStatus';
import { useSimpleFinStatus } from '#hooks/useSimpleFinStatus';
import { useSyncServerStatus } from '#hooks/useSyncServerStatus';
import { pushModal } from '#modals/modalsSlice';
import { addNotification } from '#notifications/notificationsSlice';
import { useDispatch } from '#redux';
import { BUILT_IN_BANK_SYNC_PROVIDERS } from './bankSyncUtils';
type ProviderAction = () => void | Promise<void>;
type SimpleFinAccount = {
id: string;
name: string;
balance: number;
org: {
name: string;
domain: string;
id: string;
};
};
type PluggyAiAccount = {
id: string;
name: string;
type: 'BANK' | string;
taxNumber: string;
owner: string;
balance: number;
bankData: {
automaticallyInvestedBalance: number;
closingBalance: number;
};
};
export type BuiltInBankSyncProviderState = {
id: BankSyncProviders;
displayName: string;
description: string;
isConfigured: boolean;
canConfigure: boolean;
isLoading?: boolean;
onConfigure: ProviderAction;
onLink: ProviderAction;
onReset: ProviderAction;
};
type SecretSetResponse = {
error?: string;
error_code?: string;
reason?: string;
};
type UseBuiltInBankSyncProvidersOptions = {
upgradingAccountId?: AccountEntity['id'];
};
async function ensureSuccessResponse(
response: SecretSetResponse,
fallbackMessage: string,
) {
if (response.error_code) {
throw new Error(response.reason || response.error_code);
}
if (response.error) {
throw new Error(response.reason || response.error || fallbackMessage);
}
}
export function useBuiltInBankSyncProviders({
upgradingAccountId,
}: UseBuiltInBankSyncProvidersOptions = {}) {
const { t } = useTranslation();
const dispatch = useDispatch();
const syncServerStatus = useSyncServerStatus();
const { hasPermission } = useAuth();
const multiuserEnabled = useMultiuserEnabled();
const canConfigureProviders =
!multiuserEnabled || hasPermission(Permissions.ADMINISTRATOR);
const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState<
boolean | null
>(null);
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] = useState<
boolean | null
>(null);
const [isPluggyAiSetupComplete, setIsPluggyAiSetupComplete] = useState<
boolean | null
>(null);
const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] =
useState(false);
const { configuredGoCardless } = useGoCardlessStatus();
const { configuredSimpleFin } = useSimpleFinStatus();
const { configuredPluggyAi } = usePluggyAiStatus();
useEffect(() => {
setIsGoCardlessSetupComplete(configuredGoCardless);
}, [configuredGoCardless]);
useEffect(() => {
setIsSimpleFinSetupComplete(configuredSimpleFin);
}, [configuredSimpleFin]);
useEffect(() => {
setIsPluggyAiSetupComplete(configuredPluggyAi);
}, [configuredPluggyAi]);
const onGoCardlessInit = useCallback(() => {
dispatch(
pushModal({
modal: {
name: 'gocardless-init',
options: {
onSuccess: () => setIsGoCardlessSetupComplete(true),
},
},
}),
);
}, [dispatch]);
const onSimpleFinInit = useCallback(() => {
dispatch(
pushModal({
modal: {
name: 'simplefin-init',
options: {
onSuccess: () => setIsSimpleFinSetupComplete(true),
},
},
}),
);
}, [dispatch]);
const onPluggyAiInit = useCallback(() => {
dispatch(
pushModal({
modal: {
name: 'pluggyai-init',
options: {
onSuccess: () => setIsPluggyAiSetupComplete(true),
},
},
}),
);
}, [dispatch]);
const notifyResetFailure = useCallback(
(providerName: string, error: unknown) => {
dispatch(
addNotification({
notification: {
type: 'error',
title: t('Failed to reset {{provider}}', {
provider: providerName,
}),
message: error instanceof Error ? error.message : String(error),
timeout: 5000,
},
}),
);
},
[dispatch, t],
);
const onGoCardlessReset = useCallback(async () => {
try {
await ensureSuccessResponse(
await send('secret-set', {
name: 'gocardless_secretId',
value: null,
}),
'Failed to clear GoCardless secret ID',
);
await ensureSuccessResponse(
await send('secret-set', {
name: 'gocardless_secretKey',
value: null,
}),
'Failed to clear GoCardless secret key',
);
setIsGoCardlessSetupComplete(false);
} catch (error) {
notifyResetFailure('GoCardless', error);
}
}, [notifyResetFailure]);
const onSimpleFinReset = useCallback(async () => {
try {
await ensureSuccessResponse(
await send('secret-set', {
name: 'simplefin_token',
value: null,
}),
'Failed to clear SimpleFIN token',
);
await ensureSuccessResponse(
await send('secret-set', {
name: 'simplefin_accessKey',
value: null,
}),
'Failed to clear SimpleFIN access key',
);
setIsSimpleFinSetupComplete(false);
} catch (error) {
notifyResetFailure('SimpleFIN', error);
}
}, [notifyResetFailure]);
const onPluggyAiReset = useCallback(async () => {
try {
await ensureSuccessResponse(
await send('secret-set', {
name: 'pluggyai_clientId',
value: null,
}),
'Failed to clear Pluggy.ai client ID',
);
await ensureSuccessResponse(
await send('secret-set', {
name: 'pluggyai_clientSecret',
value: null,
}),
'Failed to clear Pluggy.ai client secret',
);
await ensureSuccessResponse(
await send('secret-set', {
name: 'pluggyai_itemIds',
value: null,
}),
'Failed to clear Pluggy.ai item IDs',
);
setIsPluggyAiSetupComplete(false);
} catch (error) {
notifyResetFailure('Pluggy.ai', error);
}
}, [notifyResetFailure]);
const onConnectGoCardless = useCallback(() => {
if (!isGoCardlessSetupComplete) {
onGoCardlessInit();
return;
}
void authorizeBank(dispatch, upgradingAccountId);
}, [
dispatch,
isGoCardlessSetupComplete,
onGoCardlessInit,
upgradingAccountId,
]);
const onConnectSimpleFin = useCallback(async () => {
if (!isSimpleFinSetupComplete) {
onSimpleFinInit();
return;
}
if (loadingSimpleFinAccounts) {
return;
}
setLoadingSimpleFinAccounts(true);
try {
const results = await send('simplefin-accounts');
if (results.error_code) {
throw new Error(results.reason);
}
if ('error' in results && results.error) {
throw new Error(results.reason || results.error);
}
const externalAccounts: SyncServerSimpleFinAccount[] = (
(results.accounts ?? []) as SimpleFinAccount[]
).map(oldAccount => ({
account_id: oldAccount.id,
name: oldAccount.name,
institution: oldAccount.org.name,
orgDomain: oldAccount.org.domain,
orgId: oldAccount.org.id,
balance: oldAccount.balance,
}));
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts,
syncSource: 'simpleFin',
upgradingAccountId,
},
},
}),
);
} catch {
onSimpleFinInit();
} finally {
setLoadingSimpleFinAccounts(false);
}
}, [
dispatch,
isSimpleFinSetupComplete,
loadingSimpleFinAccounts,
onSimpleFinInit,
upgradingAccountId,
]);
const onConnectPluggyAi = useCallback(async () => {
if (!isPluggyAiSetupComplete) {
onPluggyAiInit();
return;
}
try {
const results = await send('pluggyai-accounts');
if (results.error_code) {
throw new Error(results.reason);
}
if ('error' in results) {
throw new Error(results.error);
}
const externalAccounts = (results.accounts as PluggyAiAccount[]).map(
oldAccount => ({
account_id: oldAccount.id,
name: `${oldAccount.name.trim()} - ${
oldAccount.type === 'BANK' ? oldAccount.taxNumber : oldAccount.owner
}`,
institution: oldAccount.name,
orgDomain: null,
orgId: oldAccount.id,
balance:
oldAccount.type === 'BANK'
? oldAccount.bankData.automaticallyInvestedBalance +
oldAccount.bankData.closingBalance
: oldAccount.balance,
}),
);
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts,
syncSource: 'pluggyai',
upgradingAccountId,
},
},
}),
);
} catch (error) {
dispatch(
addNotification({
notification: {
type: 'error',
title: t('Error when trying to contact Pluggy.ai'),
message: error instanceof Error ? error.message : String(error),
timeout: 5000,
},
}),
);
onPluggyAiInit();
}
}, [
dispatch,
isPluggyAiSetupComplete,
onPluggyAiInit,
t,
upgradingAccountId,
]);
const configuredProviders = {
goCardless: Boolean(isGoCardlessSetupComplete),
simpleFin: Boolean(isSimpleFinSetupComplete),
pluggyai: Boolean(isPluggyAiSetupComplete),
} satisfies Record<BankSyncProviders, boolean>;
const providers = useMemo<BuiltInBankSyncProviderState[]>(
() =>
BUILT_IN_BANK_SYNC_PROVIDERS.map(providerId => {
if (providerId === 'goCardless') {
return {
id: providerId,
displayName: 'GoCardless',
description: t(
'Link a European bank account to automatically download transactions.',
),
isConfigured: configuredProviders.goCardless,
canConfigure: canConfigureProviders,
onConfigure: onGoCardlessInit,
onLink: onConnectGoCardless,
onReset: onGoCardlessReset,
};
}
if (providerId === 'simpleFin') {
return {
id: providerId,
displayName: 'SimpleFIN',
description: t(
'Link a North American bank account to automatically download transactions.',
),
isConfigured: configuredProviders.simpleFin,
canConfigure: canConfigureProviders,
isLoading: loadingSimpleFinAccounts,
onConfigure: onSimpleFinInit,
onLink: onConnectSimpleFin,
onReset: onSimpleFinReset,
};
}
return {
id: providerId,
displayName: 'Pluggy.ai',
description: t(
'Link a Brazilian bank account to automatically download transactions.',
),
isConfigured: configuredProviders.pluggyai,
canConfigure: canConfigureProviders,
onConfigure: onPluggyAiInit,
onLink: onConnectPluggyAi,
onReset: onPluggyAiReset,
};
}),
[
canConfigureProviders,
configuredProviders.goCardless,
configuredProviders.pluggyai,
configuredProviders.simpleFin,
loadingSimpleFinAccounts,
onConnectGoCardless,
onConnectPluggyAi,
onConnectSimpleFin,
onGoCardlessInit,
onGoCardlessReset,
onPluggyAiInit,
onPluggyAiReset,
onSimpleFinInit,
onSimpleFinReset,
t,
],
);
const providersNeedingConfiguration = providers.filter(
provider => !provider.isConfigured,
);
return {
providers,
syncServerStatus,
canConfigureProviders,
showPermissionWarning:
providersNeedingConfiguration.length > 0 && !canConfigureProviders,
providersNeedingConfiguration,
};
}

View File

@@ -512,7 +512,10 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
placement="bottom end"
isOpen={balanceMenuOpen}
onOpenChange={() => setBalanceMenuOpen(false)}
style={{ margin: 1 }}
style={{
margin: 1,
minWidth: 190,
}}
isNonModal
{...balancePosition}
>

View File

@@ -0,0 +1,76 @@
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import type { Action } from './actions';
import type { ReducerState } from './constants';
import { BySaveAutomation } from './editor/BySaveAutomation';
import { FixedAutomation } from './editor/FixedAutomation';
import { HistoricalAutomation } from './editor/HistoricalAutomation';
import { LimitAutomation } from './editor/LimitAutomation';
import { PercentageAutomation } from './editor/PercentageAutomation';
import { RefillAutomation } from './editor/RefillAutomation';
import { RemainderAutomation } from './editor/RemainderAutomation';
import { ScheduleAutomation } from './editor/ScheduleAutomation';
type ActiveEditorProps = {
state: ReducerState;
dispatch: (action: Action) => void;
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
hasLimitAutomation: boolean;
onAddLimitAutomation: () => void;
};
export function ActiveEditor({
state,
dispatch,
schedules,
categories,
hasLimitAutomation,
onAddLimitAutomation,
}: ActiveEditorProps) {
switch (state.displayType) {
case 'limit':
return <LimitAutomation template={state.template} dispatch={dispatch} />;
case 'refill':
return (
<RefillAutomation
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
);
case 'fixed':
return <FixedAutomation template={state.template} dispatch={dispatch} />;
case 'schedule':
return (
<ScheduleAutomation
schedules={schedules}
template={state.template}
dispatch={dispatch}
/>
);
case 'percentage':
return (
<PercentageAutomation
dispatch={dispatch}
template={state.template}
categories={categories}
/>
);
case 'historical':
return (
<HistoricalAutomation template={state.template} dispatch={dispatch} />
);
case 'by':
return <BySaveAutomation template={state.template} dispatch={dispatch} />;
case 'remainder':
return (
<RemainderAutomation template={state.template} dispatch={dispatch} />
);
default:
state satisfies never;
return null;
}
}

View File

@@ -16,14 +16,17 @@ import { FormField, FormLabel, FormTextLabel } from '#components/forms';
import { setType } from './actions';
import type { Action } from './actions';
import { displayTemplateTypes } from './constants';
import type { ReducerState } from './constants';
import { displayTemplateTypes } from './constants';
import { getDisplayTemplateMeta } from './displayTemplateMeta';
import { BySaveAutomation } from './editor/BySaveAutomation';
import { FixedAutomation } from './editor/FixedAutomation';
import { HistoricalAutomation } from './editor/HistoricalAutomation';
import { LimitAutomation } from './editor/LimitAutomation';
import { PercentageAutomation } from './editor/PercentageAutomation';
import { RefillAutomation } from './editor/RefillAutomation';
import { RemainderAutomation } from './editor/RemainderAutomation';
import { ScheduleAutomation } from './editor/ScheduleAutomation';
import { WeekAutomation } from './editor/WeekAutomation';
type BudgetAutomationEditorProps = {
inline: boolean;
@@ -50,7 +53,7 @@ const displayTypeToDescription = {
automation.
</Trans>
),
week: (
fixed: (
<Trans>
Add a fixed amount to this category for each week in the month. For
example, $100 per week would be $400 per month in a 4-week month.
@@ -80,6 +83,18 @@ const displayTypeToDescription = {
to account for seasonal changes.
</Trans>
),
by: (
<Trans>
Spread a target amount across the months between now and a target date.
Useful for annual goals or saving toward a one-off expense.
</Trans>
),
remainder: (
<Trans>
Split any remaining To Budget across categories using this automation.
Higher weights take a larger share of the leftover funds.
</Trans>
),
};
export function BudgetAutomationEditor({
@@ -108,9 +123,9 @@ export function BudgetAutomationEditor({
/>
);
break;
case 'week':
case 'fixed':
automationEditor = (
<WeekAutomation template={state.template} dispatch={dispatch} />
<FixedAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'schedule':
@@ -136,6 +151,16 @@ export function BudgetAutomationEditor({
<HistoricalAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'by':
automationEditor = (
<BySaveAutomation template={state.template} dispatch={dispatch} />
);
break;
case 'remainder':
automationEditor = (
<RemainderAutomation template={state.template} dispatch={dispatch} />
);
break;
default:
state satisfies never;
automationEditor = (
@@ -165,7 +190,10 @@ export function BudgetAutomationEditor({
<InitialFocus>
<Select
id="type-field"
options={displayTemplateTypes}
options={displayTemplateTypes.map(type => [
type,
getDisplayTemplateMeta(type).label,
])}
defaultLabel={t('Select an option')}
value={state.displayType}
onChange={type => type && dispatch(setType(type))}

View File

@@ -14,12 +14,14 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { ReducerState } from './constants';
import { BySaveAutomationReadOnly } from './editor/BySaveAutomationReadOnly';
import { FixedAutomationReadOnly } from './editor/FixedAutomationReadOnly';
import { HistoricalAutomationReadOnly } from './editor/HistoricalAutomationReadOnly';
import { LimitAutomationReadOnly } from './editor/LimitAutomationReadOnly';
import { PercentageAutomationReadOnly } from './editor/PercentageAutomationReadOnly';
import { RefillAutomationReadOnly } from './editor/RefillAutomationReadOnly';
import { RemainderAutomationReadOnly } from './editor/RemainderAutomationReadOnly';
import { ScheduleAutomationReadOnly } from './editor/ScheduleAutomationReadOnly';
import { WeekAutomationReadOnly } from './editor/WeekAutomationReadOnly';
type BudgetAutomationReadOnlyProps = {
state: ReducerState;
@@ -52,8 +54,10 @@ export function BudgetAutomationReadOnly({
case 'refill':
automationReadOnly = <RefillAutomationReadOnly />;
break;
case 'week':
automationReadOnly = <WeekAutomationReadOnly template={state.template} />;
case 'fixed':
automationReadOnly = (
<FixedAutomationReadOnly template={state.template} />
);
break;
case 'schedule':
automationReadOnly = (
@@ -73,7 +77,18 @@ export function BudgetAutomationReadOnly({
<HistoricalAutomationReadOnly template={state.template} />
);
break;
case 'by':
automationReadOnly = (
<BySaveAutomationReadOnly template={state.template} />
);
break;
case 'remainder':
automationReadOnly = (
<RemainderAutomationReadOnly template={state.template} />
);
break;
default:
state satisfies never;
automationReadOnly = (
<Text>
<Trans>Unrecognized automation type.</Trans>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -8,7 +8,9 @@ import { theme } from '@actual-app/components/theme';
import type { CategoryEntity } from '@actual-app/core/types/models';
import { css, cx } from '@emotion/css';
import { MonthsContext } from '#components/budget/MonthsContext';
import { useFeatureFlag } from '#hooks/useFeatureFlag';
import { useSyncedPref } from '#hooks/useSyncedPref';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
@@ -30,15 +32,24 @@ export function CategoryAutomationButton({
}: CategoryAutomationButtonProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const monthsContext = useContext(MonthsContext);
const month = monthsContext?.months?.[0];
const goalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const goalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
const hasAutomations = !!category.goal_def?.length;
if (!goalTemplatesEnabled || !goalTemplatesUIEnabled) {
return null;
}
// Income categories don't accept templates in envelope budgets (only the
// tracking budget runs templates against income categories).
if (category.is_income && budgetType !== 'tracking') {
return null;
}
return (
<Button
variant="bare"
@@ -59,7 +70,7 @@ export function CategoryAutomationButton({
pushModal({
modal: {
name: 'category-automations-edit',
options: { categoryId: category.id },
options: { categoryId: category.id, month },
},
}),
);

View File

@@ -0,0 +1,62 @@
import { Trans } from 'react-i18next';
import type { Template } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { BySaveAutomationReadOnly } from './editor/BySaveAutomationReadOnly';
import { FixedAutomationReadOnly } from './editor/FixedAutomationReadOnly';
import { HistoricalAutomationReadOnly } from './editor/HistoricalAutomationReadOnly';
import { LimitAutomationReadOnly } from './editor/LimitAutomationReadOnly';
import { PercentageAutomationReadOnly } from './editor/PercentageAutomationReadOnly';
import { RefillAutomationReadOnly } from './editor/RefillAutomationReadOnly';
import { RemainderAutomationReadOnly } from './editor/RemainderAutomationReadOnly';
import { ScheduleAutomationReadOnly } from './editor/ScheduleAutomationReadOnly';
type TemplateSentenceProps = {
template: Template;
categoryNameMap: Record<string, string>;
};
export function TemplateSentence({
template,
categoryNameMap,
}: TemplateSentenceProps) {
switch (template.type) {
case 'limit':
return <LimitAutomationReadOnly template={template} />;
case 'refill':
return <RefillAutomationReadOnly />;
case 'periodic':
return <FixedAutomationReadOnly template={template} />;
case 'schedule':
return <ScheduleAutomationReadOnly template={template} />;
case 'percentage':
return (
<PercentageAutomationReadOnly
template={template}
categoryNameMap={categoryNameMap}
/>
);
case 'average':
case 'copy':
return <HistoricalAutomationReadOnly template={template} />;
case 'by':
return <BySaveAutomationReadOnly template={template} />;
case 'remainder':
return <RemainderAutomationReadOnly template={template} />;
case 'simple':
case 'spend':
case 'goal':
case 'error': {
const type = template.type;
return (
<Trans>
Unsupported template type: {{ type } satisfies TransObjectLiteral}
</Trans>
);
}
default:
template satisfies never;
return null;
}
}

View File

@@ -0,0 +1,84 @@
import {
addMonths,
dayFromDate,
firstDayOfMonth,
monthFromDate,
} from '@actual-app/core/shared/months';
import type { Template } from '@actual-app/core/types/models/templates';
import uniqueId from 'lodash/uniqueId';
import type { DisplayTemplateType } from './constants';
import { DEFAULT_PRIORITY } from './reducer';
export type AutomationEntry = {
id: string;
template: Template;
displayType: DisplayTemplateType;
};
export function createAutomationEntry(
template: Template,
displayType: DisplayTemplateType,
): AutomationEntry {
return {
id: uniqueId('automation-'),
template,
displayType,
};
}
export type AutomationExample = {
displayType: DisplayTemplateType;
create: () => AutomationEntry;
};
export function getAutomationExamples(): AutomationExample[] {
return [
{
displayType: 'fixed',
create: () =>
createAutomationEntry(
{
directive: 'template',
type: 'periodic',
amount: 100,
period: { period: 'month', amount: 1 },
starting: dayFromDate(firstDayOfMonth(new Date())),
priority: DEFAULT_PRIORITY,
},
'fixed',
),
},
{
displayType: 'by',
create: () =>
createAutomationEntry(
{
directive: 'template',
type: 'by',
amount: 1200,
// Always 12 months out so users in late-year months don't get a
// target that's already passed.
month: addMonths(monthFromDate(new Date()), 12),
annual: true,
repeat: 1,
priority: DEFAULT_PRIORITY,
},
'by',
),
},
{
displayType: 'schedule',
create: () =>
createAutomationEntry(
{
directive: 'template',
type: 'schedule',
name: '',
priority: DEFAULT_PRIORITY,
},
'schedule',
),
},
];
}

View File

@@ -0,0 +1,178 @@
import { Trans } from 'react-i18next';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
import { formatMonthLabel } from './formatMonthLabel';
import type {
AutomationErrorKind,
GlobalConflictKind,
} from './validateAutomation';
export function AutomationErrorTitle({
error,
}: {
error: AutomationErrorKind;
}) {
switch (error.kind) {
case 'schedule-not-found':
return <Trans>Schedule not found</Trans>;
case 'refill-no-cap':
return <Trans>Refill needs a balance cap</Trans>;
case 'percentage-out-of-range':
return <Trans>Percentage out of range</Trans>;
case 'percentage-no-source':
return <Trans>Source category missing</Trans>;
case 'by-no-month':
return <Trans>Target month missing</Trans>;
case 'by-target-past':
return <Trans>Target is in the past</Trans>;
case 'percentage-source-not-found':
return <Trans>Source category not recognised</Trans>;
default:
error satisfies never;
return null;
}
}
export function AutomationErrorShort({
error,
}: {
error: AutomationErrorKind;
}) {
const locale = useLocale();
switch (error.kind) {
case 'schedule-not-found':
return error.name ? (
<Trans>No schedule named &ldquo;{{ name: error.name }}&rdquo;</Trans>
) : (
<Trans>Pick a schedule</Trans>
);
case 'refill-no-cap':
return <Trans>Add a balance cap above</Trans>;
case 'percentage-out-of-range':
return (
<Trans>{{ percent: error.percent }}% must be between 0 and 100</Trans>
);
case 'percentage-no-source':
return <Trans>Pick a source category</Trans>;
case 'by-no-month':
return <Trans>Pick a target month</Trans>;
case 'by-target-past':
return (
<Trans>
{{ month: formatMonthLabel(error.month, locale) }} has already passed
</Trans>
);
case 'percentage-source-not-found':
return <Trans>Pick a valid income category</Trans>;
default:
error satisfies never;
return null;
}
}
export function AutomationErrorDetail({
error,
}: {
error: AutomationErrorKind;
}) {
switch (error.kind) {
case 'schedule-not-found':
return (
<Trans>
Pick an existing schedule, or create one in Schedules. This automation
can&rsquo;t run until it&rsquo;s linked to a schedule.
</Trans>
);
case 'refill-no-cap':
return (
<Trans>
Refill automations must have a &ldquo;Balance cap&rdquo; automation
added to use as the target.
</Trans>
);
case 'percentage-out-of-range':
return <Trans>Set a value greater than 0% and at most 100%.</Trans>;
case 'percentage-no-source':
return (
<Trans>
Percentage automations need a source category to calculate against.
</Trans>
);
case 'by-no-month':
return (
<Trans>
Goals by date need a target month. Pick when you want this fully
funded.
</Trans>
);
case 'by-target-past':
return (
<Trans>
Pick a future month, or switch to a recurring annual goal to keep
saving.
</Trans>
);
case 'percentage-source-not-found':
return (
<Trans>
The selected source &ldquo;{{ source: error.source }}&rdquo; is not a
known income category.
</Trans>
);
default:
error satisfies never;
return null;
}
}
export function GlobalConflictTitle({
conflict,
}: {
conflict: GlobalConflictKind;
}) {
switch (conflict.kind) {
case 'over-income':
return <Trans>Automations will demand more than income</Trans>;
case 'percent-over-100':
return (
<Trans>
Percent automations total {{ total: Math.round(conflict.total) }}% of
income
</Trans>
);
default:
conflict satisfies never;
return null;
}
}
export function GlobalConflictDetail({
conflict,
}: {
conflict: GlobalConflictKind;
}) {
const format = useFormat();
switch (conflict.kind) {
case 'over-income':
return (
<Trans>
This month&rsquo;s automations ask for around{' '}
{{ total: format(conflict.total, 'financial') }} but only{' '}
{{ income: format(conflict.income, 'financial') }} is available to
budget. Lower amounts or switch one to &ldquo;Whatever is left&rdquo;.
</Trans>
);
case 'percent-over-100':
return (
<Trans>
Your percent automations add up to more than 100% and will be capped
at 100%.
</Trans>
);
default:
conflict satisfies never;
return null;
}
}

View File

@@ -1,23 +1,27 @@
import type {
AverageTemplate,
ByTemplate,
CopyTemplate,
LimitTemplate,
PercentageTemplate,
PeriodicTemplate,
RefillTemplate,
RemainderTemplate,
ScheduleTemplate,
} from '@actual-app/core/types/models/templates';
export const displayTemplateTypes = [
['limit', 'Balance limit'] as const,
['refill', 'Refill'] as const,
['week', 'Fixed (weekly)'] as const,
['schedule', 'Existing schedule'] as const,
['percentage', 'Percent of category'] as const,
['historical', 'Copy past budgets'] as const,
];
'fixed',
'schedule',
'by',
'percentage',
'historical',
'limit',
'refill',
'remainder',
] as const;
export type DisplayTemplateType = (typeof displayTemplateTypes)[number][0];
export type DisplayTemplateType = (typeof displayTemplateTypes)[number];
export type ReducerState =
| {
@@ -30,7 +34,7 @@ export type ReducerState =
}
| {
template: PeriodicTemplate;
displayType: 'week';
displayType: 'fixed';
}
| {
template: ScheduleTemplate;
@@ -43,4 +47,12 @@ export type ReducerState =
| {
template: CopyTemplate | AverageTemplate;
displayType: 'historical';
}
| {
template: ByTemplate;
displayType: 'by';
}
| {
template: RemainderTemplate;
displayType: 'remainder';
};

View File

@@ -0,0 +1,91 @@
import type { ComponentType, SVGProps } from 'react';
import {
SvgChartPie,
SvgEquals,
SvgMoneyBag,
SvgPiggyBank,
SvgShare,
SvgTime,
} from '@actual-app/components/icons/v1';
import {
SvgArrowsSynchronize,
SvgCalendar3,
} from '@actual-app/components/icons/v2';
import { t } from 'i18next';
import type { DisplayTemplateType } from './constants';
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
export type DisplayTemplateMeta = {
label: string;
description: string;
icon: IconComponent;
};
export function getDisplayTemplateMeta(
displayType: DisplayTemplateType,
): DisplayTemplateMeta {
switch (displayType) {
case 'fixed':
return {
label: t('Fixed amount'),
description: t('Add a set amount every month, week, day, or year.'),
icon: SvgPiggyBank,
};
case 'schedule':
return {
label: t('Cover schedule'),
description: t('Save up for a recurring scheduled transaction.'),
icon: SvgCalendar3,
};
case 'by':
return {
label: t('Save by date'),
description: t(
'Spread a target amount across the months until a deadline.',
),
icon: SvgMoneyBag,
};
case 'percentage':
return {
label: t('% of income'),
description: t("A share of this month's or last month's income."),
icon: SvgChartPie,
};
case 'historical':
return {
label: t('From history'),
description: t(
'Use past months: average, a specific month, or a copy.',
),
icon: SvgTime,
};
case 'limit':
return {
label: t('Balance cap'),
description: t('Never let the category balance exceed a cap.'),
icon: SvgEquals,
};
case 'refill':
return {
label: t('Refill to cap'),
description: t(
'Top the category back up to the balance cap each month.',
),
icon: SvgArrowsSynchronize,
};
case 'remainder':
return {
label: t('Whatever is left'),
description: t(
'Split any remaining To Budget across these categories.',
),
icon: SvgShare,
};
default:
displayType satisfies never;
throw new Error(`Unknown display type: ${String(displayType)}`);
}
}

View File

@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { amountToInteger, integerToAmount } from '@actual-app/core/shared/util';
import type { ByTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
import { GenericInput } from '#components/util/GenericInput';
import { useFormat } from '#hooks/useFormat';
type BySaveAutomationProps = {
template: ByTemplate;
dispatch: (action: Action) => void;
};
export const BySaveAutomation = ({
template,
dispatch,
}: BySaveAutomationProps) => {
const { t } = useTranslation();
const format = useFormat();
const amount = amountToInteger(
template.amount,
format.currency.decimalPlaces,
);
const committedRepeat = template.repeat ?? 1;
const [rawRepeat, setRawRepeat] = useState(String(committedRepeat));
useEffect(() => {
setRawRepeat(String(committedRepeat));
}, [committedRepeat]);
const commitRepeat = () => {
const parsed = Math.max(1, Math.trunc(Number(rawRepeat)) || 1);
setRawRepeat(String(parsed));
if (parsed !== committedRepeat) {
dispatch(updateTemplate({ type: 'by', repeat: parsed }));
}
};
return (
<>
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Total amount')} htmlFor="by-amount-field" />
<AmountInput
id="by-amount-field"
value={amount}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'by',
amount: integerToAmount(value, format.currency.decimalPlaces),
}),
)
}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Target date')} htmlFor="by-month-field" />
<GenericInput
type="date"
field="date"
value={template.month ? `${template.month}-01` : ''}
onChange={(value: string) =>
dispatch(
updateTemplate({
type: 'by',
month: value ? value.slice(0, 7) : '',
}),
)
}
/>
</FormField>
</SpaceBetween>
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel
title={t('Repeat every')}
htmlFor="by-repeat-amount-field"
/>
<Input
id="by-repeat-amount-field"
type="number"
min={1}
step={1}
value={rawRepeat}
onChangeValue={setRawRepeat}
onBlur={commitRepeat}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Period')} htmlFor="by-period-field" />
<Select
id="by-period-field"
value={template.annual ? 'year' : 'month'}
onChange={value =>
dispatch(updateTemplate({ type: 'by', annual: value === 'year' }))
}
options={[
['month', t('Months')],
['year', t('Years')],
]}
/>
</FormField>
</SpaceBetween>
</>
);
};

View File

@@ -0,0 +1,52 @@
import { Trans } from 'react-i18next';
import { amountToInteger } from '@actual-app/core/shared/util';
import type { ByTemplate } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
import { FinancialText } from '#components/FinancialText';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
type BySaveAutomationReadOnlyProps = {
template: ByTemplate;
};
export const BySaveAutomationReadOnly = ({
template,
}: BySaveAutomationReadOnlyProps) => {
const format = useFormat();
const locale = useLocale();
const amount = format(
amountToInteger(template.amount, format.currency.decimalPlaces),
'financial',
);
const month = formatMonthLabel(template.month, locale);
const repeat = template.repeat ?? 1;
if (template.annual) {
return (
<Trans count={repeat}>
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
by {{ month }}, repeating every {{ count: repeat }} years
</Trans>
);
}
if (template.repeat && template.repeat > 0) {
return (
<Trans count={repeat}>
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
by {{ month }}, repeating every {{ count: repeat }} months
</Trans>
);
}
return (
<Trans>
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText> by{' '}
{{ month }}
</Trans>
);
};

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { amountToInteger, integerToAmount } from '@actual-app/core/shared/util';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
import { GenericInput } from '#components/util/GenericInput';
import { useFormat } from '#hooks/useFormat';
type FixedAutomationProps = {
template: PeriodicTemplate;
dispatch: (action: Action) => void;
};
type PeriodUnit = 'day' | 'week' | 'month' | 'year';
export const FixedAutomation = ({
template,
dispatch,
}: FixedAutomationProps) => {
const { t } = useTranslation();
const periodUnitOptions: Array<[PeriodUnit, string]> = [
['day', t('days')],
['week', t('weeks')],
['month', t('months')],
['year', t('years')],
];
const format = useFormat();
const amount = amountToInteger(
template.amount,
format.currency.decimalPlaces,
);
const periodUnit = template.period?.period ?? 'month';
const periodAmount = template.period?.amount ?? 1;
const [rawPeriodAmount, setRawPeriodAmount] = useState(String(periodAmount));
// Resync when a different automation row is selected (the component
// instance is reused across rows).
useEffect(() => {
setRawPeriodAmount(String(periodAmount));
}, [periodAmount]);
const commitPeriodAmount = () => {
const parsed = Math.max(1, Math.trunc(Number(rawPeriodAmount)) || 1);
setRawPeriodAmount(String(parsed));
if (parsed !== periodAmount) {
dispatch(
updateTemplate({
type: 'periodic',
period: { period: periodUnit, amount: parsed },
}),
);
}
};
return (
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Amount')} htmlFor="amount-field" />
<AmountInput
id="amount-field"
value={amount}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'periodic',
amount: integerToAmount(value, format.currency.decimalPlaces),
}),
)
}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Every')} htmlFor="period-amount-field" />
<Input
id="period-amount-field"
type="number"
min={1}
step={1}
value={rawPeriodAmount}
onChangeValue={setRawPeriodAmount}
onBlur={commitPeriodAmount}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Period')} htmlFor="period-unit-field" />
<Select
id="period-unit-field"
value={periodUnit}
onChange={value =>
dispatch(
updateTemplate({
type: 'periodic',
period: {
period: value,
amount: periodAmount,
},
}),
)
}
options={periodUnitOptions}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Starting')} htmlFor="starting-field" />
<GenericInput
type="date"
field="date"
value={template.starting ?? ''}
onChange={(value: string) =>
dispatch(updateTemplate({ type: 'periodic', starting: value }))
}
/>
</FormField>
</SpaceBetween>
);
};

View File

@@ -0,0 +1,61 @@
import { Trans } from 'react-i18next';
import { amountToInteger } from '@actual-app/core/shared/util';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { FinancialText } from '#components/FinancialText';
import { useFormat } from '#hooks/useFormat';
type FixedAutomationReadOnlyProps = {
template: PeriodicTemplate;
};
export function FixedAutomationReadOnly({
template,
}: FixedAutomationReadOnlyProps) {
const format = useFormat();
const amount = format(
amountToInteger(template.amount, format.currency.decimalPlaces),
'financial',
);
const periodAmount = template.period?.amount ?? 1;
const periodUnit = template.period?.period ?? 'month';
switch (periodUnit) {
case 'day':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} days
</Trans>
);
case 'week':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} weeks
</Trans>
);
case 'month':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} months
</Trans>
);
case 'year':
return (
<Trans count={periodAmount}>
Budget{' '}
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
every {{ count: periodAmount }} years
</Trans>
);
default:
return null;
}
}

View File

@@ -13,12 +13,12 @@ export const HistoricalAutomationReadOnly = ({
template,
}: HistoricalAutomationReadOnlyProps) => {
return template.type === 'copy' ? (
<Trans>
Budget the same amount as {{ amount: template.lookBack }} months ago
<Trans count={template.lookBack}>
Budget the same amount as {{ count: template.lookBack }} months ago
</Trans>
) : (
<Trans>
Budget the average of the last {{ amount: template.numMonths }} months
<Trans count={template.numMonths}>
Budget the average of the last {{ count: template.numMonths }} months
</Trans>
);
};

View File

@@ -1,9 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
currentDate,
dayFromDate,
@@ -18,6 +17,7 @@ import { setDay } from 'date-fns/setDay';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { LabeledCheckbox } from '#components/forms/LabeledCheckbox';
import { AmountInput } from '#components/util/AmountInput';
import { useDaysOfWeek } from '#hooks/useDaysOfWeek';
import { useFormat } from '#hooks/useFormat';
@@ -115,26 +115,21 @@ export const LimitAutomation = ({
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
{period === 'weekly' && amountField}
<FormField key="excess-funds-field" style={{ flex: 1 }}>
<FormLabel
title={t('Excess funds mode')}
htmlFor="excess-funds-field"
/>
<Select
id="excess-funds-field"
value={hold}
onChange={value =>
dispatch(updateTemplate({ type: 'limit', hold: value }))
<FormField key="hold-overflow-field" style={{ flex: 1 }}>
<LabeledCheckbox
id="hold-overflow-field"
checked={!!hold}
onChange={e =>
dispatch(
updateTemplate({ type: 'limit', hold: e.target.checked }),
)
}
options={[
[false, t('Remove all funds over the limit')],
[true, t('Retain any funds over the limit')],
]}
className={selectButtonClassName}
/>
>
<span style={{ marginLeft: 6, fontSize: 12, whiteSpace: 'nowrap' }}>
<Trans>Retain existing funds over the cap</Trans>
</span>
</LabeledCheckbox>
</FormField>
{period !== 'weekly' && <View style={{ flex: 1 }} />}
</SpaceBetween>
</>
);

View File

@@ -44,7 +44,7 @@ export const PercentageAutomation = ({
? categories.map(group => ({
...group,
categories: group.categories?.filter(
category => category.id !== 'to-budget',
category => category.id !== 'available funds',
),
}))
: categories
@@ -87,7 +87,7 @@ export const PercentageAutomation = ({
updateTemplate({
type: 'percentage',
previous,
...(previous && template.category === 'to-budget'
...(previous && template.category === 'available funds'
? { category: '' }
: {}),
}),

View File

@@ -13,7 +13,7 @@ export const PercentageAutomationReadOnly = ({
}: PercentageAutomationReadOnlyProps) => {
const { t } = useTranslation();
if (template.category === 'total') {
if (template.category === 'all income') {
return template.previous ? (
<Trans>
Budget {{ percent: template.percent }}% of total income last month
@@ -25,7 +25,7 @@ export const PercentageAutomationReadOnly = ({
);
}
if (template.category === 'to-budget') {
if (template.category === 'available funds') {
return template.previous ? (
<Trans>
Budget {{ percent: template.percent }}% of available funds to budget

View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Input } from '@actual-app/components/input';
import { SpaceBetween } from '@actual-app/components/space-between';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import type { RemainderTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
type RemainderAutomationProps = {
template: RemainderTemplate;
dispatch: (action: Action) => void;
};
export const RemainderAutomation = ({
template,
dispatch,
}: RemainderAutomationProps) => {
const { t } = useTranslation();
const committedWeight = template.weight ?? 1;
// Track the raw input so the user can clear and retype without the field
// snapping back. Commit (and clamp) on blur.
const [rawWeight, setRawWeight] = useState(String(committedWeight));
useEffect(() => {
setRawWeight(String(committedWeight));
}, [committedWeight]);
const commitWeight = () => {
const parsed = Math.max(1, Math.trunc(Number(rawWeight)) || 1);
setRawWeight(String(parsed));
if (parsed !== committedWeight) {
dispatch(updateTemplate({ type: 'remainder', weight: parsed }));
}
};
return (
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Weight')} htmlFor="remainder-weight-field" />
<Input
id="remainder-weight-field"
type="number"
min={1}
step={1}
value={rawWeight}
onChangeValue={setRawWeight}
onBlur={commitWeight}
/>
</FormField>
<Text style={{ flex: 2, color: theme.pageTextSubdued, fontSize: 12 }}>
<Trans>
Categories with higher weights get a bigger share of the leftover To
Budget.
</Trans>
</Text>
</SpaceBetween>
);
};

View File

@@ -0,0 +1,18 @@
import { Trans } from 'react-i18next';
import type { RemainderTemplate } from '@actual-app/core/types/models/templates';
type RemainderAutomationReadOnlyProps = {
template: RemainderTemplate;
};
export const RemainderAutomationReadOnly = ({
template,
}: RemainderAutomationReadOnlyProps) => {
return (
<Trans>
Share remaining funds to budget (weight {{ weight: template.weight ?? 1 }}
)
</Trans>
);
};

View File

@@ -23,8 +23,16 @@ export const ScheduleAutomation = ({
dispatch,
}: ScheduleAutomationProps) => {
const { t } = useTranslation();
// Match the filter applied to the Select options below — completed and
// tombstoned schedules aren't selectable, so a category whose only
// schedules are completed should fall through to the "no schedules" state
// instead of showing an empty picker.
const selectableSchedules = schedules.filter(
(s): s is typeof s & { name: string } =>
!!s.name && !s.completed && !s.tombstone,
);
return schedules.length ? (
return selectableSchedules.length ? (
<SpaceBetween gap={50} style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Schedule')} htmlFor="schedule-field" />
@@ -41,9 +49,7 @@ export const ScheduleAutomation = ({
}),
)
}
options={schedules.flatMap(schedule =>
schedule.name ? [[schedule.name, schedule.name]] : [],
)}
options={selectableSchedules.map(s => [s.name, s.name] as const)}
/>
</FormField>
<FormField style={{ flex: 1 }}>

View File

@@ -1,37 +0,0 @@
import { useTranslation } from 'react-i18next';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import { updateTemplate } from '#components/budget/goals/actions';
import type { Action } from '#components/budget/goals/actions';
import { FormField, FormLabel } from '#components/forms';
import { AmountInput } from '#components/util/AmountInput';
type WeekAutomationProps = {
template: PeriodicTemplate;
dispatch: (action: Action) => void;
};
export const WeekAutomation = ({ template, dispatch }: WeekAutomationProps) => {
const { t } = useTranslation();
return (
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Amount')} htmlFor="amount-field" />
<AmountInput
id="amount-field"
key="amount-input"
value={template.amount ?? 0}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(
updateTemplate({
type: 'periodic',
amount: value,
}),
)
}
/>
</FormField>
);
};

View File

@@ -1,31 +0,0 @@
import { Trans } from 'react-i18next';
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
import type { TransObjectLiteral } from '@actual-app/core/types/util';
import { FinancialText } from '#components/FinancialText';
import { useFormat } from '#hooks/useFormat';
type WeekAutomationReadOnlyProps = {
template: PeriodicTemplate;
};
export const WeekAutomationReadOnly = ({
template,
}: WeekAutomationReadOnlyProps) => {
const format = useFormat();
return (
<Trans>
Budget{' '}
<FinancialText>
{
{
amount: format(template.amount, 'financial'),
} as TransObjectLiteral
}
</FinancialText>{' '}
each week
</Trans>
);
};

View File

@@ -0,0 +1,14 @@
import * as monthUtils from '@actual-app/core/shared/months';
// Format a YYYY-MM string as "MMM yyyy" using the active locale (matching
// the convention used elsewhere in the codebase via monthUtils.format).
// Falls back to the raw input if it doesn't look like YYYY-MM, and to "—"
// for empty/missing values so callers don't need their own guards.
export function formatMonthLabel(
month: string | undefined | null,
locale?: Parameters<typeof monthUtils.format>[2],
): string {
if (!month) return '—';
if (!monthUtils.isValidYearMonth(month)) return month;
return monthUtils.format(`${month}-01`, 'MMM yyyy', locale);
}

View File

@@ -1,4 +1,9 @@
import { firstDayOfMonth } from '@actual-app/core/shared/months';
import {
addMonths,
dayFromDate,
firstDayOfMonth,
monthFromDate,
} from '@actual-app/core/shared/months';
import type { Template } from '@actual-app/core/types/models/templates';
import type { Action } from './actions';
@@ -25,7 +30,7 @@ export const getInitialState = (template: Template | null): ReducerState => {
priority: template.priority,
directive: template.directive,
},
displayType: 'week',
displayType: 'fixed',
};
case 'percentage':
return {
@@ -40,13 +45,20 @@ export const getInitialState = (template: Template | null): ReducerState => {
case 'periodic':
return {
template,
displayType: 'week',
displayType: 'fixed',
};
case 'spend':
case 'by':
throw new Error('Goal is not yet supported');
case 'by':
return {
template,
displayType: 'by',
};
case 'remainder':
throw new Error('Remainder is not yet supported');
return {
template,
displayType: 'remainder',
};
case 'limit':
return {
template,
@@ -117,7 +129,7 @@ const changeType = (
type: 'percentage',
percent: 15,
previous: false,
category: 'total',
category: 'all income',
priority: DEFAULT_PRIORITY,
},
};
@@ -134,7 +146,7 @@ const changeType = (
priority: DEFAULT_PRIORITY,
},
};
case 'week':
case 'fixed':
if (prevState.template.type === 'periodic') {
return prevState;
}
@@ -143,12 +155,12 @@ const changeType = (
template: {
directive: 'template',
type: 'periodic',
amount: 5,
amount: 100,
period: {
period: 'week',
period: 'month',
amount: 1,
},
starting: '',
starting: dayFromDate(firstDayOfMonth(new Date())),
priority: DEFAULT_PRIORITY,
},
};
@@ -168,6 +180,35 @@ const changeType = (
priority: DEFAULT_PRIORITY,
},
};
case 'by':
if (prevState.template.type === 'by') {
return prevState;
}
return {
displayType: visualType,
template: {
directive: 'template',
type: 'by',
amount: 1200,
month: addMonths(monthFromDate(new Date()), 12),
annual: true,
repeat: 1,
priority: DEFAULT_PRIORITY,
},
};
case 'remainder':
if (prevState.template.type === 'remainder') {
return prevState;
}
return {
displayType: visualType,
template: {
directive: 'template',
type: 'remainder',
weight: 1,
priority: null,
},
};
default:
// Make sure we're not missing any cases
throw new Error(

View File

@@ -7,21 +7,24 @@ export function useBudgetAutomationCategories() {
const { t } = useTranslation();
const { data: { grouped } = { grouped: [] } } = useCategories();
const categories = useMemo(() => {
const incomeGroup = grouped.filter(group => group.name === 'Income')[0];
const incomeGroups = grouped.filter(group => group.is_income);
return [
{
id: '',
name: t('Special categories'),
categories: [
{ id: 'total', group: '', name: t('Total of all income') },
{ id: 'all income', group: '', name: t('Total of all income') },
{
id: 'to-budget',
id: 'available funds',
group: '',
name: t('Available funds to budget'),
},
],
},
{ ...incomeGroup, name: t('Income categories') },
...incomeGroups.map(group => ({
...group,
name: t('Income categories'),
})),
];
}, [grouped, t]);

View File

@@ -0,0 +1,69 @@
import type { Template } from '@actual-app/core/types/models/templates';
import { describe, expect, it } from 'vitest';
import { validatePercentageAllocation } from './validateAutomation';
function percent(
category: string,
percent: number,
previous = false,
): Template {
return {
type: 'percentage',
percent,
previous,
category,
directive: 'template',
priority: 1,
};
}
describe('validatePercentageAllocation', () => {
it('returns null when no percentage templates are present', () => {
expect(validatePercentageAllocation([])).toBeNull();
});
it('flags a single source over 100%', () => {
expect(
validatePercentageAllocation([
percent('Salary', 60),
percent('Salary', 50),
]),
).toEqual({ kind: 'percent-over-100', total: 110 });
});
it('does not sum across distinct income sources', () => {
expect(
validatePercentageAllocation([
percent('Income-HSA', 100, true),
percent('Interest-HSA', 100),
]),
).toBeNull();
});
it('treats this-month and last-month income as different sources', () => {
expect(
validatePercentageAllocation([
percent('Salary', 100, false),
percent('Salary', 100, true),
]),
).toBeNull();
});
it('ignores templates with a missing source', () => {
const orphan = {
...percent('Salary', 100),
category: null as unknown as string,
};
expect(validatePercentageAllocation([orphan])).toBeNull();
});
it('matches sources case-insensitively', () => {
expect(
validatePercentageAllocation([
percent('Salary', 60),
percent('salary', 50),
]),
).toEqual({ kind: 'percent-over-100', total: 110 });
});
});

View File

@@ -0,0 +1,110 @@
import * as monthUtils from '@actual-app/core/shared/months';
import type { ScheduleEntity } from '@actual-app/core/types/models';
import type { Template } from '@actual-app/core/types/models/templates';
import type { DisplayTemplateType } from './constants';
export type AutomationErrorKind =
| { kind: 'schedule-not-found'; name: string }
| { kind: 'refill-no-cap' }
| { kind: 'percentage-out-of-range'; percent: number }
| { kind: 'percentage-no-source' }
| { kind: 'percentage-source-not-found'; source: string }
| { kind: 'by-no-month' }
| { kind: 'by-target-past'; month: string };
export type GlobalConflictKind =
| { kind: 'over-income'; total: number; income: number }
| { kind: 'percent-over-100'; total: number };
export function validateAutomation(
template: Template,
displayType: DisplayTemplateType,
allTemplates: readonly Template[],
schedules: readonly ScheduleEntity[],
today: Date,
// Set of recognised percentage sources (income category ids, lower-cased
// category names, and special source aliases like 'all income'). When
// omitted the source-not-found check is skipped (the engine still validates
// server-side at apply time).
validPercentageSources?: ReadonlySet<string>,
): AutomationErrorKind | null {
switch (displayType) {
case 'schedule':
if (template.type !== 'schedule') return null;
if (!template.name) return { kind: 'schedule-not-found', name: '' };
if (
!schedules.some(
s => s.name === template.name && !s.completed && !s.tombstone,
)
) {
return { kind: 'schedule-not-found', name: template.name };
}
return null;
case 'refill':
if (!allTemplates.some(t => t.type === 'limit')) {
return { kind: 'refill-no-cap' };
}
return null;
case 'percentage':
if (template.type !== 'percentage') return null;
if (!template.category) return { kind: 'percentage-no-source' };
if (template.percent <= 0 || template.percent > 100) {
return {
kind: 'percentage-out-of-range',
percent: template.percent,
};
}
if (
validPercentageSources &&
!validPercentageSources.has(template.category) &&
!validPercentageSources.has(template.category.toLowerCase())
) {
return {
kind: 'percentage-source-not-found',
source: template.category,
};
}
return null;
case 'by': {
if (template.type !== 'by') return null;
if (!template.month || !monthUtils.isValidYearMonth(template.month)) {
return { kind: 'by-no-month' };
}
const targetMonth = template.month;
const startOfTodayMonth = monthUtils.monthFromDate(today);
// Pass bare YYYY-MM strings, matching the server-side check in
// CategoryTemplateContext.checkByAndScheduleAndSpend and avoiding the
// local-vs-UTC parsing footgun called out in shared/months.ts:_parse.
const monthsRemaining = monthUtils.differenceInCalendarMonths(
targetMonth,
startOfTodayMonth,
);
// Recurring goals (annual/repeat) anchored on a past month are
// legitimate — the engine rolls them forward by the period. Only flag
// the past-target case for one-shot goals. Mirrors the server check in
// CategoryTemplateContext.checkByAndScheduleAndSpend.
if (monthsRemaining < 0 && !template.annual && !template.repeat) {
return { kind: 'by-target-past', month: targetMonth };
}
return null;
}
default:
return null;
}
}
export function validatePercentageAllocation(
templates: readonly Template[],
): GlobalConflictKind | null {
const percentBySource = new Map<string, number>();
for (const t of templates) {
if (t.type !== 'percentage' || !t.category) continue;
const key = `${t.previous}|${t.category.toLocaleLowerCase()}`;
percentBySource.set(key, (percentBySource.get(key) ?? 0) + t.percent);
}
const maxPercent = Math.max(0, ...percentBySource.values());
return maxPercent > 100
? { kind: 'percent-over-100', total: maxPercent }
: null;
}

View File

@@ -13,11 +13,13 @@ type BudgetMenuProps = Omit<
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyUntilYearEnd: () => void;
};
export function BudgetMenu({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyUntilYearEnd,
...props
}: BudgetMenuProps) {
const { t } = useTranslation();
@@ -39,6 +41,9 @@ export function BudgetMenu({
case 'apply-single-category-template':
onApplyBudgetTemplate?.();
break;
case 'copy-until-year-end':
onCopyUntilYearEnd?.();
break;
default:
throw new Error(`Unrecognized menu item: ${name}`);
}
@@ -65,6 +70,10 @@ export function BudgetMenu({
name: 'set-single-12-avg',
text: t('Set to yearly average'),
},
{
name: 'copy-until-year-end',
text: t('Copy until year end'),
},
...(isGoalTemplatesEnabled
? [
{

View File

@@ -344,6 +344,14 @@ export const CategoryMonth = memo(function CategoryMonth({
message: t(`Budget template applied.`),
});
}}
onCopyUntilYearEnd={() => {
onMenuAction(month, 'copy-until-year-end', {
category: category.id,
});
showUndoNotification({
message: t(`Budget copied until year end.`),
});
}}
/>
</Popover>
</View>

View File

@@ -12,6 +12,7 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Toggle } from '@actual-app/components/toggle';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
type FieldLabelProps = {
@@ -46,15 +47,71 @@ const valueStyle = {
height: styles.mobileMinHeight,
};
type InputFieldProps = ComponentPropsWithRef<typeof Input>;
const hideNativeDateIconClassName = css({
'&::-webkit-calendar-picker-indicator': {
display: 'none',
},
});
type InputFieldProps = ComponentPropsWithRef<typeof Input> & {
icon?: ReactNode;
};
export function InputField({
disabled,
style,
onUpdate,
icon,
className,
ref,
...props
}: InputFieldProps) {
if (icon) {
return (
<View
style={{
...valueStyle,
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 8,
gap: 8,
backgroundColor: disabled
? theme.formInputTextReadOnlySelection
: theme.tableBackground,
}}
>
<View style={{ color: theme.pageTextSubdued, flexShrink: 0 }}>
{icon}
</View>
<Input
ref={ref}
autoCorrect="false"
autoCapitalize="none"
disabled={disabled}
onUpdate={onUpdate}
style={{
flex: 1,
border: 'none',
backgroundColor: 'transparent',
height: '100%',
padding: 0,
color: disabled ? theme.tableTextInactive : theme.tableText,
...style,
}}
{...props}
className={renderProps =>
cx(
hideNativeDateIconClassName,
typeof className === 'function'
? className(renderProps)
: className,
)
}
/>
</View>
);
}
return (
<Input
ref={ref}
@@ -62,6 +119,7 @@ export function InputField({
autoCapitalize="none"
disabled={disabled}
onUpdate={onUpdate}
className={className}
style={{
...valueStyle,
...style,
@@ -78,6 +136,8 @@ export function InputField({
InputField.displayName = 'InputField';
type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
icon?: ReactNode;
placeholder?: string;
rightContent?: ReactNode;
alwaysShowRightContent?: boolean;
textStyle?: CSSProperties;
@@ -105,12 +165,15 @@ export function TapField({
value,
children,
className,
icon,
placeholder,
rightContent,
alwaysShowRightContent,
textStyle,
ref,
...props
}: TapFieldProps) {
const showPlaceholder = !value && !!placeholder;
return (
<Button
ref={ref}
@@ -126,16 +189,32 @@ export function TapField({
{children ? (
children
) : (
<Text
style={{
flex: 1,
userSelect: 'none',
textAlign: 'left',
...textStyle,
}}
>
{value}
</Text>
<>
{icon && (
<View
style={{
color: theme.pageTextSubdued,
marginRight: 8,
flexShrink: 0,
}}
>
{icon}
</View>
)}
<Text
style={{
flex: 1,
userSelect: 'none',
textAlign: 'left',
color: showPlaceholder
? theme.formInputTextPlaceholder
: undefined,
...textStyle,
}}
>
{showPlaceholder ? placeholder : value}
</Text>
</>
)}
{(!props.isDisabled || alwaysShowRightContent) && rightContent}
</Button>

View File

@@ -5,14 +5,17 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { AccountEntity } from '@actual-app/core/types/models';
import type {
GroupedBankSyncAccounts,
SyncProviders,
} from '#components/banksync/bankSyncUtils';
import { getGroupedBankSyncEntries } from '#components/banksync/bankSyncUtils';
import { MOBILE_NAV_HEIGHT } from '#components/mobile/MobileNavTabs';
import { BankSyncAccountsListItem } from './BankSyncAccountsListItem';
type SyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai' | 'unlinked';
type BankSyncAccountsListProps = {
groupedAccounts: Record<SyncProviders, AccountEntity[]>;
groupedAccounts: GroupedBankSyncAccounts;
syncSourceReadable: Record<SyncProviders, string>;
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
};
@@ -22,7 +25,8 @@ export function BankSyncAccountsList({
syncSourceReadable,
onAction,
}: BankSyncAccountsListProps) {
const allAccounts = Object.values(groupedAccounts).flat();
const groupedAccountEntries = getGroupedBankSyncEntries(groupedAccounts);
const allAccounts = groupedAccountEntries.flatMap(([, accounts]) => accounts);
if (allAccounts.length === 0) {
return (
@@ -47,15 +51,13 @@ export function BankSyncAccountsList({
);
}
const shouldShowProviderHeaders = Object.keys(groupedAccounts).length > 1;
const shouldShowProviderHeaders = groupedAccountEntries.length > 1;
return (
<div
style={{ flex: 1, overflow: 'auto', paddingBottom: MOBILE_NAV_HEIGHT }}
>
{(
Object.entries(groupedAccounts) as [SyncProviders, AccountEntity[]][]
).map(([provider, accounts]) => (
{groupedAccountEntries.map(([provider, accounts]) => (
<div key={provider}>
{shouldShowProviderHeaders && (
<div

View File

@@ -5,11 +5,14 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type {
AccountEntity,
BankSyncProviders,
} from '@actual-app/core/types/models';
import type { AccountEntity } from '@actual-app/core/types/models';
import {
getGroupedBankSyncEntries,
getSyncSourceReadable,
groupBankSyncAccounts,
} from '#components/banksync/bankSyncUtils';
import type { GroupedBankSyncAccounts } from '#components/banksync/bankSyncUtils';
import { Search } from '#components/common/Search';
import { MobilePageHeader, Page } from '#components/Page';
import { useAccounts } from '#hooks/useAccounts';
@@ -19,79 +22,42 @@ import { useDispatch } from '#redux';
import { BankSyncAccountsList } from './BankSyncAccountsList';
type SyncProviders = BankSyncProviders | 'unlinked';
const useSyncSourceReadable = () => {
const { t } = useTranslation();
const syncSourceReadable: Record<SyncProviders, string> = {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
unlinked: t('Unlinked'),
};
return { syncSourceReadable };
};
export function MobileBankSyncPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { syncSourceReadable } = useSyncSourceReadable();
const { data: accounts = [] } = useAccounts();
const [filter, setFilter] = useState('');
const syncSourceReadable = useMemo(() => getSyncSourceReadable(t), [t]);
const openAccounts = useMemo(
() => accounts.filter(a => !a.closed),
[accounts],
);
const groupedAccounts = useMemo(() => {
const unsorted = openAccounts.reduce(
(acc, a) => {
const syncSource = a.account_sync_source ?? 'unlinked';
acc[syncSource] = acc[syncSource] || [];
acc[syncSource].push(a);
return acc;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
const sortedKeys = Object.keys(unsorted).sort((keyA, keyB) => {
if (keyA === 'unlinked') return 1;
if (keyB === 'unlinked') return -1;
return keyA.localeCompare(keyB);
});
return sortedKeys.reduce(
(sorted, key) => {
sorted[key as SyncProviders] = unsorted[key as SyncProviders];
return sorted;
},
{} as Record<SyncProviders, AccountEntity[]>,
);
}, [openAccounts]);
const groupedAccounts = useMemo(
() => groupBankSyncAccounts(openAccounts),
[openAccounts],
);
const filteredGroupedAccounts = useMemo(() => {
if (!filter) return groupedAccounts;
const filterLower = filter.toLowerCase();
const filtered: Record<SyncProviders, AccountEntity[]> = {} as Record<
SyncProviders,
AccountEntity[]
>;
const filtered: GroupedBankSyncAccounts = {};
Object.entries(groupedAccounts).forEach(([provider, accounts]) => {
const filteredAccounts = accounts.filter(
account =>
account.name.toLowerCase().includes(filterLower) ||
account.bankName?.toLowerCase().includes(filterLower),
);
if (filteredAccounts.length > 0) {
filtered[provider as SyncProviders] = filteredAccounts;
}
});
getGroupedBankSyncEntries(groupedAccounts).forEach(
([provider, accounts]) => {
const filteredAccounts = accounts.filter(
account =>
account.name.toLowerCase().includes(filterLower) ||
account.bankName?.toLowerCase().includes(filterLower),
);
if (filteredAccounts.length > 0) {
filtered[provider] = filteredAccounts;
}
},
);
return filtered;
}, [groupedAccounts, filter]);

View File

@@ -79,61 +79,84 @@ export function BudgetCell<
);
const onOpenCategoryBudgetMenu = useCallback(() => {
const modalBudgetType = budgetType === 'envelope' ? 'envelope' : 'tracking';
const categoryBudgetMenuModal = `${modalBudgetType}-budget-menu` as const;
dispatch(
pushModal({
modal: {
name: categoryBudgetMenuModal,
options: {
categoryId: category.id,
month,
onEditNotes,
onUpdateBudget: amount => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
amount,
});
showUndoNotification({
message: `${category.name} budget has been updated to ${format(amount, 'financial')}.`,
});
},
onCopyLastMonthAverage: () => {
onBudgetAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to last month's budgeted amount.`,
});
},
onSetMonthsAverage: numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
});
},
onApplyBudgetTemplate: () => {
onBudgetAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget templates have been applied.`,
pre: categoryNotes ?? undefined,
});
const sharedOptions = {
categoryId: category.id,
month,
onEditNotes,
onUpdateBudget: (amount: number) => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
amount,
});
showUndoNotification({
message: `${category.name} budget has been updated to ${format(amount, 'financial')}.`,
});
},
onCopyLastMonthAverage: () => {
onBudgetAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to last month's budgeted amount.`,
});
},
onSetMonthsAverage: (numberOfMonths: number) => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
});
},
onApplyBudgetTemplate: () => {
onBudgetAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget templates have been applied.`,
pre: categoryNotes ?? undefined,
});
},
};
if (budgetType === 'envelope') {
dispatch(
pushModal({
modal: {
name: 'envelope-budget-menu',
options: sharedOptions,
},
}),
);
} else {
dispatch(
pushModal({
modal: {
name: 'tracking-budget-menu',
options: {
...sharedOptions,
onCopyUntilYearEnd: () => {
onBudgetAction(month, 'copy-until-year-end', {
category: category.id,
});
showUndoNotification({
message: t('{{categoryName}} budget copied until year end.', {
categoryName: category.name,
}),
});
},
},
},
},
}),
);
}),
);
}
}, [
budgetType,
category.id,
@@ -145,6 +168,7 @@ export function BudgetCell<
showUndoNotification,
onEditNotes,
format,
t,
]);
return (

View File

@@ -160,7 +160,7 @@ const AmountInput = memo(function AmountInput({
}}
data-testid="amount-input-text"
>
{editing ? text : amountToCurrency(value)}
{editing ? text || amountToCurrency(0) : amountToCurrency(value)}
</Text>
</View>
);

View File

@@ -14,11 +14,19 @@ import { Button } from '@actual-app/components/button';
import { SvgSplit } from '@actual-app/components/icons/v0';
import {
SvgAdd,
SvgCalendar,
SvgCheveronDown,
SvgLocation,
SvgPiggyBank,
SvgTag,
SvgTrash,
SvgUser,
SvgWallet,
} from '@actual-app/components/icons/v1';
import { SvgPencilWriteAlternate } from '@actual-app/components/icons/v2';
import {
SvgNotesPaper,
SvgPencilWriteAlternate,
} from '@actual-app/components/icons/v2';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
@@ -164,6 +172,14 @@ export function lookupName(items: CategoryEntity[], id?: CategoryEntity['id']) {
return items.find(item => item.id === id)?.name;
}
const dropdownChevron = (
<SvgCheveronDown
width={14}
height={14}
style={{ color: theme.pageTextSubdued, marginRight: 8 }}
/>
);
export function Status({
status,
isSplit = false,
@@ -429,6 +445,9 @@ const ChildTransactionEdit = forwardRef<
<View style={{ flexBasis: '75%' }}>
<FieldLabel title={t('Payee')} />
<TapField
icon={<SvgUser width={17} height={17} />}
placeholder={t('Who did you pay?')}
rightContent={dropdownChevron}
isDisabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'payee')
@@ -477,6 +496,9 @@ const ChildTransactionEdit = forwardRef<
<View>
<FieldLabel title={t('Category')} />
<TapField
icon={<SvgTag width={17} height={17} />}
placeholder={t('Select a category')}
rightContent={dropdownChevron}
textStyle={{
...((isOffBudget || isBudgetTransfer(transaction)) && {
fontStyle: 'italic',
@@ -499,6 +521,8 @@ const ChildTransactionEdit = forwardRef<
<View>
<FieldLabel title={t('Notes')} />
<InputField
icon={<SvgNotesPaper width={17} height={17} />}
placeholder={t('Add a note (optional)')}
disabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'notes')
@@ -1150,6 +1174,8 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<View>
<FieldLabel title={t('Payee')} />
<TapField
icon={<SvgUser width={17} height={17} />}
placeholder={t('Who did you pay?')}
textStyle={{
...(transaction.is_parent && {
fontStyle: 'italic',
@@ -1211,7 +1237,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
style={{ marginLeft: 4 }}
/>
</Button>
) : undefined
) : (
dropdownChevron
)
}
/>
</View>
@@ -1220,6 +1248,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<View>
<FieldLabel title={t('Category')} />
<TapField
icon={<SvgTag width={17} height={17} />}
placeholder={t('Select a category')}
rightContent={dropdownChevron}
style={{
...((isOffBudget || isBudgetTransfer(transaction)) && {
fontStyle: 'italic',
@@ -1300,6 +1331,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<View>
<FieldLabel title={t('Account')} />
<TapField
icon={<SvgWallet width={17} height={17} />}
placeholder={t('Select an account')}
rightContent={dropdownChevron}
isDisabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'account')
@@ -1315,6 +1349,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<FieldLabel title={t('Date')} />
<InputField
type="date"
icon={<SvgCalendar width={17} height={17} />}
disabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'date')
@@ -1359,6 +1394,8 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
<View>
<FieldLabel title={t('Notes')} />
<InputField
icon={<SvgNotesPaper width={17} height={17} />}
placeholder={t('Add a note (optional)')}
disabled={
!!editingField &&
editingField !== getFieldName(transaction.id, 'notes')

View File

@@ -1,465 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import type { CSSProperties } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { SpaceBetween } from '@actual-app/components/space-between';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from '@actual-app/core/platform/client/connection';
import { dayFromDate, firstDayOfMonth } from '@actual-app/core/shared/months';
import { q } from '@actual-app/core/shared/query';
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import type { Template } from '@actual-app/core/types/models/templates';
import uniqueId from 'lodash/uniqueId';
import { Warning } from '#components/alerts';
import { BudgetAutomation } from '#components/budget/goals/BudgetAutomation';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
import { DEFAULT_PRIORITY } from '#components/budget/goals/reducer';
import { useBudgetAutomationCategories } from '#components/budget/goals/useBudgetAutomationCategories';
import { Link } from '#components/common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
import { useBudgetAutomations } from '#hooks/useBudgetAutomations';
import { useCategory } from '#hooks/useCategory';
import { useNotes } from '#hooks/useNotes';
import { useSchedules } from '#hooks/useSchedules';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
type AutomationEntry = {
id: string;
template: Template;
displayType: DisplayTemplateType;
};
function getDisplayTypeFromTemplate(template: Template): DisplayTemplateType {
switch (template.type) {
case 'percentage':
return 'percentage';
case 'schedule':
return 'schedule';
case 'periodic':
case 'simple':
return 'week';
case 'limit':
return 'limit';
case 'refill':
return 'refill';
case 'average':
case 'copy':
return 'historical';
default:
return 'week';
}
}
function createAutomationEntry(
template: Template,
displayType: DisplayTemplateType,
): AutomationEntry {
return {
id: uniqueId('automation-'),
template,
displayType,
};
}
export function migrateTemplatesToAutomations(
templates: Template[],
): AutomationEntry[] {
const entries: AutomationEntry[] = [];
templates.forEach(template => {
// Expand simple templates into limit, refill, and/or periodic templates
if (template.type === 'simple') {
let hasExpandedTemplate = false;
if (template.limit) {
hasExpandedTemplate = true;
entries.push(
createAutomationEntry(
{
type: 'limit',
amount: template.limit.amount,
hold: template.limit.hold,
period: template.limit.period,
start: template.limit.start,
directive: 'template',
priority: null,
},
'limit',
),
);
entries.push(
createAutomationEntry(
{
type: 'refill',
directive: 'template',
priority: template.priority,
},
'refill',
),
);
}
// If it has a monthly amount, create a periodic template
if (template.monthly != null && template.monthly !== 0) {
hasExpandedTemplate = true;
entries.push(
createAutomationEntry(
{
type: 'periodic',
amount: template.monthly,
period: {
period: 'month',
amount: 1,
},
starting: dayFromDate(firstDayOfMonth(new Date())),
directive: 'template',
priority: template.priority,
},
'week',
),
);
}
if (!hasExpandedTemplate) {
entries.push(
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
);
}
return;
}
// For all other template types, create a single entry
entries.push(
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
);
});
return entries;
}
function BudgetAutomationList({
automations,
setAutomations,
schedules,
categories,
style,
}: {
automations: AutomationEntry[];
setAutomations: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
style?: CSSProperties;
}) {
const onAdd = () => {
setAutomations(prev => [
...prev,
createAutomationEntry(
{
type: 'periodic',
amount: 500,
period: {
period: 'month',
amount: 1,
},
starting: dayFromDate(firstDayOfMonth(new Date())),
directive: 'template',
priority: DEFAULT_PRIORITY,
},
'week',
),
]);
};
const onAddLimit = () => {
setAutomations(prev => [
...prev,
createAutomationEntry(
{
directive: 'template',
type: 'limit',
amount: 500,
period: 'monthly',
hold: false,
priority: null,
},
'limit',
),
]);
};
const onDelete = (index: number) => () => {
setAutomations(prev => [...prev.slice(0, index), ...prev.slice(index + 1)]);
};
const onSave = useCallback(
(index: number) =>
(template: Template, displayType: DisplayTemplateType) => {
setAutomations(prev =>
prev.map((oldAutomation, mapIndex) =>
mapIndex === index
? { ...oldAutomation, template, displayType }
: oldAutomation,
),
);
},
[setAutomations],
);
const hasLimitAutomation = automations.some(
automation => automation.displayType === 'limit',
);
return (
<SpaceBetween
direction="vertical"
gap={20}
align="stretch"
wrap={false}
style={{
overflowY: 'scroll',
...style,
}}
>
{automations.map((automation, index) => (
<BudgetAutomation
key={automation.id}
onSave={onSave(index)}
onDelete={onDelete(index)}
template={automation.template}
categories={categories}
schedules={schedules}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={
automation.displayType === 'refill' ? onAddLimit : undefined
}
readOnlyStyle={{
color: theme.pillText,
backgroundColor: theme.pillBackground,
borderRadius: 4,
padding: 16,
paddingLeft: 30,
paddingRight: 16,
}}
/>
))}
<Button onPress={onAdd}>
<Trans>Add new automation</Trans>
</Button>
</SpaceBetween>
);
}
function BudgetAutomationMigrationWarning({
categoryId,
style,
}: {
categoryId: string;
style?: CSSProperties;
}) {
const notes = useNotes(categoryId);
const templates = useMemo(() => {
if (!notes) return null;
const lines = notes.split('\n');
return lines
.flatMap(line => {
if (line.trim().startsWith('#template')) return line;
if (line.trim().startsWith('#goal')) return line;
if (line.trim().startsWith('#cleanup')) return line;
return [];
})
.join('\n');
}, [notes]);
if (!templates) return null;
return (
<Warning style={style}>
<SpaceBetween direction="vertical" style={{ minHeight: 'unset' }}>
<View>
<Trans>
This category uses notes-based automations (formerly "budget
templates"). We have automatically imported your existing
automations below. Please review them for accuracy and hit save to
complete the migration.
</Trans>
</View>
<View>
<Trans>
Original templates:
<View
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
marginTop: 4,
padding: 12,
borderRadius: 4,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
>
{templates}
</View>
</Trans>
</View>
</SpaceBetween>
</Warning>
);
}
export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
const { t } = useTranslation();
const dispatch = useDispatch();
const [automations, setAutomations] = useState<
Record<string, AutomationEntry[]>
>({});
const onLoaded = useCallback((result: Record<string, Template[]>) => {
const next: Record<string, AutomationEntry[]> = {};
for (const [id, templates] of Object.entries(result)) {
next[id] = migrateTemplatesToAutomations(templates);
}
setAutomations(next);
}, []);
const { loading } = useBudgetAutomations({
categoryId,
onLoaded,
});
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { schedules } = useSchedules({
query: schedulesQuery,
});
const categories = useBudgetAutomationCategories();
const { data: currentCategory } = useCategory(categoryId);
const needsMigration = currentCategory?.template_settings?.source !== 'ui';
const onSave = async (close: () => void) => {
if (!automations[categoryId]) {
close();
return;
}
const templates = automations[categoryId].map(({ template }) => template);
await send('budget/set-category-automations', {
categoriesWithTemplates: [
{
id: categoryId,
templates,
},
],
source: 'ui',
});
close();
};
return (
<Modal
name="category-automations-edit"
containerProps={{
style: { width: 850, height: 650, paddingBottom: 20 },
}}
>
{({ state }) => (
<SpaceBetween
direction="vertical"
wrap={false}
align="stretch"
style={{ height: '100%' }}
>
<ModalHeader
title={t('Budget automations: {{category}}', {
category: currentCategory?.name,
})}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
{loading ? (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
) : (
<SpaceBetween align="stretch" direction="vertical" wrap={false}>
{needsMigration && (
<BudgetAutomationMigrationWarning
categoryId={categoryId}
style={{ flexShrink: 0 }}
/>
)}
<BudgetAutomationList
automations={automations[categoryId] || []}
setAutomations={(
cb: (prev: AutomationEntry[]) => AutomationEntry[],
) => {
setAutomations(prev => ({
...prev,
[categoryId]: cb(prev[categoryId] || []),
}));
}}
schedules={schedules}
categories={categories}
/>
</SpaceBetween>
)}
<View style={{ flexGrow: 1 }} />
<SpaceBetween
style={{
marginTop: 20,
justifyContent: 'flex-end',
flexShrink: 0,
}}
>
{!needsMigration && (
<Link
variant="text"
onClick={() => {
const templates = automations[categoryId] || [];
dispatch(
pushModal({
modal: {
name: 'category-automations-unmigrate',
options: {
categoryId,
templates: templates.map(({ template }) => template),
},
},
}),
);
}}
>
<Trans>Un-migrate</Trans>
</Link>
)}
{/* <View style={{ flex: 1 }} /> */}
<Button onPress={() => state.close()}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="primary"
onPress={() => onSave(() => state.close())}
>
<Trans>Save</Trans>
</Button>
</SpaceBetween>
</SpaceBetween>
)}
</Modal>
);
}

View File

@@ -0,0 +1,294 @@
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgDelete } from '@actual-app/components/icons/v0';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Input } from '@actual-app/components/input';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import { css } from '@emotion/css';
import { ActiveEditor } from '#components/budget/goals/ActiveEditor';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import {
AutomationErrorDetail,
AutomationErrorTitle,
} from '#components/budget/goals/automationMessages';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
import {
getInitialState,
templateReducer,
} from '#components/budget/goals/reducer';
import type { AutomationErrorKind } from '#components/budget/goals/validateAutomation';
import { TypePicker } from './TypePicker';
const CONFIG_PANEL_CLASS = css({
'& > *:first-child': {
marginTop: 0,
},
'& span > label': {
fontSize: 11,
fontWeight: 600,
color: theme.pageTextSubdued,
letterSpacing: '0.04em',
textTransform: 'uppercase',
},
// Match Select borders to text inputs (Button uses buttonNormalBorder which
// is brighter than formInputBorder in dark/midnight themes).
'& button[type="button"]:not([aria-pressed])': {
borderColor: theme.formInputBorder,
},
});
const SINGLETON_TYPES: ReadonlySet<DisplayTemplateType> = new Set([
'limit',
'refill',
'remainder',
]);
type AutomationEditorPaneProps = {
entries: AutomationEntry[];
activeIdx: number;
automationErrors: (AutomationErrorKind | null)[];
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
hasLimitAutomation: boolean;
onAddLimitAutomation: () => void;
setEntries: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
onDelete: (index: number) => void;
};
export function AutomationEditorPane({
entries,
activeIdx,
automationErrors,
schedules,
categories,
hasLimitAutomation,
onAddLimitAutomation,
setEntries,
onDelete,
}: AutomationEditorPaneProps) {
const active = entries[activeIdx];
const activeError = automationErrors[activeIdx];
const state = active ? getInitialState(active.template) : null;
const dispatch = (action: Parameters<typeof templateReducer>[1]) => {
setEntries(prev =>
prev.map((entry, i) => {
if (i !== activeIdx) return entry;
const current = getInitialState(entry.template);
const next = templateReducer(current, action);
return {
id: entry.id,
template: next.template,
displayType: next.displayType,
};
}),
);
};
const setPriority = (priority: number) => {
setEntries(prev =>
prev.map((entry, i) => {
if (i !== activeIdx) return entry;
const t = entry.template;
switch (t.type) {
case 'percentage':
case 'periodic':
case 'by':
case 'spend':
case 'simple':
case 'schedule':
case 'average':
case 'copy':
case 'refill':
return { ...entry, template: { ...t, priority } };
default:
return entry;
}
}),
);
};
const disabledTypes = new Set<DisplayTemplateType>();
entries.forEach((entry, i) => {
if (i !== activeIdx && SINGLETON_TYPES.has(entry.displayType)) {
disabledTypes.add(entry.displayType);
}
});
if (!active || !state) {
return (
<View style={{ padding: 20, color: theme.pageTextSubdued }}>
<Trans>Select an automation on the left.</Trans>
</View>
);
}
return (
<View
style={{
flex: 1,
padding: 20,
overflowY: 'auto',
gap: 14,
}}
>
{activeError && (
<View
style={{
padding: '10px 12px',
borderRadius: 6,
backgroundColor: theme.errorBackground,
border: `1px solid ${theme.errorBorder}`,
color: theme.errorText,
fontSize: 13,
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<SvgAlertTriangle
width={14}
height={14}
style={{ marginTop: 2, color: 'inherit', flexShrink: 0 }}
/>
<View style={{ minWidth: 0 }}>
<Text style={{ fontWeight: 600, color: 'inherit' }}>
<AutomationErrorTitle error={activeError} />
</Text>
<Text
style={{
fontSize: 12,
marginTop: 2,
color: 'inherit',
display: 'block',
}}
>
<AutomationErrorDetail error={activeError} />
</Text>
</View>
</View>
)}
<Text
style={{
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
fontWeight: 600,
letterSpacing: '0.05em',
}}
>
<Trans>Automation type</Trans>
</Text>
<TypePicker
active={state.displayType}
disabledTypes={disabledTypes}
onPick={type => dispatch({ type: 'set-type', payload: type })}
/>
{state.displayType !== 'refill' && (
<>
<Text
style={{
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
fontWeight: 600,
letterSpacing: '0.05em',
}}
>
<Trans>Configuration</Trans>
</Text>
<View
className={CONFIG_PANEL_CLASS}
style={{
padding: 16,
backgroundColor: theme.tableBackground,
borderRadius: 6,
border: `1px solid ${theme.tableBorder}`,
}}
>
<ActiveEditor
state={state}
dispatch={dispatch}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
</View>
</>
)}
{state.displayType === 'refill' && (
<ActiveEditor
state={state}
dispatch={dispatch}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
/>
)}
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
{'priority' in state.template &&
typeof state.template.priority === 'number' && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<Text
style={{
fontSize: 11,
fontWeight: 600,
color: theme.pageTextSubdued,
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
<Trans>Priority</Trans>
</Text>
<Input
type="number"
style={{ width: 64 }}
value={String(state.template.priority)}
onChangeValue={value => {
if (value === '') return;
const parsed = Math.round(Number(value));
if (Number.isNaN(parsed)) return;
setPriority(Math.max(0, parsed));
}}
/>
</View>
)}
<View style={{ flex: 1 }} />
<Button
variant="bare"
onPress={() => onDelete(activeIdx)}
style={{ color: theme.errorText }}
>
<span
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<SvgDelete width={10} height={10} style={{ color: 'inherit' }} />
<Trans>Delete automation</Trans>
</span>
</Button>
</View>
</View>
);
}

View File

@@ -0,0 +1,179 @@
import { useTranslation } from 'react-i18next';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { AutomationErrorShort } from '#components/budget/goals/automationMessages';
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
import { TemplateSentence } from '#components/budget/goals/TemplateSentence';
import type { AutomationErrorKind } from '#components/budget/goals/validateAutomation';
import { useFormat } from '#hooks/useFormat';
type AutomationListRowProps = {
index: number;
entry: AutomationEntry;
isActive: boolean;
error: AutomationErrorKind | null;
contribution: number | null;
categoryNameMap: Record<string, string>;
onSelect: (index: number) => void;
};
export function AutomationListRow({
index,
entry,
isActive,
error,
contribution,
categoryNameMap,
onSelect,
}: AutomationListRowProps) {
const { t } = useTranslation();
const format = useFormat();
const meta = getDisplayTemplateMeta(entry.displayType);
const Icon = meta.icon;
const subtitle = error ? (
<AutomationErrorShort error={error} />
) : (
<TemplateSentence
template={entry.template}
categoryNameMap={categoryNameMap}
/>
);
const borderColor = isActive
? theme.tableBorderSelected
: error
? theme.errorBorder
: 'transparent';
const backgroundColor = isActive
? theme.upcomingBackground
: error
? theme.errorBackground
: 'transparent';
const titleColor = error ? theme.errorText : theme.pageText;
const subtitleColor = error ? theme.errorText : theme.pageTextSubdued;
const priority =
'priority' in entry.template && typeof entry.template.priority === 'number'
? entry.template.priority
: null;
return (
<View
onClick={() => onSelect(index)}
aria-label={t('Select automation')}
style={{
flexShrink: 0,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 10,
marginBottom: 4,
borderRadius: 6,
border: `1px solid ${borderColor}`,
backgroundColor,
cursor: 'pointer',
position: 'relative',
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 6,
backgroundColor: error
? theme.errorBackground
: isActive
? theme.upcomingBackground
: theme.pillBackground,
color: error
? theme.errorText
: isActive
? theme.pageTextPositive
: theme.pageTextSubdued,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon width={14} height={14} style={{ color: 'inherit' }} />
</View>
<View style={{ minWidth: 0, flex: 1 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 4,
fontSize: 12,
fontWeight: 600,
color: titleColor,
}}
>
<Text>{meta.label}</Text>
{error && (
<SvgAlertTriangle
width={11}
height={11}
style={{ color: 'inherit' }}
/>
)}
</View>
<Text
style={{
fontSize: 11,
color: subtitleColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{subtitle}
</Text>
</View>
<View
style={{
flexShrink: 0,
alignItems: 'flex-end',
gap: 2,
}}
>
<Text
style={{
fontSize: 12,
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
color:
contribution == null ||
Number.isNaN(contribution) ||
contribution === 0
? theme.pageTextSubdued
: theme.pageText,
}}
>
{contribution == null || Number.isNaN(contribution)
? '—'
: contribution > 0
? '+' + format(contribution, 'financial')
: format(contribution, 'financial')}
</Text>
{priority != null && (
<Text
style={{
fontSize: 10,
color: theme.pageTextSubdued,
fontVariantNumeric: 'tabular-nums',
letterSpacing: '0.04em',
}}
>
{t('Priority: {{priority}}', { priority })}
</Text>
)}
</View>
</View>
);
}

View File

@@ -0,0 +1,68 @@
import type { CSSProperties } from 'react';
import { Trans } from 'react-i18next';
import { Text } from '@actual-app/components/text';
import { View } from '@actual-app/components/view';
import { Warning } from '#components/alerts';
import { useNotes } from '#hooks/useNotes';
export function BudgetAutomationMigrationWarning({
categoryId,
style,
}: {
categoryId: string;
style?: CSSProperties;
}) {
const notes = useNotes(categoryId);
if (!notes) return null;
const templates = notes
.split('\n')
.filter(line => /^\s*#(template|goal|cleanup)\b/.test(line))
.join('\n');
if (!templates) return null;
return (
<Warning
style={{
padding: '8px 12px',
fontSize: 12,
...style,
}}
>
<View style={{ gap: 4 }}>
<Text>
<Trans>
Imported from notes-based templates. Review and Save to complete the
migration.
</Trans>
</Text>
<details>
<summary style={{ cursor: 'pointer', fontSize: 11, opacity: 0.85 }}>
<Trans>Show original templates</Trans>
</summary>
<View
style={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 11,
marginTop: 6,
padding: 8,
borderRadius: 4,
// Translucent overlay rather than a theme token so the inset
// effect works regardless of the surrounding Warning colour
// (which differs between light/dark/midnight themes).
backgroundColor: 'rgba(0, 0, 0, 0.15)',
maxHeight: 120,
overflowY: 'auto',
}}
>
{templates}
</View>
</details>
</View>
</Warning>
);
}

View File

@@ -0,0 +1,408 @@
import { useEffect, useState } from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from '@actual-app/core/platform/client/connection';
import type {
CategoryGroupEntity,
ScheduleEntity,
} from '@actual-app/core/types/models';
import { css } from '@emotion/css';
import debounce from 'lodash/debounce';
import {
createAutomationEntry,
getAutomationExamples,
} from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
import {
validateAutomation,
validatePercentageAllocation,
} from '#components/budget/goals/validateAutomation';
import { Link } from '#components/common/Link';
import { useFormat } from '#hooks/useFormat';
import { useLocale } from '#hooks/useLocale';
import { pushModal } from '#modals/modalsSlice';
import { useDispatch } from '#redux';
import { AutomationEditorPane } from './AutomationEditorPane';
import { AutomationListRow } from './AutomationListRow';
import { BudgetAutomationMigrationWarning } from './BudgetAutomationMigrationWarning';
import { ConflictBanner } from './ConflictBanner';
import { EmptyState } from './EmptyState';
const RULE_LIST_WIDTH = 310;
const ALWAYS_SCROLL_CLASS = css({
scrollbarGutter: 'stable',
'&::-webkit-scrollbar': {
width: 11,
backgroundColor: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
width: 7,
minHeight: 24,
borderRadius: 30,
backgroundClip: 'padding-box',
border: '2px solid rgba(0, 0, 0, 0)',
backgroundColor: theme.tableBorder,
},
});
type BudgetAutomationsBodyProps = {
categoryId: string;
categoryName: string;
needsMigration: boolean;
initialEntries: AutomationEntry[];
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
month: string;
onClose: () => void;
};
export function BudgetAutomationsBody({
categoryId,
categoryName,
needsMigration,
initialEntries,
schedules,
categories,
month,
onClose,
}: BudgetAutomationsBodyProps) {
const dispatch = useDispatch();
const format = useFormat();
const locale = useLocale();
const [entries, setEntries] = useState<AutomationEntry[]>(initialEntries);
const [activeIdx, setActiveIdx] = useState(0);
const [saving, setSaving] = useState(false);
const [dryRun, setDryRun] = useState<{
budgeted: number;
perTemplate: number[];
} | null>(null);
const onAddAutomation = (create?: () => AutomationEntry) => {
const fallback = getAutomationExamples().find(
e => e.displayType === 'fixed',
);
const entry = (create ?? fallback?.create)?.();
if (!entry) return;
setEntries(prev => {
const next = [...prev, entry];
setActiveIdx(next.length - 1);
return next;
});
};
const onAddLimitAutomation = () => {
const entry = createAutomationEntry(
{
directive: 'template',
type: 'limit',
amount: 500,
period: 'monthly',
hold: false,
priority: null,
},
'limit',
);
setEntries(prev => [entry, ...prev]);
setActiveIdx(0);
};
const onDelete = (index: number) => {
setEntries(prev => {
const next = prev.filter((_, i) => i !== index);
setActiveIdx(currentActive => {
if (next.length === 0) return 0;
if (currentActive >= next.length) return next.length - 1;
if (currentActive > index) return currentActive - 1;
return currentActive;
});
return next;
});
};
const onSave = async () => {
if (saving) return;
setSaving(true);
try {
const templatesToSave = entries.map(({ template }) => template);
await send('budget/set-category-automations', {
categoriesWithTemplates: [
{ id: categoryId, templates: templatesToSave },
],
source: 'ui',
});
onClose();
} finally {
setSaving(false);
}
};
const onUnmigrate = () => {
dispatch(
pushModal({
modal: {
name: 'category-automations-unmigrate',
options: {
categoryId,
templates: entries.map(({ template }) => template),
},
},
}),
);
};
const templates = entries.map(e => e.template);
const validPercentageSources = new Set<string>([
'all income',
'available funds',
]);
for (const group of categories) {
for (const cat of group.categories ?? []) {
if (!cat.is_income) continue;
validPercentageSources.add(cat.id);
if (cat.name) validPercentageSources.add(cat.name.toLowerCase());
}
}
const automationErrors = entries.map(entry =>
validateAutomation(
entry.template,
entry.displayType,
templates,
schedules,
new Date(),
validPercentageSources,
),
);
useEffect(() => {
if (templates.length === 0) {
setDryRun({ budgeted: 0, perTemplate: [] });
return;
}
let cancelled = false;
const run = debounce(async () => {
try {
const result = await send('budget/dry-run-category-template', {
month,
categoryId,
templates,
});
if (!cancelled) setDryRun(result);
} catch {
if (!cancelled) setDryRun(null);
}
}, 200);
void run();
return () => {
cancelled = true;
run.cancel();
};
}, [templates, month, categoryId]);
const totalMonthly = dryRun?.budgeted ?? 0;
const contributions: (number | null)[] = entries.map((_, i) =>
dryRun?.perTemplate?.[i] != null ? dryRun.perTemplate[i] : null,
);
const hasErrors = automationErrors.some(error => error !== null);
const conflict = validatePercentageAllocation(templates);
const categoryNameMap: Record<string, string> = {};
for (const group of categories) {
for (const cat of group.categories ?? []) {
categoryNameMap[cat.id] = cat.name;
}
}
const hasLimitAutomation = entries.some(e => e.displayType === 'limit');
const safeActiveIdx = Math.min(activeIdx, Math.max(0, entries.length - 1));
return (
<View style={{ flex: 1, flexDirection: 'column', minHeight: 0 }}>
<View
style={{
padding: '20px 24px 16px',
borderBottom: `1px solid ${theme.tableBorder}`,
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
}}
>
<View style={{ minWidth: 0 }}>
<Text style={{ fontSize: 12, color: theme.pageTextSubdued }}>
<Trans>Budget automation</Trans>
</Text>
<Text
style={{
fontSize: 20,
fontWeight: 600,
color: theme.pageText,
marginTop: 2,
}}
>
{categoryName}
</Text>
</View>
<View style={{ textAlign: 'right', flexShrink: 0, minWidth: 220 }}>
<Text
style={{
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
letterSpacing: '0.04em',
}}
>
<Trans>
Projected for {{ month: formatMonthLabel(month, locale) }}
</Trans>
</Text>
<Text
style={{
fontSize: 22,
fontWeight: 600,
color: theme.pageTextPositive,
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.2,
display: 'block',
}}
>
{format(totalMonthly, 'financial')}
</Text>
</View>
</View>
{needsMigration && (
<BudgetAutomationMigrationWarning
categoryId={categoryId}
style={{ flexShrink: 0, margin: '12px 24px 0' }}
/>
)}
{conflict && <ConflictBanner conflict={conflict} />}
<View
style={{
flex: 1,
flexDirection: 'row',
minHeight: 0,
}}
>
<View
className={ALWAYS_SCROLL_CLASS}
style={{
width: RULE_LIST_WIDTH,
borderRight: `1px solid ${theme.tableBorder}`,
padding: 10,
overflowY: 'scroll',
}}
>
<View
style={{
flexShrink: 0,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: '6px 8px',
fontSize: 11,
textTransform: 'uppercase',
color: theme.pageTextSubdued,
fontWeight: 600,
letterSpacing: '0.05em',
}}
>
<Text>
<Trans>Automations</Trans>
</Text>
</View>
{entries.map((entry, i) => (
<AutomationListRow
key={entry.id}
index={i}
entry={entry}
isActive={i === safeActiveIdx}
error={automationErrors[i]}
contribution={contributions[i]}
categoryNameMap={categoryNameMap}
onSelect={setActiveIdx}
/>
))}
<Button
variant="bare"
onPress={() => onAddAutomation()}
style={{
width: '100%',
marginTop: 8,
padding: 10,
border: `1px dashed ${theme.tableBorder}`,
borderRadius: 6,
color: theme.pageTextPositive,
fontWeight: 600,
fontSize: 12,
justifyContent: 'center',
}}
>
+ <Trans>Add an automation</Trans>
</Button>
</View>
<View style={{ flex: 1, minWidth: 0 }}>
{entries.length === 0 ? (
<EmptyState onAdd={onAddAutomation} />
) : (
<AutomationEditorPane
entries={entries}
activeIdx={safeActiveIdx}
automationErrors={automationErrors}
schedules={schedules}
categories={categories}
hasLimitAutomation={hasLimitAutomation}
onAddLimitAutomation={onAddLimitAutomation}
setEntries={setEntries}
onDelete={onDelete}
/>
)}
</View>
</View>
<View
style={{
padding: '12px 20px',
borderTop: `1px solid ${theme.tableBorder}`,
flexDirection: 'row',
gap: 8,
alignItems: 'center',
backgroundColor: theme.tableBackground,
flexShrink: 0,
}}
>
{!needsMigration && (
<Link variant="text" onClick={onUnmigrate}>
<Trans>Un-migrate to text notes</Trans>
</Link>
)}
<View style={{ flex: 1 }} />
<Button onPress={onClose}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="primary"
onPress={onSave}
isDisabled={hasErrors || conflict !== null || saving}
>
<Trans>Save</Trans>
</Button>
</View>
</View>
);
}

View File

@@ -1,21 +1,41 @@
import type { Template } from '@actual-app/core/types/models/templates';
import { migrateTemplatesToAutomations } from './BudgetAutomationsModal';
import { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
describe('migrateTemplatesToAutomations', () => {
it('preserves simple templates that have no limit and no monthly amount', () => {
it('drops simple templates that have no limit and no monthly amount', () => {
// these would otherwise be pushed as a phantom 'fixed' entry that
// crashes FixedAutomationReadOnly (no .amount, no .period)
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 5,
} satisfies Template;
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(migrateTemplatesToAutomations([simpleTemplate])).toEqual([]);
});
expect(result).toHaveLength(1);
expect(result[0].displayType).toBe('week');
expect(result[0].template).toEqual(simpleTemplate);
expect(result[0].id).toMatch(/^automation-/);
it('drops simple templates whose monthly amount is zero with no limit', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 5,
monthly: 0,
} satisfies Template;
expect(migrateTemplatesToAutomations([simpleTemplate])).toEqual([]);
});
it('throws when a goal directive reaches migration', () => {
const goalTemplate = {
type: 'goal',
amount: 1000,
directive: 'goal',
} satisfies Template;
expect(() => migrateTemplatesToAutomations([goalTemplate])).toThrow(
/Unsupported template type/,
);
});
it('expands a simple template with limit into limit and refill entries', () => {
@@ -63,7 +83,7 @@ describe('migrateTemplatesToAutomations', () => {
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(1);
expect(result[0].displayType).toBe('week');
expect(result[0].displayType).toBe('fixed');
expect(result[0].template).toMatchObject({
type: 'periodic',
amount: 45,
@@ -79,7 +99,10 @@ describe('migrateTemplatesToAutomations', () => {
});
});
it('expands a simple template with both limit and monthly into three entries in order', () => {
it('expands a simple template with both limit and monthly into limit + periodic (no implicit refill)', () => {
// `#template 20 up to 200 per week` budgets 20/month and caps at the
// limit — the engine's runSimple returns just the monthly value, so
// there is no implicit refill-to-cap behaviour to migrate.
const simpleTemplate = {
type: 'simple',
directive: 'template',
@@ -94,13 +117,9 @@ describe('migrateTemplatesToAutomations', () => {
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(3);
expect(result.map(entry => entry.displayType)).toEqual([
'limit',
'refill',
'week',
]);
expect(result[2].template).toMatchObject({
expect(result).toHaveLength(2);
expect(result.map(entry => entry.displayType)).toEqual(['limit', 'fixed']);
expect(result[1].template).toMatchObject({
type: 'periodic',
amount: 20,
directive: 'template',

View File

@@ -0,0 +1,136 @@
import { useState } from 'react';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { View } from '@actual-app/components/view';
import { currentMonth } from '@actual-app/core/shared/months';
import { q } from '@actual-app/core/shared/query';
import type { Template } from '@actual-app/core/types/models/templates';
import { useBudgetAutomationCategories } from '#components/budget/goals/useBudgetAutomationCategories';
import { Modal } from '#components/common/Modal';
import { useBudgetAutomations } from '#hooks/useBudgetAutomations';
import { useCategory } from '#hooks/useCategory';
import { useNotes } from '#hooks/useNotes';
import { useSchedules } from '#hooks/useSchedules';
import { BudgetAutomationsBody } from './BudgetAutomationsBody';
import { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
import {
hasCleanupLine,
UnsupportedDirectivesNotice,
} from './UnsupportedDirectivesNotice';
const MODAL_WIDTH = 960;
const MODAL_HEIGHT = 760;
export function BudgetAutomationsModal({
categoryId,
month,
}: {
categoryId: string;
month?: string;
}) {
const [parsedTemplates, setParsedTemplates] = useState<Template[] | null>(
null,
);
const effectiveMonth = month ?? currentMonth();
const onLoaded = (result: Record<string, Template[]>) => {
setParsedTemplates(result[categoryId] ?? []);
};
const { loading } = useBudgetAutomations({ categoryId, onLoaded });
const { schedules } = useSchedules({ query: q('schedules').select('*') });
const categories = useBudgetAutomationCategories();
const { data: currentCategory } = useCategory(categoryId);
const notes = useNotes(categoryId);
const needsMigration = currentCategory?.template_settings?.source !== 'ui';
const hasGoalTemplate =
parsedTemplates?.some(t => t.type === 'goal') ?? false;
const hasErrorTemplate =
parsedTemplates?.some(t => t.type === 'error') ?? false;
const hasSpendTemplate =
parsedTemplates?.some(t => t.type === 'spend') ?? false;
// Only surface stale `#cleanup` lines for categories that haven't been
// migrated to UI-managed automations; once `source === 'ui'`, the notes
// are no longer the source of truth.
const hasCleanupDirective = needsMigration && hasCleanupLine(notes);
const hasUnsupportedDirective =
hasGoalTemplate ||
hasErrorTemplate ||
hasSpendTemplate ||
hasCleanupDirective;
const incomeNameToId = new Map<string, string>();
for (const group of categories) {
for (const cat of group.categories ?? []) {
if (cat.name) incomeNameToId.set(cat.name.toLowerCase(), cat.id);
}
}
const resolved = parsedTemplates?.map(t => {
if (t.type !== 'percentage' || !t.category) return t;
const id = incomeNameToId.get(t.category.toLowerCase());
return id ? { ...t, category: id } : t;
});
const initialEntries =
resolved && !hasUnsupportedDirective
? migrateTemplatesToAutomations(resolved)
: null;
return (
<Modal
name="category-automations-edit"
containerProps={{
style: {
width: MODAL_WIDTH,
maxWidth: '95vw',
height: MODAL_HEIGHT,
maxHeight: '90vh',
padding: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
},
}}
>
{({ state }) => (
<View style={{ flex: 1, minHeight: 0 }}>
{loading || parsedTemplates === null ? (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
) : hasUnsupportedDirective ? (
<UnsupportedDirectivesNotice
hasGoalTemplate={hasGoalTemplate}
hasErrorTemplate={hasErrorTemplate}
hasSpendTemplate={hasSpendTemplate}
hasCleanupDirective={hasCleanupDirective}
onClose={() => state.close()}
/>
) : (
<BudgetAutomationsBody
categoryId={categoryId}
categoryName={currentCategory?.name ?? ''}
needsMigration={needsMigration}
initialEntries={initialEntries ?? []}
schedules={schedules}
categories={categories}
month={effectiveMonth}
onClose={() => state.close()}
/>
)}
</View>
)}
</Modal>
);
}

View File

@@ -0,0 +1,39 @@
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
GlobalConflictDetail,
GlobalConflictTitle,
} from '#components/budget/goals/automationMessages';
import type { GlobalConflictKind } from '#components/budget/goals/validateAutomation';
type ConflictBannerProps = {
conflict: GlobalConflictKind;
};
export function ConflictBanner({ conflict }: ConflictBannerProps) {
return (
<View
style={{
padding: '8px 22px',
backgroundColor: theme.errorBackground,
borderBottom: `1px solid ${theme.errorBorder}`,
color: theme.errorText,
fontSize: 12,
flexDirection: 'row',
gap: 8,
alignItems: 'center',
}}
>
<SvgAlertTriangle width={14} height={14} style={{ color: 'inherit' }} />
<Text style={{ color: 'inherit' }}>
<strong>
<GlobalConflictTitle conflict={conflict} />.
</strong>{' '}
<GlobalConflictDetail conflict={conflict} />
</Text>
</View>
);
}

View File

@@ -0,0 +1,138 @@
import { Trans } from 'react-i18next';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { getAutomationExamples } from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
type EmptyStateProps = {
onAdd: (create: () => AutomationEntry) => void;
};
export function EmptyState({ onAdd }: EmptyStateProps) {
const examples = getAutomationExamples();
return (
<View
style={{
padding: '40px 20px',
textAlign: 'center',
maxWidth: 540,
margin: '0 auto',
}}
>
<View
style={{
width: 56,
height: 56,
borderRadius: 12,
margin: '0 auto 14px',
backgroundColor: theme.upcomingBackground,
color: theme.pageTextPositive,
alignItems: 'center',
justifyContent: 'center',
}}
>
<SvgAlertTriangle width={20} height={20} style={{ color: 'inherit' }} />
</View>
<Text
style={{
fontSize: 18,
fontWeight: 600,
color: theme.pageText,
letterSpacing: '-0.01em',
}}
>
<Trans>No automations yet</Trans>
</Text>
<Text
style={{
fontSize: 13,
color: theme.pageTextSubdued,
marginTop: 4,
marginBottom: 22,
display: 'block',
}}
>
<Trans>
Budget automations keep this category funded with one click each
month. Start with one of these.
</Trans>
</Text>
<View
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: 10,
textAlign: 'center',
}}
>
{examples.map(example => {
const meta = getDisplayTemplateMeta(example.displayType);
const Icon = meta.icon;
return (
<View
key={example.displayType}
role="button"
tabIndex={0}
aria-label={meta.label}
onClick={() => onAdd(example.create)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onAdd(example.create);
}
}}
style={{
padding: 14,
borderRadius: 8,
backgroundColor: theme.cardBackground,
border: `1px solid ${theme.tableBorder}`,
gap: 6,
cursor: 'pointer',
}}
>
<View
style={{
width: 30,
height: 30,
borderRadius: 6,
backgroundColor: theme.upcomingBackground,
color: theme.pageTextPositive,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
marginBottom: 6,
}}
>
<Icon width={16} height={16} />
</View>
<Text
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageText,
}}
>
{meta.label}
</Text>
<Text
style={{
fontSize: 11,
color: theme.pageTextSubdued,
lineHeight: 1.4,
}}
>
{meta.description}
</Text>
</View>
);
})}
</View>
</View>
);
}

View File

@@ -0,0 +1,109 @@
import { useTranslation } from 'react-i18next';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { displayTemplateTypes } from '#components/budget/goals/constants';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
type TypePickerProps = {
active: DisplayTemplateType;
disabledTypes: ReadonlySet<DisplayTemplateType>;
onPick: (type: DisplayTemplateType) => void;
};
export function TypePicker({ active, disabledTypes, onPick }: TypePickerProps) {
const { t } = useTranslation();
const entries = displayTemplateTypes.map(
id => [id, getDisplayTemplateMeta(id)] as const,
);
const disabledHint = t('Only one of this type allowed per category');
return (
<View
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 8,
}}
>
{entries.map(([id, meta]) => {
const Icon = meta.icon;
const isActive = id === active;
const isDisabled = !isActive && disabledTypes.has(id);
return (
<View
key={id}
role="button"
tabIndex={isDisabled ? -1 : 0}
aria-pressed={isActive}
aria-disabled={isDisabled}
title={isDisabled ? disabledHint : undefined}
onClick={() => {
if (!isDisabled) onPick(id);
}}
onKeyDown={e => {
if (isDisabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onPick(id);
}
}}
style={{
padding: '10px 10px 8px',
borderRadius: 6,
backgroundColor: isActive
? theme.upcomingBackground
: theme.cardBackground,
border: `1px solid ${isActive ? theme.pageTextPositive : theme.tableBorder}`,
gap: 6,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.45 : 1,
minWidth: 0,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<Icon
width={16}
height={16}
style={{
flexShrink: 0,
color: isActive ? theme.pageTextPositive : theme.pageText,
}}
/>
<Text
style={{
display: 'block',
fontSize: 12,
fontWeight: 600,
color: isActive ? theme.pageTextPositive : theme.pageText,
lineHeight: 1.25,
}}
>
{meta.label}
</Text>
</View>
<Text
style={{
display: 'block',
fontSize: 11,
color: theme.pageTextSubdued,
lineHeight: 1.35,
}}
>
{meta.description}
</Text>
</View>
);
})}
</View>
);
}

View File

@@ -0,0 +1,100 @@
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
export function UnsupportedDirectivesNotice({
hasGoalTemplate,
hasErrorTemplate,
hasSpendTemplate,
hasCleanupDirective,
onClose,
}: {
hasGoalTemplate: boolean;
hasErrorTemplate: boolean;
hasSpendTemplate: boolean;
hasCleanupDirective: boolean;
onClose: () => void;
}) {
return (
<View
style={{
flex: 1,
padding: 32,
gap: 16,
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
}}
>
<SvgAlertTriangle
width={32}
height={32}
style={{ color: theme.errorText }}
/>
<Text
style={{
fontSize: 18,
fontWeight: 600,
color: theme.pageText,
}}
>
<Trans>This category isn&rsquo;t supported in the UI yet</Trans>
</Text>
<Text
style={{
fontSize: 13,
color: theme.pageTextSubdued,
maxWidth: 480,
lineHeight: 1.5,
}}
>
{hasErrorTemplate ? (
<Trans>
One or more <code>#template</code> lines in this category&rsquo;s
notes couldn&rsquo;t be parsed. Fix them as text first, then re-open
this modal to migrate.
</Trans>
) : hasSpendTemplate ? (
<Trans>
This category uses a <code>spend from</code> template, which the
budget automations UI doesn&rsquo;t handle yet. Keep editing it as
text in the category&rsquo;s notes.
</Trans>
) : hasGoalTemplate && hasCleanupDirective ? (
<Trans>
This category&rsquo;s notes use <code>#goal</code> and{' '}
<code>#cleanup</code> directives, neither of which the budget
automations UI handles yet. Keep editing them as text in the
category&rsquo;s notes.
</Trans>
) : hasGoalTemplate ? (
<Trans>
This category uses a <code>#goal</code> directive, which the budget
automations UI doesn&rsquo;t handle yet. Keep editing it as text in
the category&rsquo;s notes.
</Trans>
) : (
<Trans>
This category uses a <code>#cleanup</code> directive, which the
budget automations UI doesn&rsquo;t handle yet. Keep editing it as
text in the category&rsquo;s notes.
</Trans>
)}
</Text>
<Button onPress={onClose}>
<Trans>Close</Trans>
</Button>
</View>
);
}
const CLEANUP_DIRECTIVE = /^\s*#cleanup\b/;
export function hasCleanupLine(notes: string | null | undefined): boolean {
if (!notes) return false;
return notes.split('\n').some(line => CLEANUP_DIRECTIVE.test(line));
}

View File

@@ -0,0 +1,2 @@
export { BudgetAutomationsModal } from './BudgetAutomationsModal';
export { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';

View File

@@ -0,0 +1,111 @@
import { dayFromDate, firstDayOfMonth } from '@actual-app/core/shared/months';
import type { Template } from '@actual-app/core/types/models/templates';
import { createAutomationEntry } from '#components/budget/goals/automationExamples';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
import type { DisplayTemplateType } from '#components/budget/goals/constants';
function getDisplayTypeFromTemplate(template: Template): DisplayTemplateType {
switch (template.type) {
case 'percentage':
return 'percentage';
case 'schedule':
return 'schedule';
case 'periodic':
case 'simple':
return 'fixed';
case 'limit':
return 'limit';
case 'refill':
return 'refill';
case 'average':
case 'copy':
return 'historical';
case 'by':
return 'by';
case 'remainder':
return 'remainder';
case 'goal':
case 'error':
case 'spend':
// filtered upstream by hasUnsupportedDirective; surface if it ever isn't
throw new Error(`Unsupported template type reached migration`);
default: {
const _exhaustive: never = template;
void _exhaustive;
throw new Error(`Unhandled template type`);
}
}
}
export function migrateTemplatesToAutomations(
templates: Template[],
): AutomationEntry[] {
const entries: AutomationEntry[] = [];
templates.forEach(template => {
if (template.type === 'simple') {
const monthly = template.monthly;
const hasMonthly = monthly != null && monthly !== 0;
if (template.limit) {
entries.push(
createAutomationEntry(
{
type: 'limit',
amount: template.limit.amount,
hold: template.limit.hold,
period: template.limit.period,
start: template.limit.start,
directive: 'template',
priority: null,
},
'limit',
),
);
// The implicit refill only applies to a limit-only simple template
// (e.g. `#template up to 200`). When a monthly amount is also set
// (`#template 50 up to 200`), the engine just budgets the monthly
// amount and clamps to the cap — no top-up to the limit.
if (!hasMonthly) {
entries.push(
createAutomationEntry(
{
type: 'refill',
directive: 'template',
priority: template.priority,
},
'refill',
),
);
}
}
if (hasMonthly) {
entries.push(
createAutomationEntry(
{
type: 'periodic',
amount: monthly,
period: { period: 'month', amount: 1 },
starting: dayFromDate(firstDayOfMonth(new Date())),
directive: 'template',
priority: template.priority,
},
'fixed',
),
);
}
// a simple template with neither monthly nor limit is a no-op; drop it
// rather than passing through as a phantom 'fixed' entry that would
// crash FixedAutomationReadOnly (no .amount, no .period)
return;
}
entries.push(
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
);
});
return entries;
}

View File

@@ -73,21 +73,6 @@ export function ConfirmTransactionEditModal({
out of balance.
</Trans>
</Block>
) : confirmReason === 'batchDuplicateWithReconciledTransfer' ? (
<Block>
<Trans>
This transfer has a linked transaction in another account that
is reconciled. Duplicating it may bring that account's
reconciliation out of balance.
</Trans>
</Block>
) : confirmReason === 'batchDuplicateWithReconciled' ? (
<Block>
<Trans>
Duplicating reconciled transactions may bring your
reconciliation out of balance.
</Trans>
</Block>
) : confirmReason === 'editReconciled' ? (
<Block>
<Trans>

View File

@@ -1,32 +1,20 @@
import React, { useEffect, useState } from 'react';
import { Dialog, DialogTrigger } from 'react-aria-components';
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button, ButtonWithLoading } from '@actual-app/components/button';
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
import { Button } from '@actual-app/components/button';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { Menu } from '@actual-app/components/menu';
import { Paragraph } from '@actual-app/components/paragraph';
import { Popover } from '@actual-app/components/popover';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from '@actual-app/core/platform/client/connection';
import { useAuth } from '#auth/AuthProvider';
import { Permissions } from '#auth/types';
import { Warning } from '#components/alerts';
import { BuiltInProviders } from '#components/banksync/BuiltInProviders';
import { useBuiltInBankSyncProviders } from '#components/banksync/useBuiltInBankSyncProviders';
import { Link } from '#components/common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
import { useMultiuserEnabled } from '#components/ServerContext';
import { authorizeBank } from '#gocardless';
import { useGoCardlessStatus } from '#hooks/useGoCardlessStatus';
import { usePluggyAiStatus } from '#hooks/usePluggyAiStatus';
import { useSimpleFinStatus } from '#hooks/useSimpleFinStatus';
import { useSyncServerStatus } from '#hooks/useSyncServerStatus';
import { useNavigate } from '#hooks/useNavigate';
import { pushModal } from '#modals/modalsSlice';
import type { Modal as ModalType } from '#modals/modalsSlice';
import { addNotification } from '#notifications/notificationsSlice';
import { useDispatch } from '#redux';
type CreateAccountModalProps = Extract<
@@ -38,296 +26,25 @@ export function CreateAccountModal({
upgradingAccountId,
}: CreateAccountModalProps) {
const { t } = useTranslation();
const syncServerStatus = useSyncServerStatus();
const dispatch = useDispatch();
const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState<
boolean | null
>(null);
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] = useState<
boolean | null
>(null);
const [isPluggyAiSetupComplete, setIsPluggyAiSetupComplete] = useState<
boolean | null
>(null);
const { hasPermission } = useAuth();
const multiuserEnabled = useMultiuserEnabled();
const onConnectGoCardless = () => {
if (!isGoCardlessSetupComplete) {
onGoCardlessInit();
return;
}
if (upgradingAccountId == null) {
void authorizeBank(dispatch);
} else {
void authorizeBank(dispatch);
}
};
const onConnectSimpleFin = async () => {
if (!isSimpleFinSetupComplete) {
onSimpleFinInit();
return;
}
if (loadingSimpleFinAccounts) {
return;
}
setLoadingSimpleFinAccounts(true);
try {
const results = await send('simplefin-accounts');
if (results.error_code) {
throw new Error(results.reason);
}
const newAccounts = [];
type NormalizedAccount = {
account_id: string;
name: string;
institution: string;
orgDomain: string;
orgId: string;
balance: number;
};
for (const oldAccount of results.accounts ?? []) {
const newAccount: NormalizedAccount = {
account_id: oldAccount.id,
name: oldAccount.name,
institution: oldAccount.org.name,
orgDomain: oldAccount.org.domain,
orgId: oldAccount.org.id,
balance: oldAccount.balance,
};
newAccounts.push(newAccount);
}
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts: newAccounts,
syncSource: 'simpleFin',
},
},
}),
);
} catch (err) {
console.error(err);
dispatch(
pushModal({
modal: {
name: 'simplefin-init',
options: {
onSuccess: () => setIsSimpleFinSetupComplete(true),
},
},
}),
);
}
setLoadingSimpleFinAccounts(false);
};
const onConnectPluggyAi = async () => {
if (!isPluggyAiSetupComplete) {
onPluggyAiInit();
return;
}
try {
const results = await send('pluggyai-accounts');
if (results.error_code) {
throw new Error(results.reason);
} else if ('error' in results) {
throw new Error(results.error);
}
const newAccounts = [];
type NormalizedAccount = {
account_id: string;
name: string;
institution: string;
orgDomain: string | null;
orgId: string;
balance: number;
};
for (const oldAccount of results.accounts) {
const newAccount: NormalizedAccount = {
account_id: oldAccount.id,
name: `${oldAccount.name.trim()} - ${oldAccount.type === 'BANK' ? oldAccount.taxNumber : oldAccount.owner}`,
institution: oldAccount.name,
orgDomain: null,
orgId: oldAccount.id,
balance:
oldAccount.type === 'BANK'
? oldAccount.bankData.automaticallyInvestedBalance +
oldAccount.bankData.closingBalance
: oldAccount.balance,
};
newAccounts.push(newAccount);
}
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts: newAccounts,
syncSource: 'pluggyai',
},
},
}),
);
} catch (err) {
console.error(err);
addNotification({
notification: {
type: 'error',
title: t('Error when trying to contact Pluggy.ai'),
message: (err as Error).message,
timeout: 5000,
},
});
dispatch(
pushModal({
modal: {
name: 'pluggyai-init',
options: {
onSuccess: () => setIsPluggyAiSetupComplete(true),
},
},
}),
);
}
};
const onGoCardlessInit = () => {
dispatch(
pushModal({
modal: {
name: 'gocardless-init',
options: {
onSuccess: () => setIsGoCardlessSetupComplete(true),
},
},
}),
);
};
const onSimpleFinInit = () => {
dispatch(
pushModal({
modal: {
name: 'simplefin-init',
options: {
onSuccess: () => setIsSimpleFinSetupComplete(true),
},
},
}),
);
};
const onPluggyAiInit = () => {
dispatch(
pushModal({
modal: {
name: 'pluggyai-init',
options: {
onSuccess: () => setIsPluggyAiSetupComplete(true),
},
},
}),
);
};
const onGoCardlessReset = () => {
void send('secret-set', {
name: 'gocardless_secretId',
value: null,
}).then(() => {
void send('secret-set', {
name: 'gocardless_secretKey',
value: null,
}).then(() => {
setIsGoCardlessSetupComplete(false);
});
});
};
const onSimpleFinReset = () => {
void send('secret-set', {
name: 'simplefin_token',
value: null,
}).then(() => {
void send('secret-set', {
name: 'simplefin_accessKey',
value: null,
}).then(() => {
setIsSimpleFinSetupComplete(false);
});
});
};
const onPluggyAiReset = () => {
void send('secret-set', {
name: 'pluggyai_clientId',
value: null,
}).then(() => {
void send('secret-set', {
name: 'pluggyai_clientSecret',
value: null,
}).then(() => {
void send('secret-set', {
name: 'pluggyai_itemIds',
value: null,
}).then(() => {
setIsPluggyAiSetupComplete(false);
});
});
});
};
const navigate = useNavigate();
const {
providers,
syncServerStatus,
showPermissionWarning,
providersNeedingConfiguration,
} = useBuiltInBankSyncProviders({ upgradingAccountId });
const onCreateLocalAccount = () => {
dispatch(pushModal({ modal: { name: 'add-local-account' } }));
};
const { configuredGoCardless } = useGoCardlessStatus();
useEffect(() => {
setIsGoCardlessSetupComplete(configuredGoCardless);
}, [configuredGoCardless]);
const { configuredSimpleFin } = useSimpleFinStatus();
useEffect(() => {
setIsSimpleFinSetupComplete(configuredSimpleFin);
}, [configuredSimpleFin]);
const { configuredPluggyAi } = usePluggyAiStatus();
useEffect(() => {
setIsPluggyAiSetupComplete(configuredPluggyAi);
}, [configuredPluggyAi]);
let title = t('Add account');
const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] =
useState(false);
if (upgradingAccountId != null) {
title = t('Link account');
}
const canSetSecrets =
!multiuserEnabled || hasPermission(Permissions.ADMINISTRATOR);
return (
<Modal name="add-account">
{({ state }) => (
@@ -336,266 +53,69 @@ export function CreateAccountModal({
title={title}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}>
{upgradingAccountId == null && (
<View style={{ gap: 10 }}>
<InitialFocus>
<Button
variant="primary"
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
}}
onPress={onCreateLocalAccount}
>
<Trans>Create a local account</Trans>
</Button>
</InitialFocus>
<View style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Text>
<Trans>
<strong>Create a local account</strong> if you want to add
transactions manually. You can also{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/transactions/importing"
linkColor="muted"
>
import QIF/OFX/QFX files into a local account
</Link>
.
</Trans>
</Text>
</View>
</View>
)}
<View style={{ gap: 10 }}>
{syncServerStatus === 'online' ? (
<>
{canSetSecrets && (
<>
<View
style={{
flexDirection: 'row',
gap: 10,
alignItems: 'center',
}}
>
<ButtonWithLoading
isDisabled={syncServerStatus !== 'online'}
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
flex: 1,
}}
onPress={onConnectGoCardless}
<View
style={{
maxWidth: upgradingAccountId == null ? 500 : 720,
gap: 24,
color: theme.pageText,
}}
>
{upgradingAccountId != null ? (
<>
<Paragraph
style={{ fontSize: 15, color: theme.pageTextSubdued }}
>
<Trans>
Choose a bank sync provider to connect this account.
</Trans>
</Paragraph>
<BuiltInProviders
providers={providers}
syncServerStatus={syncServerStatus}
showPermissionWarning={showPermissionWarning}
providersNeedingConfiguration={providersNeedingConfiguration}
/>
</>
) : (
<>
<View style={{ gap: 10 }}>
<InitialFocus>
<Button
variant="primary"
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
}}
onPress={onCreateLocalAccount}
>
<Trans>Create a local account</Trans>
</Button>
</InitialFocus>
<View style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Text>
<Trans>
<strong>Create a local account</strong> if you want to
add transactions manually. You can also{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/transactions/importing"
linkColor="muted"
>
{isGoCardlessSetupComplete
? t('Link bank account with GoCardless')
: t('Set up GoCardless for bank sync')}
</ButtonWithLoading>
{isGoCardlessSetupComplete && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('GoCardless menu')}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Dialog>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onGoCardlessReset();
}
}}
items={[
{
name: 'reconfigure',
text: t('Reset GoCardless credentials'),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Trans>
<strong>
Link a <em>European</em> bank account
</strong>{' '}
to automatically download transactions. GoCardless
provides reliable, up-to-date information from
hundreds of banks.
</Trans>
</Text>
<View
style={{
flexDirection: 'row',
gap: 10,
marginTop: '18px',
alignItems: 'center',
}}
>
<ButtonWithLoading
isDisabled={syncServerStatus !== 'online'}
isLoading={loadingSimpleFinAccounts}
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
flex: 1,
}}
onPress={onConnectSimpleFin}
>
{isSimpleFinSetupComplete
? t('Link bank account with SimpleFIN')
: t('Set up SimpleFIN for bank sync')}
</ButtonWithLoading>
{isSimpleFinSetupComplete && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('SimpleFIN menu')}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Dialog>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onSimpleFinReset();
}
}}
items={[
{
name: 'reconfigure',
text: t('Reset SimpleFIN credentials'),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Trans>
<strong>
Link a <em>North American</em> bank account
</strong>{' '}
to automatically download transactions. SimpleFIN
provides reliable, up-to-date information from
hundreds of banks.
</Trans>
</Text>
<View
style={{
flexDirection: 'row',
gap: 10,
alignItems: 'center',
}}
>
<ButtonWithLoading
isDisabled={syncServerStatus !== 'online'}
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
flex: 1,
}}
onPress={onConnectPluggyAi}
>
{isPluggyAiSetupComplete
? t('Link bank account with Pluggy.ai')
: t('Set up Pluggy.ai for bank sync')}
</ButtonWithLoading>
{isPluggyAiSetupComplete && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('Pluggy.ai menu')}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Dialog>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onPluggyAiReset();
}
}}
items={[
{
name: 'reconfigure',
text: t('Reset Pluggy.ai credentials'),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Trans>
<strong>
Link a <em>Brazilian</em> bank account
</strong>{' '}
to automatically download transactions. Pluggy.ai
provides reliable, up-to-date information from
hundreds of banks.
</Trans>
</Text>
</>
)}
{(!isGoCardlessSetupComplete ||
!isSimpleFinSetupComplete ||
!isPluggyAiSetupComplete) &&
!canSetSecrets && (
<Warning>
<Trans>
You don&apos;t have the required permissions to set up
secrets. Please contact an Admin to configure
</Trans>{' '}
{[
isGoCardlessSetupComplete ? '' : 'GoCardless',
isSimpleFinSetupComplete ? '' : 'SimpleFIN',
isPluggyAiSetupComplete ? '' : 'Pluggy.ai',
]
.filter(Boolean)
.join(' or ')}
import QIF/OFX/QFX files into a local account
</Link>
.
</Warning>
)}
</>
) : (
<>
</Trans>
</Text>
</View>
</View>
<View style={{ gap: 10 }}>
<Button
isDisabled
onPress={() => {
state.close();
void navigate('/bank-sync');
}}
style={{
padding: '10px 0',
fontSize: 15,
@@ -604,22 +124,17 @@ export function CreateAccountModal({
>
<Trans>Set up bank sync</Trans>
</Button>
<Paragraph style={{ fontSize: 15 }}>
<Paragraph
style={{ fontSize: 15, color: theme.pageTextSubdued }}
>
<Trans>
Connect to an Actual server to set up{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/advanced/bank-sync"
linkColor="muted"
>
automatic syncing
</Link>
.
Configure providers and link accounts from the Bank Sync
page.
</Trans>
</Paragraph>
</>
)}
</View>
</View>
</>
)}
</View>
</>
)}

View File

@@ -74,22 +74,26 @@ export type SelectLinkedAccountsModalProps =
requisitionId: string;
externalAccounts: SyncServerGoCardlessAccount[];
syncSource: 'goCardless';
upgradingAccountId?: string;
}
| {
requisitionId?: undefined;
externalAccounts: SyncServerSimpleFinAccount[];
syncSource: 'simpleFin';
upgradingAccountId?: string;
}
| {
requisitionId?: undefined;
externalAccounts: SyncServerPluggyAiAccount[];
syncSource: 'pluggyai';
upgradingAccountId?: string;
};
export function SelectLinkedAccountsModal({
requisitionId = undefined,
externalAccounts,
syncSource,
upgradingAccountId,
}: SelectLinkedAccountsModalProps) {
const propsWithSortedExternalAccounts =
useMemo<SelectLinkedAccountsModalProps>(() => {
@@ -104,22 +108,25 @@ export function SelectLinkedAccountsModal({
return {
syncSource: 'simpleFin',
externalAccounts: toSort as SyncServerSimpleFinAccount[],
upgradingAccountId,
};
case 'pluggyai':
return {
syncSource: 'pluggyai',
externalAccounts: toSort as SyncServerPluggyAiAccount[],
upgradingAccountId,
};
case 'goCardless':
return {
syncSource: 'goCardless',
requisitionId: requisitionId!,
externalAccounts: toSort as SyncServerGoCardlessAccount[],
upgradingAccountId,
};
default:
throw new Error(`Unrecognized sync source: ${String(syncSource)}`);
}
}, [externalAccounts, syncSource, requisitionId]);
}, [externalAccounts, syncSource, requisitionId, upgradingAccountId]);
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
@@ -140,11 +147,27 @@ export function SelectLinkedAccountsModal({
});
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
() => {
return Object.fromEntries(
const initiallyChosenAccounts = Object.fromEntries(
localAccounts
.filter(acc => acc.account_id)
.map(acc => [acc.account_id, acc.id]),
);
const preselectedExternalAccount =
propsWithSortedExternalAccounts.externalAccounts.find(
account => initiallyChosenAccounts[account.account_id] == null,
);
if (
upgradingAccountId &&
preselectedExternalAccount &&
!Object.values(initiallyChosenAccounts).includes(upgradingAccountId)
) {
initiallyChosenAccounts[preselectedExternalAccount.account_id] =
upgradingAccountId;
}
return initiallyChosenAccounts;
},
);
const [customStartingDates, setCustomStartingDates] = useState<

View File

@@ -42,6 +42,7 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyUntilYearEnd,
onEditNotes,
month,
}: TrackingBudgetMenuModalProps) {
@@ -200,6 +201,7 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
onCopyUntilYearEnd={onCopyUntilYearEnd}
/>
)}
</>

View File

@@ -11,9 +11,26 @@ import type { Template } from '@actual-app/core/types/models/templates';
import { Link } from '#components/common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
import { Notes } from '#components/Notes';
import { useCategories } from '#hooks/useCategories';
import { useCategory } from '#hooks/useCategory';
import { useNotes } from '#hooks/useNotes';
// The UI's CategoryAutocomplete stores the income category id on a
// percentage template, but text-template grammar addresses categories by
// name. Rewrite percentage templates so the un-migrated notes are readable
// (and don't drift if the category is later renamed).
function sanitizePercentageCategoriesForNotes(
templates: Template[],
idToName: Map<string, string>,
): Template[] {
return templates.map(template => {
if (template.type !== 'percentage') return template;
const name = idToName.get(template.category);
if (name) return { ...template, category: name };
return template;
});
}
export function UnmigrateBudgetAutomationsModal({
categoryId,
templates,
@@ -23,6 +40,7 @@ export function UnmigrateBudgetAutomationsModal({
}) {
const { t } = useTranslation();
const { data: category } = useCategory(categoryId);
const { data: categoryData } = useCategories();
const existingNotes = useNotes(categoryId) || '';
const [editedNotes, setEditedNotes] = useState<string>('');
@@ -30,12 +48,18 @@ export function UnmigrateBudgetAutomationsModal({
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!categoryData?.list) return;
const idToName = new Map<string, string>();
for (const cat of categoryData.list) {
idToName.set(cat.id, cat.name);
}
const sanitized = sanitizePercentageCategoriesForNotes(templates, idToName);
let mounted = true;
void (async () => {
try {
const text: string = await send(
'budget/render-note-templates',
templates,
sanitized,
);
if (mounted) setRendered(text);
} catch {
@@ -45,7 +69,7 @@ export function UnmigrateBudgetAutomationsModal({
return () => {
mounted = false;
};
}, [templates]);
}, [templates, categoryData]);
// Seed editable notes once templates rendered
useEffect(() => {
@@ -87,13 +111,21 @@ export function UnmigrateBudgetAutomationsModal({
async function onSave(close: () => void) {
setSaving(true);
await send('notes-save-undoable', { id: categoryId, note: editedNotes });
await send('budget/set-category-automations', {
categoriesWithTemplates: [{ id: categoryId, templates }],
source: 'notes',
});
setSaving(false);
close();
try {
await send('notes-save-undoable', { id: categoryId, note: editedNotes });
// Hand control back to the notes parser: clear the UI-managed goal_def
// and mark notes as the source of truth. `storeNoteTemplates` will
// re-derive goal_def from the notes the next time it runs (e.g. on
// modal open or when applying templates).
await send('budget/set-category-automations', {
categoriesWithTemplates: [{ id: categoryId, templates: [] }],
source: 'notes',
});
await send('budget/store-note-templates');
close();
} finally {
setSaving(false);
}
}
return (

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { calculateSpendingReportTimeRange } from './reportRanges';
// In test mode, monthUtils.currentMonth() returns '2017-01'
describe('calculateSpendingReportTimeRange', () => {
it('preserves the saved compare month for live average reports', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
compare: '2016-12',
isLive: true,
mode: 'average',
});
expect(compare).toBe('2016-12');
expect(compareTo).toBe('2016-12');
});
it('preserves the saved compare month for live budget reports', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
compare: '2016-12',
isLive: true,
mode: 'budget',
});
expect(compare).toBe('2016-12');
expect(compareTo).toBe('2016-12');
});
it('preserves the saved compare months for live single month reports', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
compare: '2016-12',
compareTo: '2016-11',
isLive: true,
mode: 'single-month',
});
expect(compare).toBe('2016-12');
expect(compareTo).toBe('2016-11');
});
it('defaults live average reports to the current month without a saved compare month', () => {
const [compare, compareTo] = calculateSpendingReportTimeRange({
isLive: true,
mode: 'average',
});
expect(compare).toBe('2017-01');
expect(compareTo).toBe('2017-01');
});
});

View File

@@ -249,7 +249,12 @@ export function calculateSpendingReportTimeRange({
mode?: 'budget' | 'average' | 'single-month';
}): [string, string] {
if (['budget', 'average'].includes(mode) && isLive) {
return [monthUtils.currentMonth(), monthUtils.currentMonth()];
const month = compare ?? monthUtils.currentMonth();
return [month, month];
}
if (mode === 'single-month' && isLive && compare) {
return [compare, compareTo ?? monthUtils.subMonths(compare, 1)];
}
const [start, end] = calculateTimeRange(

Some files were not shown because too many files have changed in this diff Show More