Compare commits
54 Commits
v26.5.2
...
ai/stabili
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac2f04c34b | ||
|
|
0b2dba60cd | ||
|
|
f624ae9701 | ||
|
|
732a6a107b | ||
|
|
3104503a8a | ||
|
|
db38565524 | ||
|
|
9e9cf45641 | ||
|
|
8ab8277429 | ||
|
|
d9fb66422b | ||
|
|
345c99be4d | ||
|
|
e3b42b51a3 | ||
|
|
bc08ed97e9 | ||
|
|
d2b50adf30 | ||
|
|
1f4f706c4a | ||
|
|
852b95524b | ||
|
|
6fb73786d5 | ||
|
|
e3952d2a24 | ||
|
|
fea36466d2 | ||
|
|
1fadfa4e9b | ||
|
|
6ead7ea42c | ||
|
|
d6fc3212b9 | ||
|
|
071611fcc5 | ||
|
|
263358b5cf | ||
|
|
44fc959ed8 | ||
|
|
d787d0ce43 | ||
|
|
2c3e2a34fd | ||
|
|
78d533c800 | ||
|
|
49f6b21f2c | ||
|
|
9f05207fe8 | ||
|
|
8366c442a2 | ||
|
|
4b73fd7e45 | ||
|
|
c593bda145 | ||
|
|
1b86bba2cd | ||
|
|
6c2c96e826 | ||
|
|
6298f6a324 | ||
|
|
1afe7c9a1e | ||
|
|
24279264da | ||
|
|
4a5ee9c2dc | ||
|
|
a8eb204ce7 | ||
|
|
f68e4fbb2a | ||
|
|
dd3b1144d1 | ||
|
|
ff0f5bdb35 | ||
|
|
11ce29e7fd | ||
|
|
d58c9a9a07 | ||
|
|
598e3ec9d8 | ||
|
|
c2987af64f | ||
|
|
c7d39961cf | ||
|
|
a42b7c5777 | ||
|
|
33af9bf906 | ||
|
|
46687da7a8 | ||
|
|
3928d5b2a8 | ||
|
|
8b29ee40a7 | ||
|
|
9acbd6388b | ||
|
|
77f0a3e58b |
@@ -1,6 +1,6 @@
|
||||
issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: false
|
||||
enabled: true
|
||||
reviews:
|
||||
request_changes_workflow: true
|
||||
review_status: false
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,9 +3,6 @@ contact_links:
|
||||
- name: Bank-sync issues
|
||||
url: https://discord.gg/pRYNYr4W5A
|
||||
about: Is bank-sync not working? Returning too much or too few information? Reach out to the community on Discord.
|
||||
- name: Support
|
||||
url: https://discord.gg/pRYNYr4W5A
|
||||
about: Need help with something? Having troubles setting up? Or perhaps issues using the API? Reach out to the community on Discord.
|
||||
- name: Translations
|
||||
url: https://hosted.weblate.org/projects/actualbudget/actual/
|
||||
about: Found a string that needs a better translation? Add your suggestion or upvote an existing one in Weblate.
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/tech-support.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Tech Support
|
||||
description: Need help with something? Having troubles setting up? Or perhaps issues using the API?
|
||||
title: '[Support]: '
|
||||
labels: ['tech-support']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> ⚠️ **Tech support tickets opened here are automatically closed.** GitHub Issues are reserved for bug reports and feature requests. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Describe your problem
|
||||
description: Please describe, in as much detail as you can, what you need help with.
|
||||
placeholder: I'm trying to [...] but [...]
|
||||
validations:
|
||||
required: true
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
|
||||
|
||||
|
||||
3
.github/actions/docs-spelling/expect.txt
vendored
@@ -146,8 +146,6 @@ pluggyai
|
||||
Poste
|
||||
PPABPLPK
|
||||
prefs
|
||||
Primoco
|
||||
Priotecs
|
||||
proactively
|
||||
Qatari
|
||||
QNTOFRP
|
||||
@@ -179,7 +177,6 @@ SWEDBANK
|
||||
SWEDNOKK
|
||||
Synology
|
||||
systemctl
|
||||
tada
|
||||
taskbar
|
||||
templating
|
||||
THB
|
||||
|
||||
1
.github/workflows/cut-release-branch.yml
vendored
@@ -26,6 +26,7 @@ permissions:
|
||||
jobs:
|
||||
cut-release-branch:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
1
.github/workflows/docker-edge.yml
vendored
@@ -32,6 +32,7 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu, alpine]
|
||||
|
||||
1
.github/workflows/docker-release.yml
vendored
@@ -27,6 +27,7 @@ jobs:
|
||||
build:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
||||
9
.github/workflows/e2e-test.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/electron-master.yml
vendored
@@ -21,6 +21,7 @@ jobs:
|
||||
# this is so the assets can be added to the release
|
||||
permissions:
|
||||
contents: write
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -123,6 +124,7 @@ jobs:
|
||||
publish-microsoft-store:
|
||||
needs: build
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
steps:
|
||||
- name: Install StoreBroker
|
||||
|
||||
23
.github/workflows/issues-close-tech-support.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Close tech support issues with automated message
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
tech-support:
|
||||
if: ${{ github.event.label.name == 'tech-support' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create comment and close issue
|
||||
run: |
|
||||
gh issue comment "$ISSUE_URL" --body ":wave: Thanks for reaching out!
|
||||
|
||||
GitHub Issues are reserved for bug reports and feature requests, so tech support tickets are automatically closed. The fastest way to get help is to ask the community on [Discord](https://discord.gg/pRYNYr4W5A) — that's where most of the community lives and can help you in real time.
|
||||
|
||||
<!-- tech-support-auto-close-comment -->"
|
||||
|
||||
gh issue close "$ISSUE_URL"
|
||||
env:
|
||||
ISSUE_URL: https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
1
.github/workflows/netlify-release.yml
vendored
@@ -19,6 +19,7 @@ concurrency:
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
1
.github/workflows/publish-flathub.yml
vendored
@@ -21,6 +21,7 @@ concurrency:
|
||||
jobs:
|
||||
publish-flathub:
|
||||
runs-on: ubuntu-22.04
|
||||
environment: release
|
||||
steps:
|
||||
- name: Resolve version
|
||||
id: resolve_version
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: release
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
1
.github/workflows/publish-npm-packages.yml
vendored
@@ -87,6 +87,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish npm packages
|
||||
needs: build-and-pack
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
145
.github/workflows/size-compare.yml
vendored
@@ -33,6 +33,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -44,140 +45,120 @@ jobs:
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Wait for ${{github.base_ref}} web build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-web-build
|
||||
# Resolve one successful `build.yml` run for each side (master and PR
|
||||
# head) up front, then pin every download below to its `run_id`. This
|
||||
# ensures artifact downloads are consistent and prevents race conditions.
|
||||
- name: Resolve build runs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
id: build-runs
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{github.base_ref}} API build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-api-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{github.base_ref}} CLI build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-cli-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{github.base_ref}} CRDT build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-crdt-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: crdt
|
||||
ref: ${{github.base_ref}}
|
||||
script: |
|
||||
const TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const SLEEP_MS = 15000;
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-web-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: Wait for API PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-api-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: Wait for CLI PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-cli-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: Wait for CRDT PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-crdt-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: crdt
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
async function resolveRun({ label, filter, notFoundHint }) {
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
while (true) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'build.yml',
|
||||
...filter,
|
||||
status: 'success',
|
||||
per_page: 1,
|
||||
});
|
||||
if (data.workflow_runs.length > 0) {
|
||||
const run = data.workflow_runs[0];
|
||||
core.info(`Found ${label} build run ${run.id} (${run.html_url})`);
|
||||
return run.id;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`No successful build.yml run found for ${label} within 30 min — ${notFoundHint}.`,
|
||||
);
|
||||
}
|
||||
core.info(`No successful ${label} build run yet — sleeping 15s.`);
|
||||
await new Promise(r => setTimeout(r, SLEEP_MS));
|
||||
}
|
||||
}
|
||||
|
||||
- name: Report build failure
|
||||
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure' || steps.wait-for-crdt-build.outputs.conclusion == 'failure'
|
||||
run: |
|
||||
echo "Build failed on PR branch or ${GITHUB_BASE_REF}"
|
||||
exit 1
|
||||
const baseRef = process.env.BASE_REF;
|
||||
const headSha = process.env.HEAD_SHA;
|
||||
const [masterRunId, headRunId] = await Promise.all([
|
||||
resolveRun({
|
||||
label: baseRef,
|
||||
filter: { branch: baseRef },
|
||||
notFoundHint: `${baseRef} may be broken`,
|
||||
}),
|
||||
resolveRun({
|
||||
label: `PR head ${headSha}`,
|
||||
filter: { head_sha: headSha },
|
||||
notFoundHint:
|
||||
'build may still be running, have failed, or the branch may have been force-pushed',
|
||||
}),
|
||||
]);
|
||||
core.setOutput('master_run_id', masterRunId);
|
||||
core.setOutput('head_run_id', headRunId);
|
||||
|
||||
- name: Download web build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
id: pr-web-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
run_id: ${{ steps.build-runs.outputs.master_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: build-stats
|
||||
path: base
|
||||
- name: Download API build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
id: pr-api-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
run_id: ${{ steps.build-runs.outputs.master_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: api-build-stats
|
||||
path: base
|
||||
- name: Download build stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
run_id: ${{ steps.build-runs.outputs.head_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download API stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
run_id: ${{ steps.build-runs.outputs.head_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: api-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download CLI build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
run_id: ${{ steps.build-runs.outputs.master_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: base
|
||||
- name: Download CLI stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
run_id: ${{ steps.build-runs.outputs.head_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download CRDT build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
run_id: ${{ steps.build-runs.outputs.master_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: crdt-build-stats
|
||||
path: base
|
||||
- name: Download CRDT stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
run_id: ${{ steps.build-runs.outputs.head_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: crdt-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Strip content hashes from stats files
|
||||
run: |
|
||||
if [ -f ./head/web-stats.json ]; then
|
||||
|
||||
9
.github/workflows/vrt-update-apply.yml
vendored
@@ -75,9 +75,12 @@ jobs:
|
||||
|
||||
echo "Found patch file: $PATCH_FILE"
|
||||
|
||||
# Validate patch only contains PNG files
|
||||
# Validate patch only contains PNG files. `git format-patch` emits a
|
||||
# `GIT binary patch` block for PNGs (no +++/--- lines), so check
|
||||
# `diff --git` headers — those are present for both text and binary.
|
||||
echo "Validating patch contains only PNG files..."
|
||||
if grep -E '^(\+\+\+|---) [ab]/' "$PATCH_FILE" | grep -v '\.png$'; then
|
||||
if grep -E '^diff --git ' "$PATCH_FILE" \
|
||||
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
|
||||
echo "ERROR: Patch contains non-PNG files! Rejecting for security."
|
||||
echo "applied=false" >> "$GITHUB_OUTPUT"
|
||||
echo "error=Patch validation failed: contains non-PNG files" >> "$GITHUB_OUTPUT"
|
||||
@@ -85,7 +88,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Extract file list for verification
|
||||
FILES_CHANGED=$(grep -E '^\+\+\+ b/' "$PATCH_FILE" | sed 's/^+++ b\///' | wc -l)
|
||||
FILES_CHANGED=$(grep -cE '^diff --git ' "$PATCH_FILE")
|
||||
echo "Patch modifies $FILES_CHANGED PNG file(s)"
|
||||
|
||||
# Configure git
|
||||
|
||||
251
.github/workflows/vrt-update-generate.yml
vendored
@@ -36,15 +36,16 @@ jobs:
|
||||
content: 'eyes'
|
||||
});
|
||||
|
||||
generate-vrt-updates:
|
||||
name: Generate VRT Updates
|
||||
get-pr:
|
||||
name: Resolve PR details
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on PR comments containing /update-vrt
|
||||
if: >
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
outputs:
|
||||
head_sha: ${{ steps.pr.outputs.head_sha }}
|
||||
head_ref: ${{ steps.pr.outputs.head_ref }}
|
||||
head_repo: ${{ steps.pr.outputs.head_repo }}
|
||||
steps:
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
@@ -60,11 +61,131 @@ jobs:
|
||||
core.setOutput('head_ref', pr.head.ref);
|
||||
core.setOutput('head_repo', pr.head.repo.full_name);
|
||||
|
||||
build-web:
|
||||
name: Build web bundle
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-pr
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.head_sha }}
|
||||
ref: ${{ needs.get-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:
|
||||
download-translations: 'false'
|
||||
- name: Build browser bundle
|
||||
# REACT_APP_NETLIFY=true keeps the "Create test file" button in the
|
||||
# production bundle — every VRT test's beforeEach relies on it via
|
||||
# ConfigurationPage.createTestFile().
|
||||
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
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: desktop-client-build
|
||||
path: packages/desktop-client/build/
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
browser-vrt:
|
||||
name: Browser VRT (shard ${{ matrix.shard }}/3)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-pr, build-web]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
env:
|
||||
E2E_USE_BUILD: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ needs.get-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:
|
||||
download-translations: 'false'
|
||||
- name: Download web build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: desktop-client-build
|
||||
path: packages/desktop-client/build/
|
||||
- name: Run VRT Tests
|
||||
continue-on-error: true
|
||||
run: yarn vrt --update-snapshots --shard=${{ matrix.shard }}/3
|
||||
- name: Create shard patch with PNG changes only
|
||||
id: create-patch
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git add "**/*.png"
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No VRT changes in this shard"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
git commit -m "Update VRT screenshots (browser shard ${{ matrix.shard }})"
|
||||
git format-patch -1 HEAD --stdout > vrt-shard.patch
|
||||
|
||||
# Validate patch only contains PNG files. `git format-patch` emits a
|
||||
# `GIT binary patch` block for PNGs (no +++/--- lines), so check
|
||||
# `diff --git` headers — those are present for both text and binary.
|
||||
if grep -E '^diff --git ' vrt-shard.patch \
|
||||
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
|
||||
echo "ERROR: Shard patch contains non-PNG files!"
|
||||
exit 1
|
||||
fi
|
||||
- name: Upload shard patch
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vrt-shard-browser-${{ matrix.shard }}
|
||||
path: vrt-shard.patch
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
desktop-vrt:
|
||||
name: Desktop VRT
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-pr
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ needs.get-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:
|
||||
@@ -74,48 +195,120 @@ jobs:
|
||||
- name: Install build tools
|
||||
run: apt-get update && apt-get install -y build-essential python3
|
||||
|
||||
- name: Run VRT Tests on Desktop app
|
||||
- name: Run Desktop VRT Tests
|
||||
continue-on-error: true
|
||||
run: |
|
||||
yarn rebuild-electron
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
|
||||
|
||||
- name: Run VRT Tests
|
||||
continue-on-error: true
|
||||
run: yarn vrt --update-snapshots
|
||||
|
||||
- name: Create patch with PNG changes only
|
||||
- name: Create shard 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"
|
||||
|
||||
# Stage only PNG files
|
||||
git add "**/*.png"
|
||||
|
||||
# Check if there are any changes
|
||||
if git diff --staged --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No VRT changes to commit"
|
||||
echo "No VRT changes in desktop shard"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Create commit and patch
|
||||
git commit -m "Update VRT screenshots"
|
||||
git format-patch -1 HEAD --stdout > vrt-update.patch
|
||||
git commit -m "Update VRT screenshots (desktop)"
|
||||
git format-patch -1 HEAD --stdout > vrt-shard.patch
|
||||
|
||||
# Validate patch only contains PNG files
|
||||
if grep -E '^(\+\+\+|---) [ab]/' vrt-update.patch | grep -v '\.png$'; then
|
||||
echo "ERROR: Patch contains non-PNG files!"
|
||||
# See validation note in browser-vrt above.
|
||||
if grep -E '^diff --git ' vrt-shard.patch \
|
||||
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
|
||||
echo "ERROR: Desktop shard patch contains non-PNG files!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Patch created successfully with PNG changes only"
|
||||
- name: Upload shard patch
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vrt-shard-desktop
|
||||
path: vrt-shard.patch
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
merge-patch:
|
||||
name: Merge VRT Patches
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-pr, browser-vrt, desktop-vrt]
|
||||
if: ${{ !cancelled() && needs.get-pr.result == 'success' }}
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ needs.get-pr.outputs.head_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download all shard patches
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/shard-patches
|
||||
pattern: vrt-shard-*
|
||||
|
||||
- name: Merge shard patches
|
||||
id: create-patch
|
||||
run: |
|
||||
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"
|
||||
|
||||
shopt -s nullglob
|
||||
patches=(/tmp/shard-patches/*/vrt-shard.patch)
|
||||
|
||||
if [ ${#patches[@]} -eq 0 ]; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No shard patches to merge"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Defense in depth: re-validate every shard patch before applying.
|
||||
# See validation note in browser-vrt above for why we match
|
||||
# `diff --git` headers instead of +++/--- lines.
|
||||
for patch in "${patches[@]}"; do
|
||||
echo "Validating $patch"
|
||||
if grep -E '^diff --git ' "$patch" \
|
||||
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
|
||||
echo "ERROR: $patch contains non-PNG files!"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Apply each shard patch. Shards touch disjoint PNG files so
|
||||
# order does not matter. --index stages the applied changes.
|
||||
for patch in "${patches[@]}"; do
|
||||
echo "Applying $patch"
|
||||
git apply --index "$patch"
|
||||
done
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No VRT changes after merge"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
git commit -m "Update VRT screenshots"
|
||||
git format-patch -1 HEAD --stdout > vrt-update.patch
|
||||
|
||||
# Final guard on the combined patch.
|
||||
if grep -E '^diff --git ' vrt-update.patch \
|
||||
| grep -vE '^diff --git a/[^[:space:]]+\.png b/[^[:space:]]+\.png$'; then
|
||||
echo "ERROR: Merged patch contains non-PNG files!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Merged patch created successfully with PNG changes only"
|
||||
|
||||
- name: Upload patch artifact
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
@@ -130,11 +323,11 @@ jobs:
|
||||
run: |
|
||||
mkdir -p pr-metadata
|
||||
echo "${{ github.event.issue.number }}" > pr-metadata/pr-number.txt
|
||||
echo "${STEPS_PR_OUTPUTS_HEAD_REF}" > pr-metadata/head-ref.txt
|
||||
echo "${STEPS_PR_OUTPUTS_HEAD_REPO}" > pr-metadata/head-repo.txt
|
||||
echo "${NEEDS_GET_PR_OUTPUTS_HEAD_REF}" > pr-metadata/head-ref.txt
|
||||
echo "${NEEDS_GET_PR_OUTPUTS_HEAD_REPO}" > pr-metadata/head-repo.txt
|
||||
env:
|
||||
STEPS_PR_OUTPUTS_HEAD_REF: ${{ steps.pr.outputs.head_ref }}
|
||||
STEPS_PR_OUTPUTS_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
|
||||
NEEDS_GET_PR_OUTPUTS_HEAD_REF: ${{ needs.get-pr.outputs.head_ref }}
|
||||
NEEDS_GET_PR_OUTPUTS_HEAD_REPO: ${{ needs.get-pr.outputs.head_repo }}
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
|
||||
3
.gitignore
vendored
@@ -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/*
|
||||
|
||||
@@ -375,7 +375,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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 -m ./packages/desktop-electron -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
@@ -48,7 +50,7 @@
|
||||
"@actual-app/crdt": "workspace:*",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "beta",
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"**/node_modules/*",
|
||||
"dist",
|
||||
"@types",
|
||||
"*.test.ts",
|
||||
"*.config.ts",
|
||||
"*.config.mts"
|
||||
]
|
||||
|
||||
@@ -43,13 +43,16 @@ Configuration is resolved in this order (highest priority first):
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | --------------------------------------------- |
|
||||
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
|
||||
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
|
||||
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
|
||||
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
|
||||
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
|
||||
| Variable | Description |
|
||||
| ---------------------- | ----------------------------------------------------- |
|
||||
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
|
||||
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
|
||||
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
|
||||
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
|
||||
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
|
||||
| `ACTUAL_CACHE_TTL` | Cache TTL in seconds (default: 60) |
|
||||
| `ACTUAL_LOCK_TIMEOUT` | Budget-dir lock wait timeout in seconds (default: 10) |
|
||||
| `ACTUAL_NO_LOCK` | Set to `1` to disable budget-dir locking |
|
||||
|
||||
### Config File
|
||||
|
||||
@@ -59,7 +62,10 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
|
||||
{
|
||||
"serverUrl": "http://localhost:5006",
|
||||
"password": "your-password",
|
||||
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
|
||||
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f",
|
||||
"cacheTtl": 60,
|
||||
"lockTimeout": 10,
|
||||
"noLock": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -74,6 +80,11 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
|
||||
| `--session-token <token>` | Session token |
|
||||
| `--sync-id <id>` | Budget Sync ID |
|
||||
| `--data-dir <path>` | Data directory |
|
||||
| `--cache-ttl <seconds>` | Cache TTL; `0` disables caching (default: 60) |
|
||||
| `--refresh` | Force a sync on this call, ignoring the cache |
|
||||
| `--no-cache` | Alias for `--refresh` |
|
||||
| `--lock-timeout <secs>` | Lock wait timeout (default: 10) |
|
||||
| `--no-lock` | Disable budget-dir locking (use with care) |
|
||||
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
|
||||
| `--verbose` | Show informational messages |
|
||||
|
||||
@@ -92,6 +103,7 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
|
||||
| `schedules` | Manage scheduled transactions |
|
||||
| `query` | Run an ActualQL query |
|
||||
| `server` | Server utilities and lookups |
|
||||
| `sync` | Refresh or inspect local cache |
|
||||
|
||||
Run `actual <command> --help` for subcommands and options.
|
||||
|
||||
@@ -135,22 +147,32 @@ All monetary amounts are **integer cents** when passed as input (flags, JSON):
|
||||
|
||||
- **Split transactions:** When summing or counting transactions, filter `"is_parent": false` to avoid double-counting. A split parent holds the total amount, and its children hold the individual parts — including both would count the total twice.
|
||||
|
||||
- **Avoid rapid sequential requests:** Each CLI invocation opens a new server connection. Running queries in a tight loop (e.g. one per month) may trigger rate limiting or authentication failures. Instead, fetch all data in a single query with a date range filter and process locally:
|
||||
- **Rapid sequential requests:** The CLI caches the budget locally (see [Caching](#caching)), so read-heavy scripts no longer need a single-query workaround by default. For very chatty scripts, run `actual sync` once and then use a long `--cache-ttl` for reads:
|
||||
|
||||
```bash
|
||||
# Good: single query for the full year
|
||||
actual query run --table transactions \
|
||||
--filter '{"$and":[{"date":{"$gte":"2025-01-01"}},{"date":{"$lte":"2025-12-31"}}]}' \
|
||||
--limit 5000
|
||||
|
||||
# Bad: one query per month in a loop (may fail with auth errors)
|
||||
for month in 01 02 03 ...; do actual query run ...; done
|
||||
actual sync
|
||||
actual --cache-ttl 3600 query run ...
|
||||
actual --cache-ttl 3600 accounts list
|
||||
```
|
||||
|
||||
- **Uncategorized transactions:** `category.name` is `null` for transactions without a category. Account for this when filtering or grouping by category.
|
||||
|
||||
- **No date sub-fields in AQL:** `date.month`, `date.year`, etc. are not supported as query fields. To group by month, fetch raw transactions with a date range filter and aggregate locally in a script.
|
||||
|
||||
## Caching
|
||||
|
||||
The CLI keeps a local copy of your budget so repeated commands don't hit the sync server on every call. Within the TTL (default `60` seconds), read commands (`list`, `balance`, `query run`, …) reuse the cached budget without a network round-trip. Write commands (`add`, `update`, `set-amount`, …) always sync with the server before and after the write.
|
||||
|
||||
- `actual sync` — refresh the cache now.
|
||||
- `actual sync --status` — show how stale the local cache is.
|
||||
- `actual sync --clear` — delete the local cache; the next command re-downloads.
|
||||
- `--refresh` (or `--no-cache`) — force a sync on a single call.
|
||||
- `--cache-ttl <seconds>` — override the TTL for a single call (use `0` to disable caching).
|
||||
|
||||
### Concurrency
|
||||
|
||||
The CLI takes a shared lock for reads and an exclusive lock for writes on the per-budget cache directory. Many parallel reads are safe; writes serialize. If another CLI process is holding the lock, subsequent invocations wait up to `--lock-timeout` seconds (default `10`) before failing with an error. Pass `--no-lock` to opt out in trusted single-process setups.
|
||||
|
||||
## Running Locally (Development)
|
||||
|
||||
If you're working on the CLI within the monorepo:
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
],
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#cache": "./src/cache.ts",
|
||||
"#commands/*": "./src/commands/*.ts",
|
||||
"#config": "./src/config.ts",
|
||||
"#connection": "./src/connection.ts",
|
||||
"#input": "./src/input.ts",
|
||||
"#lock": "./src/lock.ts",
|
||||
"#output": "./src/output.ts",
|
||||
"#utils": "./src/utils.ts"
|
||||
},
|
||||
@@ -33,10 +35,12 @@
|
||||
"@actual-app/api": "workspace:*",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^14.0.3",
|
||||
"cosmiconfig": "^9.0.1"
|
||||
"cosmiconfig": "^9.0.1",
|
||||
"proper-lockfile": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/proper-lockfile": "^4",
|
||||
"@typescript/native-preview": "beta",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"vite": "^8.0.5",
|
||||
|
||||
206
packages/cli/src/cache.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
CACHE_FILE_NAME,
|
||||
decideSyncAction,
|
||||
readCacheState,
|
||||
writeCacheState,
|
||||
} from './cache';
|
||||
|
||||
describe('readCacheState', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null when the file does not exist', () => {
|
||||
expect(readCacheState(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the file is corrupt', () => {
|
||||
writeFileSync(join(dir, CACHE_FILE_NAME), 'not json');
|
||||
expect(readCacheState(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the file has the wrong version', () => {
|
||||
writeFileSync(
|
||||
join(dir, CACHE_FILE_NAME),
|
||||
JSON.stringify({
|
||||
version: 999,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
}),
|
||||
);
|
||||
expect(readCacheState(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the parsed state when the file is valid', () => {
|
||||
writeFileSync(
|
||||
join(dir, CACHE_FILE_NAME),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1234,
|
||||
lastDownloadedAt: 5678,
|
||||
}),
|
||||
);
|
||||
expect(readCacheState(dir)).toEqual({
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1234,
|
||||
lastDownloadedAt: 5678,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeCacheState', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes the state to the cache file', () => {
|
||||
writeCacheState(dir, {
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
});
|
||||
const raw = readFileSync(join(dir, CACHE_FILE_NAME), 'utf-8');
|
||||
expect(JSON.parse(raw).syncId).toBe('a');
|
||||
});
|
||||
|
||||
it('is atomic: removes the tmp file after rename', () => {
|
||||
writeCacheState(dir, {
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
});
|
||||
expect(existsSync(join(dir, `${CACHE_FILE_NAME}.tmp`))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw when the filesystem refuses the write', () => {
|
||||
// Force ENOTDIR by pointing writeCacheState at a path whose parent is a
|
||||
// regular file — no OS-specific pseudo-filesystem semantics needed.
|
||||
const file = join(dir, 'not-a-dir');
|
||||
writeFileSync(file, '');
|
||||
expect(() =>
|
||||
writeCacheState(join(file, 'nested'), {
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideSyncAction', () => {
|
||||
const base = {
|
||||
state: {
|
||||
version: 1 as const,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-1',
|
||||
serverUrl: 'http://s',
|
||||
lastSyncedAt: 1_000_000,
|
||||
lastDownloadedAt: 1_000_000,
|
||||
},
|
||||
config: { syncId: 'sync-1', serverUrl: 'http://s' },
|
||||
now: 1_000_000,
|
||||
ttlMs: 60_000,
|
||||
mutates: false,
|
||||
refresh: false,
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
it('returns "download" when state is null', () => {
|
||||
expect(decideSyncAction({ ...base, state: null }).action).toBe('download');
|
||||
});
|
||||
|
||||
it('returns "download" when syncId changed', () => {
|
||||
expect(
|
||||
decideSyncAction({
|
||||
...base,
|
||||
config: { ...base.config, syncId: 'other' },
|
||||
}).action,
|
||||
).toBe('download');
|
||||
});
|
||||
|
||||
it('returns "download" when serverUrl changed', () => {
|
||||
expect(
|
||||
decideSyncAction({
|
||||
...base,
|
||||
config: { ...base.config, serverUrl: 'http://other' },
|
||||
}).action,
|
||||
).toBe('download');
|
||||
});
|
||||
|
||||
it('returns "skip" for a read within the TTL', () => {
|
||||
expect(decideSyncAction({ ...base, now: 1_000_000 + 30_000 }).action).toBe(
|
||||
'skip',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns "sync" for a read past the TTL', () => {
|
||||
expect(decideSyncAction({ ...base, now: 1_000_000 + 61_000 }).action).toBe(
|
||||
'sync',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns "sync" for a write even when fresh', () => {
|
||||
expect(decideSyncAction({ ...base, mutates: true }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('returns "sync" when refresh is true', () => {
|
||||
expect(decideSyncAction({ ...base, refresh: true }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('returns "sync" when ttlMs is 0', () => {
|
||||
expect(decideSyncAction({ ...base, ttlMs: 0 }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('returns "sync" for encrypted budgets within the TTL', () => {
|
||||
expect(decideSyncAction({ ...base, encrypted: true }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('treats clock skew (negative age) as stale', () => {
|
||||
expect(decideSyncAction({ ...base, now: 999_999 }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('carries cached state on non-download actions', () => {
|
||||
const decision = decideSyncAction({ ...base, mutates: true });
|
||||
expect(decision).toEqual({ action: 'sync', state: base.state });
|
||||
});
|
||||
});
|
||||
107
packages/cli/src/cache.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { isRecord } from './utils';
|
||||
|
||||
export const CACHE_FILE_NAME = 'state.json';
|
||||
export const CACHE_VERSION = 1;
|
||||
export const META_ROOT_DIR = '.actual-cli';
|
||||
|
||||
export type CacheState = {
|
||||
version: typeof CACHE_VERSION;
|
||||
syncId: string;
|
||||
budgetId: string;
|
||||
serverUrl: string;
|
||||
lastSyncedAt: number;
|
||||
lastDownloadedAt: number;
|
||||
};
|
||||
|
||||
export function getMetaDir(dataDir: string, syncId: string): string {
|
||||
return join(dataDir, META_ROOT_DIR, syncId);
|
||||
}
|
||||
|
||||
function cachePath(metaDir: string): string {
|
||||
return join(metaDir, CACHE_FILE_NAME);
|
||||
}
|
||||
|
||||
function isCacheState(value: unknown): value is CacheState {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value.version === CACHE_VERSION &&
|
||||
typeof value.syncId === 'string' &&
|
||||
typeof value.budgetId === 'string' &&
|
||||
typeof value.serverUrl === 'string' &&
|
||||
typeof value.lastSyncedAt === 'number' &&
|
||||
typeof value.lastDownloadedAt === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
export function readCacheState(metaDir: string): CacheState | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(cachePath(metaDir), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return isCacheState(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function writeCacheState(metaDir: string, state: CacheState): void {
|
||||
try {
|
||||
mkdirSync(metaDir, { recursive: true });
|
||||
const target = cachePath(metaDir);
|
||||
// Unique tmp name per writer: concurrent shared-lock commands (encrypted
|
||||
// budgets, --refresh, stale TTL) can both publish, and a shared tmp path
|
||||
// lets the second writer's truncate destroy the first writer's bytes
|
||||
// before either renames into place.
|
||||
const tmp = `${target}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(state));
|
||||
renameSync(tmp, target);
|
||||
} catch {
|
||||
// Cache persistence is best-effort. A read-only or unreachable dir must
|
||||
// not crash the CLI; the next invocation simply won't find a cache.
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncDecision =
|
||||
| { action: 'download' }
|
||||
| { action: 'skip'; state: CacheState }
|
||||
| { action: 'sync'; state: CacheState };
|
||||
|
||||
export type DecideSyncArgs = {
|
||||
state: CacheState | null;
|
||||
config: { syncId: string; serverUrl: string };
|
||||
now: number;
|
||||
ttlMs: number;
|
||||
mutates: boolean;
|
||||
refresh: boolean;
|
||||
encrypted: boolean;
|
||||
};
|
||||
|
||||
export function decideSyncAction({
|
||||
state,
|
||||
config,
|
||||
now,
|
||||
ttlMs,
|
||||
mutates,
|
||||
refresh,
|
||||
encrypted,
|
||||
}: DecideSyncArgs): SyncDecision {
|
||||
if (state === null) return { action: 'download' };
|
||||
if (state.syncId !== config.syncId) return { action: 'download' };
|
||||
if (state.serverUrl !== config.serverUrl) return { action: 'download' };
|
||||
if (mutates || refresh || ttlMs === 0 || encrypted) {
|
||||
return { action: 'sync', state };
|
||||
}
|
||||
const age = now - state.lastSyncedAt;
|
||||
if (age < 0) return { action: 'sync', state };
|
||||
if (age < ttlMs) return { action: 'skip', state };
|
||||
return { action: 'sync', state };
|
||||
}
|
||||
@@ -14,26 +14,30 @@ export function registerAccountsCommand(program: Command) {
|
||||
.option('--include-closed', 'Include closed accounts', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const allAccounts = await api.getAccounts();
|
||||
const accounts = allAccounts.filter(
|
||||
a => cmdOpts.includeClosed || !a.closed,
|
||||
);
|
||||
// Stable sort: on-budget first, off-budget second
|
||||
// (preserves API sort_order within each group)
|
||||
accounts.sort((a, b) => Number(a.offbudget) - Number(b.offbudget));
|
||||
const balances = await Promise.all(
|
||||
accounts.map(a => api.getAccountBalance(a.id)),
|
||||
);
|
||||
const output = accounts.map((a, i) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
offbudget: a.offbudget,
|
||||
closed: a.closed,
|
||||
balance: balances[i],
|
||||
}));
|
||||
printOutput(output, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const allAccounts = await api.getAccounts();
|
||||
const accounts = allAccounts.filter(
|
||||
a => cmdOpts.includeClosed || !a.closed,
|
||||
);
|
||||
// Stable sort: on-budget first, off-budget second
|
||||
// (preserves API sort_order within each group)
|
||||
accounts.sort((a, b) => Number(a.offbudget) - Number(b.offbudget));
|
||||
const balances = await Promise.all(
|
||||
accounts.map(a => api.getAccountBalance(a.id)),
|
||||
);
|
||||
const output = accounts.map((a, i) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
offbudget: a.offbudget,
|
||||
closed: a.closed,
|
||||
balance: balances[i],
|
||||
}));
|
||||
printOutput(output, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -49,13 +53,17 @@ export function registerAccountsCommand(program: Command) {
|
||||
.action(async cmdOpts => {
|
||||
const balance = parseIntFlag(cmdOpts.balance, '--balance');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createAccount(
|
||||
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
|
||||
balance,
|
||||
);
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.createAccount(
|
||||
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
|
||||
balance,
|
||||
);
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -81,10 +89,14 @@ export function registerAccountsCommand(program: Command) {
|
||||
'No update fields provided. Use --name or --offbudget.',
|
||||
);
|
||||
}
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateAccount(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updateAccount(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -100,14 +112,18 @@ export function registerAccountsCommand(program: Command) {
|
||||
)
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.closeAccount(
|
||||
id,
|
||||
cmdOpts.transferAccount,
|
||||
cmdOpts.transferCategory,
|
||||
);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.closeAccount(
|
||||
id,
|
||||
cmdOpts.transferAccount,
|
||||
cmdOpts.transferCategory,
|
||||
);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -115,10 +131,14 @@ export function registerAccountsCommand(program: Command) {
|
||||
.description('Reopen a closed account')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.reopenAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.reopenAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -126,10 +146,14 @@ export function registerAccountsCommand(program: Command) {
|
||||
.description('Delete an account')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -148,9 +172,13 @@ export function registerAccountsCommand(program: Command) {
|
||||
cutoff = cutoffDate;
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const balance = await api.getAccountBalance(id, cutoff);
|
||||
printOutput({ id, balance }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const balance = await api.getAccountBalance(id, cutoff);
|
||||
printOutput({ id, balance }, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { resolveConfig } from '#config';
|
||||
import { withConnection } from '#connection';
|
||||
import { printOutput } from '#output';
|
||||
import { parseBoolFlag, parseIntFlag } from '#utils';
|
||||
@@ -20,7 +19,7 @@ export function registerBudgetsCommand(program: Command) {
|
||||
const result = await api.getBudgets();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
{ mutates: false, skipBudget: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,40 +29,33 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.option('--encryption-password <password>', 'Encryption password')
|
||||
.action(async (syncId: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const config = await resolveConfig(opts);
|
||||
const password = config.encryptionPassword ?? cmdOpts.encryptionPassword;
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
async config => {
|
||||
const password =
|
||||
cmdOpts.encryptionPassword ?? config.encryptionPassword;
|
||||
await api.downloadBudget(syncId, {
|
||||
password,
|
||||
});
|
||||
printOutput({ success: true, syncId }, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
{ mutates: false, skipBudget: true },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('sync')
|
||||
.description('Sync the current budget')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.sync();
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('months')
|
||||
.description('List available budget months')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getBudgetMonths();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getBudgetMonths();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -71,10 +63,14 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.description('Get budget data for a specific month (YYYY-MM)')
|
||||
.action(async (month: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getBudgetMonth(month);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getBudgetMonth(month);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -89,10 +85,14 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.action(async cmdOpts => {
|
||||
const amount = parseIntFlag(cmdOpts.amount, '--amount');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -104,10 +104,14 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.action(async cmdOpts => {
|
||||
const flag = parseBoolFlag(cmdOpts.flag, '--flag');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -121,10 +125,14 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.action(async cmdOpts => {
|
||||
const parsedAmount = parseIntFlag(cmdOpts.amount, '--amount');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -133,9 +141,13 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.resetBudgetHold(cmdOpts.month);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.resetBudgetHold(cmdOpts.month);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@ export function registerCategoriesCommand(program: Command) {
|
||||
.description('List all categories')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCategories();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCategories();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
categories
|
||||
@@ -29,15 +33,19 @@ export function registerCategoriesCommand(program: Command) {
|
||||
.option('--is-income', 'Mark as income category', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createCategory({
|
||||
name: cmdOpts.name,
|
||||
group_id: cmdOpts.groupId,
|
||||
is_income: cmdOpts.isIncome,
|
||||
hidden: false,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.createCategory({
|
||||
name: cmdOpts.name,
|
||||
group_id: cmdOpts.groupId,
|
||||
is_income: cmdOpts.isIncome,
|
||||
hidden: false,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
categories
|
||||
@@ -55,10 +63,14 @@ export function registerCategoriesCommand(program: Command) {
|
||||
throw new Error('No update fields provided. Use --name or --hidden.');
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateCategory(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updateCategory(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
categories
|
||||
@@ -67,9 +79,13 @@ export function registerCategoriesCommand(program: Command) {
|
||||
.option('--transfer-to <id>', 'Transfer transactions to this category')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteCategory(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteCategory(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
.description('List all category groups')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCategoryGroups();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCategoryGroups();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
groups
|
||||
@@ -28,14 +32,18 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
.option('--is-income', 'Mark as income group', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createCategoryGroup({
|
||||
name: cmdOpts.name,
|
||||
is_income: cmdOpts.isIncome,
|
||||
hidden: false,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.createCategoryGroup({
|
||||
name: cmdOpts.name,
|
||||
is_income: cmdOpts.isIncome,
|
||||
hidden: false,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
groups
|
||||
@@ -53,10 +61,14 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
throw new Error('No update fields provided. Use --name or --hidden.');
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateCategoryGroup(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updateCategoryGroup(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
groups
|
||||
@@ -65,9 +77,13 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
.option('--transfer-to <id>', 'Transfer transactions to this category ID')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,10 +12,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
.description('List all payees')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getPayees();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getPayees();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -23,10 +27,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
.description('List frequently used payees')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCommonPayees();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCommonPayees();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -35,10 +43,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
.requiredOption('--name <name>', 'Payee name')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createPayee({ name: cmdOpts.name });
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.createPayee({ name: cmdOpts.name });
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -54,10 +66,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updatePayee(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updatePayee(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -65,10 +81,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
.description('Delete a payee')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deletePayee(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deletePayee(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -87,9 +107,13 @@ export function registerPayeesCommand(program: Command) {
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.mergePayees(cmdOpts.target, mergeIds);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.mergePayees(cmdOpts.target, mergeIds);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,27 +301,31 @@ export function registerQueryCommand(program: Command) {
|
||||
.addHelpText('after', RUN_EXAMPLES)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
|
||||
if (parsed !== undefined && !isRecord(parsed)) {
|
||||
throw new Error('Query file must contain a JSON object');
|
||||
}
|
||||
const queryObj = parsed
|
||||
? buildQueryFromFile(parsed, cmdOpts.table)
|
||||
: buildQueryFromFlags(cmdOpts);
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
|
||||
if (parsed !== undefined && !isRecord(parsed)) {
|
||||
throw new Error('Query file must contain a JSON object');
|
||||
}
|
||||
const queryObj = parsed
|
||||
? buildQueryFromFile(parsed, cmdOpts.table)
|
||||
: buildQueryFromFlags(cmdOpts);
|
||||
|
||||
const result = await api.aqlQuery(queryObj);
|
||||
const result = await api.aqlQuery(queryObj);
|
||||
|
||||
if (!isRecord(result) || !('data' in result)) {
|
||||
throw new Error('Query result missing data');
|
||||
}
|
||||
if (!isRecord(result) || !('data' in result)) {
|
||||
throw new Error('Query result missing data');
|
||||
}
|
||||
|
||||
if (cmdOpts.count) {
|
||||
printOutput({ count: result.data }, opts.format);
|
||||
} else {
|
||||
printOutput(result.data, opts.format);
|
||||
}
|
||||
});
|
||||
if (cmdOpts.count) {
|
||||
printOutput({ count: result.data }, opts.format);
|
||||
} else {
|
||||
printOutput(result.data, opts.format);
|
||||
}
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
query
|
||||
|
||||
@@ -15,10 +15,14 @@ export function registerRulesCommand(program: Command) {
|
||||
.description('List all rules')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getRules();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getRules();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
rules
|
||||
@@ -26,10 +30,14 @@ export function registerRulesCommand(program: Command) {
|
||||
.description('List rules for a specific payee')
|
||||
.action(async (payeeId: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getPayeeRules(payeeId);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getPayeeRules(payeeId);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
rules
|
||||
@@ -39,13 +47,17 @@ export function registerRulesCommand(program: Command) {
|
||||
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createRule
|
||||
>[0];
|
||||
const id = await api.createRule(rule);
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createRule
|
||||
>[0];
|
||||
const id = await api.createRule(rule);
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
rules
|
||||
@@ -55,13 +67,17 @@ export function registerRulesCommand(program: Command) {
|
||||
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateRule
|
||||
>[0];
|
||||
await api.updateRule(rule);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateRule
|
||||
>[0];
|
||||
await api.updateRule(rule);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
rules
|
||||
@@ -69,9 +85,13 @@ export function registerRulesCommand(program: Command) {
|
||||
.description('Delete a rule')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteRule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteRule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@ export function registerSchedulesCommand(program: Command) {
|
||||
.description('List all schedules')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getSchedules();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getSchedules();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
schedules
|
||||
@@ -28,13 +32,17 @@ export function registerSchedulesCommand(program: Command) {
|
||||
.option('--file <path>', 'Read schedule from JSON file (use - for stdin)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const schedule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createSchedule
|
||||
>[0];
|
||||
const id = await api.createSchedule(schedule);
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const schedule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createSchedule
|
||||
>[0];
|
||||
const id = await api.createSchedule(schedule);
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
schedules
|
||||
@@ -45,13 +53,17 @@ export function registerSchedulesCommand(program: Command) {
|
||||
.option('--reset-next-date', 'Reset next occurrence date', false)
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const fields = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateSchedule
|
||||
>[1];
|
||||
await api.updateSchedule(id, fields, cmdOpts.resetNextDate);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const fields = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateSchedule
|
||||
>[1];
|
||||
await api.updateSchedule(id, fields, cmdOpts.resetNextDate);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
schedules
|
||||
@@ -59,9 +71,13 @@ export function registerSchedulesCommand(program: Command) {
|
||||
.description('Delete a schedule')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteSchedule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteSchedule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function registerServerCommand(program: Command) {
|
||||
const version = await api.getServerVersion();
|
||||
printOutput({ version }, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
{ mutates: false, skipBudget: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,13 +34,17 @@ export function registerServerCommand(program: Command) {
|
||||
.requiredOption('--name <name>', 'Entity name')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
|
||||
printOutput(
|
||||
{ id, type: cmdOpts.type, name: cmdOpts.name },
|
||||
opts.format,
|
||||
);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
|
||||
printOutput(
|
||||
{ id, type: cmdOpts.type, name: cmdOpts.name },
|
||||
opts.format,
|
||||
);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
server
|
||||
@@ -49,12 +53,16 @@ export function registerServerCommand(program: Command) {
|
||||
.option('--account <id>', 'Specific account ID to sync')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const args = cmdOpts.account
|
||||
? { accountId: cmdOpts.account }
|
||||
: undefined;
|
||||
await api.runBankSync(args);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const args = cmdOpts.account
|
||||
? { accountId: cmdOpts.account }
|
||||
: undefined;
|
||||
await api.runBankSync(args);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
124
packages/cli/src/commands/sync.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { CACHE_FILE_NAME, getMetaDir, writeCacheState } from '#cache';
|
||||
import { resolveConfig } from '#config';
|
||||
|
||||
import { registerSyncCommand } from './sync';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
downloadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
loadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
sync: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
getBudgets: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'bud-disk-1', groupId: 'sync-1' }]),
|
||||
}));
|
||||
|
||||
vi.mock('#config', () => ({
|
||||
resolveConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
function metaDirFor(syncId: string) {
|
||||
return getMetaDir(dataDir, syncId);
|
||||
}
|
||||
|
||||
function program() {
|
||||
const p = new Command();
|
||||
p.exitOverride();
|
||||
p.option('--sync-id <id>');
|
||||
p.option('--data-dir <path>');
|
||||
p.option('--format <fmt>');
|
||||
p.option('--verbose');
|
||||
registerSyncCommand(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
describe('actual sync', () => {
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'actual-cli-sync-'));
|
||||
vi.mocked(resolveConfig).mockResolvedValue({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir,
|
||||
syncId: 'sync-1',
|
||||
cacheTtl: 60,
|
||||
lockTimeout: 10,
|
||||
refresh: false,
|
||||
noLock: true,
|
||||
});
|
||||
stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutSpy.mockRestore();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs a sync and prints the syncId', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: 0,
|
||||
lastDownloadedAt: 0,
|
||||
});
|
||||
await program().parseAsync(['node', 'actual', 'sync']);
|
||||
const out = stdoutSpy.mock.calls
|
||||
.map((c: unknown[]) => String(c[0]))
|
||||
.join('');
|
||||
expect(out).toMatch(/"syncId":\s*"sync-1"/);
|
||||
});
|
||||
|
||||
it('--status prints cache info without syncing', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now() - 5000,
|
||||
lastDownloadedAt: Date.now() - 5000,
|
||||
});
|
||||
await program().parseAsync(['node', 'actual', 'sync', '--status']);
|
||||
const out = stdoutSpy.mock.calls
|
||||
.map((c: unknown[]) => String(c[0]))
|
||||
.join('');
|
||||
expect(out).toMatch(/"stale":\s*(true|false)/);
|
||||
expect(out).toMatch(/"ageSeconds":\s*\d+/);
|
||||
});
|
||||
|
||||
it('--status on no prior sync reports "never synced" and exits 0', async () => {
|
||||
await program().parseAsync(['node', 'actual', 'sync', '--status']);
|
||||
const out = stdoutSpy.mock.calls
|
||||
.map((c: unknown[]) => String(c[0]))
|
||||
.join('');
|
||||
expect(out).toMatch(/"neverSynced":\s*true/);
|
||||
});
|
||||
|
||||
it('--clear removes the cache file', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
expect(existsSync(join(metaDirFor('sync-1'), CACHE_FILE_NAME))).toBe(true);
|
||||
await program().parseAsync(['node', 'actual', 'sync', '--clear']);
|
||||
expect(existsSync(join(metaDirFor('sync-1'), CACHE_FILE_NAME))).toBe(false);
|
||||
});
|
||||
});
|
||||
118
packages/cli/src/commands/sync.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { CACHE_FILE_NAME, getMetaDir, readCacheState } from '#cache';
|
||||
import type { CliConfig } from '#config';
|
||||
import { resolveConfig } from '#config';
|
||||
import { withConnection } from '#connection';
|
||||
import { acquireExclusive } from '#lock';
|
||||
import { printOutput } from '#output';
|
||||
|
||||
type SyncCmdOpts = {
|
||||
status?: boolean;
|
||||
clear?: boolean;
|
||||
};
|
||||
|
||||
async function requireSyncIdAndMeta(
|
||||
opts: Record<string, unknown>,
|
||||
flag: string,
|
||||
): Promise<{ config: CliConfig; meta: string }> {
|
||||
const config = await resolveConfig(opts);
|
||||
if (!config.syncId) {
|
||||
throw new Error(
|
||||
`Sync ID is required for sync ${flag}. Set --sync-id or ACTUAL_SYNC_ID.`,
|
||||
);
|
||||
}
|
||||
return { config, meta: getMetaDir(config.dataDir, config.syncId) };
|
||||
}
|
||||
|
||||
export function registerSyncCommand(program: Command) {
|
||||
program
|
||||
.command('sync')
|
||||
.description(
|
||||
'Sync the local cached budget with the server, print cache status, or clear the cache',
|
||||
)
|
||||
.option('--status', 'Print cache status without syncing', false)
|
||||
.option(
|
||||
'--clear',
|
||||
'Delete the local cache; next command re-downloads',
|
||||
false,
|
||||
)
|
||||
.action(async (cmdOpts: SyncCmdOpts) => {
|
||||
const opts = program.opts();
|
||||
|
||||
if (cmdOpts.status) {
|
||||
const { config, meta } = await requireSyncIdAndMeta(opts, '--status');
|
||||
const state = readCacheState(meta);
|
||||
if (state === null) {
|
||||
printOutput(
|
||||
{
|
||||
neverSynced: true,
|
||||
syncId: config.syncId,
|
||||
ttlSeconds: config.cacheTtl,
|
||||
},
|
||||
opts.format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rawAgeSeconds = Math.round(
|
||||
(Date.now() - state.lastSyncedAt) / 1000,
|
||||
);
|
||||
const ageSeconds = Math.max(0, rawAgeSeconds);
|
||||
printOutput(
|
||||
{
|
||||
neverSynced: false,
|
||||
syncId: state.syncId,
|
||||
budgetId: state.budgetId,
|
||||
syncedAt: new Date(state.lastSyncedAt).toISOString(),
|
||||
lastDownloadedAt: new Date(state.lastDownloadedAt).toISOString(),
|
||||
ageSeconds,
|
||||
ttlSeconds: config.cacheTtl,
|
||||
stale: rawAgeSeconds < 0 || rawAgeSeconds > config.cacheTtl,
|
||||
},
|
||||
opts.format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmdOpts.clear) {
|
||||
const { config, meta } = await requireSyncIdAndMeta(opts, '--clear');
|
||||
// Serialize with concurrent writers so we don't rm a half-written
|
||||
// state.json that's about to be renamed into place.
|
||||
const release = config.noLock
|
||||
? null
|
||||
: await acquireExclusive(meta, {
|
||||
timeoutMs: config.lockTimeout * 1000,
|
||||
});
|
||||
try {
|
||||
rmSync(join(meta, CACHE_FILE_NAME), { force: true });
|
||||
} finally {
|
||||
await release?.();
|
||||
}
|
||||
printOutput({ cleared: true, syncId: config.syncId }, opts.format);
|
||||
return;
|
||||
}
|
||||
|
||||
await withConnection(
|
||||
opts,
|
||||
async config => {
|
||||
const state = config.syncId
|
||||
? readCacheState(getMetaDir(config.dataDir, config.syncId))
|
||||
: null;
|
||||
printOutput(
|
||||
{
|
||||
syncedAt: new Date(
|
||||
state?.lastSyncedAt ?? Date.now(),
|
||||
).toISOString(),
|
||||
syncId: config.syncId,
|
||||
budgetId: state?.budgetId ?? config.syncId,
|
||||
},
|
||||
opts.format,
|
||||
);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -12,10 +12,14 @@ export function registerTagsCommand(program: Command) {
|
||||
.description('List all tags')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTags();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getTags();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
tags
|
||||
@@ -26,14 +30,18 @@ export function registerTagsCommand(program: Command) {
|
||||
.option('--description <description>', 'Tag description')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createTag({
|
||||
tag: cmdOpts.tag,
|
||||
color: cmdOpts.color,
|
||||
description: cmdOpts.description,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.createTag({
|
||||
tag: cmdOpts.tag,
|
||||
color: cmdOpts.color,
|
||||
description: cmdOpts.description,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
tags
|
||||
@@ -55,10 +63,14 @@ export function registerTagsCommand(program: Command) {
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateTag(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updateTag(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
tags
|
||||
@@ -66,9 +78,13 @@ export function registerTagsCommand(program: Command) {
|
||||
.description('Delete a tag')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteTag(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteTag(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,14 +18,18 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTransactions(
|
||||
cmdOpts.account,
|
||||
cmdOpts.start,
|
||||
cmdOpts.end,
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getTransactions(
|
||||
cmdOpts.account,
|
||||
cmdOpts.start,
|
||||
cmdOpts.end,
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
transactions
|
||||
@@ -41,20 +45,24 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.option('--run-transfers', 'Process transfers', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.addTransactions
|
||||
>[1];
|
||||
const result = await api.addTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
learnCategories: cmdOpts.learnCategories,
|
||||
runTransfers: cmdOpts.runTransfers,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.addTransactions
|
||||
>[1];
|
||||
const result = await api.addTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
learnCategories: cmdOpts.learnCategories,
|
||||
runTransfers: cmdOpts.runTransfers,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
transactions
|
||||
@@ -69,20 +77,24 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.option('--dry-run', 'Preview without importing', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.importTransactions
|
||||
>[1];
|
||||
const result = await api.importTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
defaultCleared: true,
|
||||
dryRun: cmdOpts.dryRun,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.importTransactions
|
||||
>[1];
|
||||
const result = await api.importTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
defaultCleared: true,
|
||||
dryRun: cmdOpts.dryRun,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
transactions
|
||||
@@ -92,13 +104,17 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const fields = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateTransaction
|
||||
>[1];
|
||||
await api.updateTransaction(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const fields = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateTransaction
|
||||
>[1];
|
||||
await api.updateTransaction(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
transactions
|
||||
@@ -106,9 +122,13 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.description('Delete a transaction')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteTransaction(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteTransaction(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ describe('resolveConfig', () => {
|
||||
'ACTUAL_SYNC_ID',
|
||||
'ACTUAL_DATA_DIR',
|
||||
'ACTUAL_ENCRYPTION_PASSWORD',
|
||||
'ACTUAL_CACHE_TTL',
|
||||
'ACTUAL_LOCK_TIMEOUT',
|
||||
'ACTUAL_NO_LOCK',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -159,6 +162,125 @@ describe('resolveConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache options', () => {
|
||||
beforeEach(() => {
|
||||
process.env.ACTUAL_SERVER_URL = 'http://test';
|
||||
process.env.ACTUAL_PASSWORD = 'pw';
|
||||
});
|
||||
|
||||
it('defaults cacheTtl to 60 seconds', async () => {
|
||||
const config = await resolveConfig({});
|
||||
expect(config.cacheTtl).toBe(60);
|
||||
});
|
||||
|
||||
it('reads cacheTtl from env', async () => {
|
||||
process.env.ACTUAL_CACHE_TTL = '300';
|
||||
const config = await resolveConfig({});
|
||||
expect(config.cacheTtl).toBe(300);
|
||||
});
|
||||
|
||||
it('prefers cacheTtl from CLI flag', async () => {
|
||||
process.env.ACTUAL_CACHE_TTL = '300';
|
||||
const config = await resolveConfig({ cacheTtl: 10 });
|
||||
expect(config.cacheTtl).toBe(10);
|
||||
});
|
||||
|
||||
it('rejects negative cacheTtl', async () => {
|
||||
await expect(resolveConfig({ cacheTtl: -1 })).rejects.toThrow(/cacheTtl/);
|
||||
});
|
||||
|
||||
it('rejects non-integer cacheTtl from env', async () => {
|
||||
process.env.ACTUAL_CACHE_TTL = 'banana';
|
||||
await expect(resolveConfig({})).rejects.toThrow(/ACTUAL_CACHE_TTL/);
|
||||
});
|
||||
|
||||
it('defaults lockTimeout to 10 seconds', async () => {
|
||||
const config = await resolveConfig({});
|
||||
expect(config.lockTimeout).toBe(10);
|
||||
});
|
||||
|
||||
it('reads lockTimeout from env', async () => {
|
||||
process.env.ACTUAL_LOCK_TIMEOUT = '30';
|
||||
const config = await resolveConfig({});
|
||||
expect(config.lockTimeout).toBe(30);
|
||||
});
|
||||
|
||||
it('defaults refresh to false', async () => {
|
||||
const config = await resolveConfig({});
|
||||
expect(config.refresh).toBe(false);
|
||||
});
|
||||
|
||||
it('sets refresh when provided on CLI opts', async () => {
|
||||
const config = await resolveConfig({ refresh: true });
|
||||
expect(config.refresh).toBe(true);
|
||||
});
|
||||
|
||||
it('sets refresh when --no-cache is passed (cliOpts.cache === false)', async () => {
|
||||
const config = await resolveConfig({ cache: false });
|
||||
expect(config.refresh).toBe(true);
|
||||
});
|
||||
|
||||
it('does not set refresh when cliOpts.cache is true (flag absent)', async () => {
|
||||
const config = await resolveConfig({ cache: true });
|
||||
expect(config.refresh).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults noLock to false', async () => {
|
||||
const config = await resolveConfig({});
|
||||
expect(config.noLock).toBe(false);
|
||||
});
|
||||
|
||||
it('sets noLock when --no-lock is passed (cliOpts.lock === false)', async () => {
|
||||
const config = await resolveConfig({ lock: false });
|
||||
expect(config.noLock).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves noLock false when cliOpts.lock is true (flag absent)', async () => {
|
||||
const config = await resolveConfig({ lock: true });
|
||||
expect(config.noLock).toBe(false);
|
||||
});
|
||||
|
||||
it('parses ACTUAL_NO_LOCK=1 as true', async () => {
|
||||
process.env.ACTUAL_NO_LOCK = '1';
|
||||
const config = await resolveConfig({});
|
||||
expect(config.noLock).toBe(true);
|
||||
});
|
||||
|
||||
it('parses ACTUAL_NO_LOCK=true as true', async () => {
|
||||
process.env.ACTUAL_NO_LOCK = 'true';
|
||||
const config = await resolveConfig({});
|
||||
expect(config.noLock).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on an invalid ACTUAL_NO_LOCK value', async () => {
|
||||
process.env.ACTUAL_NO_LOCK = 'yes';
|
||||
await expect(resolveConfig({})).rejects.toThrow(/ACTUAL_NO_LOCK/);
|
||||
});
|
||||
|
||||
it('reads cacheTtl/lockTimeout/noLock from config file', async () => {
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'pw',
|
||||
cacheTtl: 120,
|
||||
lockTimeout: 5,
|
||||
noLock: true,
|
||||
});
|
||||
const config = await resolveConfig({});
|
||||
expect(config.cacheTtl).toBe(120);
|
||||
expect(config.lockTimeout).toBe(5);
|
||||
expect(config.noLock).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-number cacheTtl in config file', async () => {
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'pw',
|
||||
cacheTtl: 'soon',
|
||||
});
|
||||
await expect(resolveConfig({})).rejects.toThrow(/cacheTtl/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosmiconfig handling', () => {
|
||||
it('handles null result (no config file found)', async () => {
|
||||
mockConfigFile(null);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from 'path';
|
||||
|
||||
import { cosmiconfig } from 'cosmiconfig';
|
||||
|
||||
import { isRecord } from './utils';
|
||||
import { isRecord, parseBoolEnv, parseNonNegativeIntFlag } from './utils';
|
||||
|
||||
export type CliConfig = {
|
||||
serverUrl: string;
|
||||
@@ -12,6 +12,10 @@ export type CliConfig = {
|
||||
syncId?: string;
|
||||
dataDir: string;
|
||||
encryptionPassword?: string;
|
||||
cacheTtl: number;
|
||||
lockTimeout: number;
|
||||
refresh: boolean;
|
||||
noLock: boolean;
|
||||
};
|
||||
|
||||
export type CliGlobalOpts = {
|
||||
@@ -21,10 +25,29 @@ export type CliGlobalOpts = {
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
cacheTtl?: number;
|
||||
lockTimeout?: number;
|
||||
refresh?: boolean;
|
||||
// Commander stores --no-foo flags under the positive key. Default true,
|
||||
// false when the flag is passed.
|
||||
cache?: boolean;
|
||||
lock?: boolean;
|
||||
format?: 'json' | 'table' | 'csv';
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
const stringKeys = [
|
||||
'serverUrl',
|
||||
'password',
|
||||
'sessionToken',
|
||||
'syncId',
|
||||
'dataDir',
|
||||
'encryptionPassword',
|
||||
] as const;
|
||||
|
||||
const numberKeys = ['cacheTtl', 'lockTimeout'] as const;
|
||||
const booleanKeys = ['noLock'] as const;
|
||||
|
||||
type ConfigFileContent = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
@@ -32,15 +55,15 @@ type ConfigFileContent = {
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
cacheTtl?: number;
|
||||
lockTimeout?: number;
|
||||
noLock?: boolean;
|
||||
};
|
||||
|
||||
const configFileKeys: readonly string[] = [
|
||||
'serverUrl',
|
||||
'password',
|
||||
'sessionToken',
|
||||
'syncId',
|
||||
'dataDir',
|
||||
'encryptionPassword',
|
||||
...stringKeys,
|
||||
...numberKeys,
|
||||
...booleanKeys,
|
||||
];
|
||||
|
||||
function validateConfigFileContent(value: unknown): ConfigFileContent {
|
||||
@@ -54,9 +77,30 @@ function validateConfigFileContent(value: unknown): ConfigFileContent {
|
||||
if (!configFileKeys.includes(key)) {
|
||||
throw new Error(`Invalid config file: unknown key "${key}"`);
|
||||
}
|
||||
if (value[key] !== undefined && typeof value[key] !== 'string') {
|
||||
const v = value[key];
|
||||
if (v === undefined) continue;
|
||||
if (
|
||||
(stringKeys as readonly string[]).includes(key) &&
|
||||
typeof v !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`,
|
||||
`Invalid config file: key "${key}" must be a string, got ${typeof v}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(numberKeys as readonly string[]).includes(key) &&
|
||||
(typeof v !== 'number' || !Number.isInteger(v) || v < 0)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid config file: key "${key}" must be a non-negative integer`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(booleanKeys as readonly string[]).includes(key) &&
|
||||
typeof v !== 'boolean'
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid config file: key "${key}" must be a boolean, got ${typeof v}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +127,22 @@ async function loadConfigFile(): Promise<ConfigFileContent> {
|
||||
return {};
|
||||
}
|
||||
|
||||
function parseNonNegativeIntEnv(
|
||||
raw: string | undefined,
|
||||
source: string,
|
||||
): number | undefined {
|
||||
return raw === undefined ? undefined : parseNonNegativeIntFlag(raw, source);
|
||||
}
|
||||
|
||||
function validateNonNegativeInt(value: number, name: string): number {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(
|
||||
`Invalid ${name}: expected a non-negative integer, got ${value}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function resolveConfig(
|
||||
cliOpts: CliGlobalOpts,
|
||||
): Promise<CliConfig> {
|
||||
@@ -128,6 +188,37 @@ export async function resolveConfig(
|
||||
);
|
||||
}
|
||||
|
||||
const cacheTtl = validateNonNegativeInt(
|
||||
cliOpts.cacheTtl ??
|
||||
parseNonNegativeIntEnv(
|
||||
process.env.ACTUAL_CACHE_TTL,
|
||||
'ACTUAL_CACHE_TTL',
|
||||
) ??
|
||||
fileConfig.cacheTtl ??
|
||||
60,
|
||||
'cacheTtl',
|
||||
);
|
||||
|
||||
const lockTimeout = validateNonNegativeInt(
|
||||
cliOpts.lockTimeout ??
|
||||
parseNonNegativeIntEnv(
|
||||
process.env.ACTUAL_LOCK_TIMEOUT,
|
||||
'ACTUAL_LOCK_TIMEOUT',
|
||||
) ??
|
||||
fileConfig.lockTimeout ??
|
||||
10,
|
||||
'lockTimeout',
|
||||
);
|
||||
|
||||
const refresh = (cliOpts.refresh ?? false) || cliOpts.cache === false;
|
||||
|
||||
const flagNoLock = cliOpts.lock === false ? true : undefined;
|
||||
const noLock =
|
||||
flagNoLock ??
|
||||
parseBoolEnv(process.env.ACTUAL_NO_LOCK, 'ACTUAL_NO_LOCK') ??
|
||||
fileConfig.noLock ??
|
||||
false;
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
password,
|
||||
@@ -135,5 +226,9 @@ export async function resolveConfig(
|
||||
syncId,
|
||||
dataDir,
|
||||
encryptionPassword,
|
||||
cacheTtl,
|
||||
lockTimeout,
|
||||
refresh,
|
||||
noLock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import { getMetaDir, writeCacheState } from './cache';
|
||||
import { resolveConfig } from './config';
|
||||
import { withConnection } from './connection';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
downloadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
loadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
sync: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
getBudgets: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'bud-disk-1', groupId: 'sync-1' }]),
|
||||
}));
|
||||
|
||||
vi.mock('./config', () => ({
|
||||
resolveConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
function metaDirFor(syncId: string) {
|
||||
return getMetaDir(dataDir, syncId);
|
||||
}
|
||||
|
||||
function setConfig(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(resolveConfig).mockResolvedValue({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
syncId: 'budget-1',
|
||||
dataDir,
|
||||
syncId: 'sync-1',
|
||||
cacheTtl: 60,
|
||||
lockTimeout: 10,
|
||||
refresh: false,
|
||||
noLock: true,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -31,104 +51,182 @@ describe('withConnection', () => {
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'actual-cli-conn-'));
|
||||
setConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('calls api.init with password when no sessionToken', async () => {
|
||||
setConfig({ password: 'pw', sessionToken: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
dataDir,
|
||||
verbose: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls api.init with sessionToken when present', async () => {
|
||||
setConfig({ sessionToken: 'tok', password: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
sessionToken: 'tok',
|
||||
dataDir: '/tmp/data',
|
||||
dataDir,
|
||||
verbose: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls api.downloadBudget when syncId is set', async () => {
|
||||
setConfig({ syncId: 'budget-1' });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.downloadBudget).toHaveBeenCalledWith('budget-1', {
|
||||
it('first run: calls downloadBudget and writes cache state', async () => {
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.downloadBudget).toHaveBeenCalledWith('sync-1', {
|
||||
password: undefined,
|
||||
});
|
||||
expect(api.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when loadBudget is true but syncId is not set', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
|
||||
await expect(withConnection({}, async () => 'ok')).rejects.toThrow(
|
||||
'Sync ID is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips budget download when loadBudget is false and syncId is not set', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok', { loadBudget: false });
|
||||
|
||||
it('skips sync on a read inside the TTL', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.loadBudget).toHaveBeenCalledWith('bud-disk-1');
|
||||
expect(api.sync).not.toHaveBeenCalled();
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call api.downloadBudget when loadBudget is false', async () => {
|
||||
setConfig({ syncId: 'budget-1' });
|
||||
|
||||
await withConnection({}, async () => 'ok', { loadBudget: false });
|
||||
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
it('syncs on a read past the TTL', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now() - 10 * 60_000,
|
||||
lastDownloadedAt: Date.now() - 10 * 60_000,
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.loadBudget).toHaveBeenCalled();
|
||||
expect(api.sync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns callback result', async () => {
|
||||
const result = await withConnection({}, async () => 42);
|
||||
it('write command syncs before and after the callback, even when fresh', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: true });
|
||||
expect(api.loadBudget).toHaveBeenCalled();
|
||||
expect(api.sync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('--refresh forces a sync on a read inside the TTL', async () => {
|
||||
setConfig({ refresh: true });
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.sync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('encrypted budget forces a sync on a read inside the TTL', async () => {
|
||||
setConfig({ encryptionPassword: 'secret' });
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.sync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidates cache when syncId changes', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'OTHER',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.downloadBudget).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips budget work when skipBudget is true', async () => {
|
||||
await withConnection({}, async () => 'ok', {
|
||||
mutates: false,
|
||||
skipBudget: true,
|
||||
});
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
expect(api.loadBudget).not.toHaveBeenCalled();
|
||||
expect(api.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when syncId is missing and skipBudget is false', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
await expect(
|
||||
withConnection({}, async () => 'ok', { mutates: false }),
|
||||
).rejects.toThrow('Sync ID is required');
|
||||
});
|
||||
|
||||
it('returns the callback result', async () => {
|
||||
const result = await withConnection({}, async () => 42, {
|
||||
mutates: false,
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on success', async () => {
|
||||
await withConnection({}, async () => 'ok');
|
||||
it('calls api.shutdown on success', async () => {
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on error', async () => {
|
||||
it('calls api.shutdown on error', async () => {
|
||||
await expect(
|
||||
withConnection({}, async () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
withConnection(
|
||||
{},
|
||||
async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
{ mutates: false },
|
||||
),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
expect(api.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write to stderr by default', async () => {
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(stderrSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes info to stderr when verbose', async () => {
|
||||
await withConnection({ verbose: true }, async () => 'ok');
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Connecting to'),
|
||||
);
|
||||
it('propagates sync errors on a stale read', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now() - 10 * 60_000,
|
||||
lastDownloadedAt: Date.now() - 10 * 60_000,
|
||||
});
|
||||
vi.mocked(api.sync).mockRejectedValueOnce(new Error('network'));
|
||||
await expect(
|
||||
withConnection({}, async () => 'ok', { mutates: false }),
|
||||
).rejects.toThrow('network');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import type { CacheState } from './cache';
|
||||
import {
|
||||
CACHE_VERSION,
|
||||
decideSyncAction,
|
||||
getMetaDir,
|
||||
readCacheState,
|
||||
writeCacheState,
|
||||
} from './cache';
|
||||
import type { CliConfig, CliGlobalOpts } from './config';
|
||||
import { resolveConfig } from './config';
|
||||
import type { CliGlobalOpts } from './config';
|
||||
|
||||
function info(message: string, verbose?: boolean) {
|
||||
if (verbose) {
|
||||
process.stderr.write(message + '\n');
|
||||
}
|
||||
}
|
||||
import { acquireExclusive, acquireShared } from './lock';
|
||||
import type { Release } from './lock';
|
||||
|
||||
type ConnectionOptions = {
|
||||
loadBudget?: boolean;
|
||||
mutates: boolean;
|
||||
skipBudget?: boolean;
|
||||
};
|
||||
|
||||
function info(message: string, verbose?: boolean) {
|
||||
if (verbose) process.stderr.write(message + '\n');
|
||||
}
|
||||
|
||||
async function resolveBudgetIdForSyncId(syncId: string): Promise<string> {
|
||||
const budgets = await api.getBudgets();
|
||||
const match = budgets.find(
|
||||
b =>
|
||||
typeof b.id === 'string' &&
|
||||
(b.groupId === syncId || b.cloudFileId === syncId),
|
||||
);
|
||||
if (!match?.id) {
|
||||
throw new Error(
|
||||
`Could not resolve on-disk budget id for syncId ${syncId} after download.`,
|
||||
);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
|
||||
export async function withConnection<T>(
|
||||
globalOpts: CliGlobalOpts,
|
||||
fn: () => Promise<T>,
|
||||
options: ConnectionOptions = {},
|
||||
fn: (config: CliConfig) => Promise<T>,
|
||||
{ mutates, skipBudget = false }: ConnectionOptions,
|
||||
): Promise<T> {
|
||||
const { loadBudget = true } = options;
|
||||
const config = await resolveConfig(globalOpts);
|
||||
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
|
||||
info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose);
|
||||
|
||||
if (config.sessionToken) {
|
||||
@@ -48,17 +67,87 @@ export async function withConnection<T>(
|
||||
}
|
||||
|
||||
try {
|
||||
if (loadBudget && config.syncId) {
|
||||
info(`Downloading budget ${config.syncId}...`, globalOpts.verbose);
|
||||
await api.downloadBudget(config.syncId, {
|
||||
password: config.encryptionPassword,
|
||||
});
|
||||
} else if (loadBudget && !config.syncId) {
|
||||
if (skipBudget) return await fn(config);
|
||||
if (!config.syncId) {
|
||||
throw new Error(
|
||||
'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.',
|
||||
);
|
||||
}
|
||||
return await fn();
|
||||
|
||||
const meta = getMetaDir(config.dataDir, config.syncId);
|
||||
let release: Release | null = null;
|
||||
if (!config.noLock) {
|
||||
release = mutates
|
||||
? await acquireExclusive(meta, {
|
||||
timeoutMs: config.lockTimeout * 1000,
|
||||
})
|
||||
: await acquireShared(meta, {
|
||||
timeoutMs: config.lockTimeout * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const cachedState = readCacheState(meta);
|
||||
const decision = decideSyncAction({
|
||||
state: cachedState,
|
||||
config: { syncId: config.syncId, serverUrl: config.serverUrl },
|
||||
now: Date.now(),
|
||||
ttlMs: config.cacheTtl * 1000,
|
||||
mutates,
|
||||
refresh: config.refresh,
|
||||
encrypted: Boolean(config.encryptionPassword),
|
||||
});
|
||||
|
||||
let state: CacheState;
|
||||
if (decision.action === 'download') {
|
||||
info(
|
||||
cachedState === null
|
||||
? `Downloading budget ${config.syncId} for the first time...`
|
||||
: `Re-downloading budget ${config.syncId} (cache invalidated)...`,
|
||||
globalOpts.verbose,
|
||||
);
|
||||
await api.downloadBudget(config.syncId, {
|
||||
password: config.encryptionPassword,
|
||||
});
|
||||
const budgetId = await resolveBudgetIdForSyncId(config.syncId);
|
||||
const now = Date.now();
|
||||
state = {
|
||||
version: CACHE_VERSION,
|
||||
syncId: config.syncId,
|
||||
budgetId,
|
||||
serverUrl: config.serverUrl,
|
||||
lastSyncedAt: now,
|
||||
lastDownloadedAt: now,
|
||||
};
|
||||
writeCacheState(meta, state);
|
||||
} else if (decision.action === 'skip') {
|
||||
const age = Math.round(
|
||||
(Date.now() - decision.state.lastSyncedAt) / 1000,
|
||||
);
|
||||
info(`Using cached budget (synced ${age}s ago)...`, globalOpts.verbose);
|
||||
await api.loadBudget(decision.state.budgetId);
|
||||
state = decision.state;
|
||||
} else {
|
||||
info(`Syncing budget ${config.syncId}...`, globalOpts.verbose);
|
||||
await api.loadBudget(decision.state.budgetId);
|
||||
await api.sync();
|
||||
state = { ...decision.state, lastSyncedAt: Date.now() };
|
||||
writeCacheState(meta, state);
|
||||
}
|
||||
|
||||
const result = await fn(config);
|
||||
|
||||
if (mutates) {
|
||||
info(`Pushing changes for ${config.syncId}...`, globalOpts.verbose);
|
||||
await api.sync();
|
||||
state = { ...state, lastSyncedAt: Date.now() };
|
||||
writeCacheState(meta, state);
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
if (release) await release();
|
||||
}
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import { registerQueryCommand } from './commands/query';
|
||||
import { registerRulesCommand } from './commands/rules';
|
||||
import { registerSchedulesCommand } from './commands/schedules';
|
||||
import { registerServerCommand } from './commands/server';
|
||||
import { registerSyncCommand } from './commands/sync';
|
||||
import { registerTagsCommand } from './commands/tags';
|
||||
import { registerTransactionsCommand } from './commands/transactions';
|
||||
import { parseNonNegativeIntFlag } from './utils';
|
||||
|
||||
declare const __CLI_VERSION__: string;
|
||||
|
||||
@@ -32,6 +34,22 @@ program
|
||||
'--encryption-password <password>',
|
||||
'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)',
|
||||
)
|
||||
.option(
|
||||
'--cache-ttl <seconds>',
|
||||
'Cache TTL in seconds (env: ACTUAL_CACHE_TTL; default: 60)',
|
||||
value => parseNonNegativeIntFlag(value, '--cache-ttl'),
|
||||
)
|
||||
.option('--refresh', 'Force a sync on this call, ignoring the cache', false)
|
||||
.option('--no-cache', 'Alias for --refresh')
|
||||
.option(
|
||||
'--lock-timeout <seconds>',
|
||||
'How long to wait for another CLI process to release the lock (env: ACTUAL_LOCK_TIMEOUT; default: 10)',
|
||||
value => parseNonNegativeIntFlag(value, '--lock-timeout'),
|
||||
)
|
||||
.option(
|
||||
'--no-lock',
|
||||
'Disable the budget directory lock (use with care, env: ACTUAL_NO_LOCK)',
|
||||
)
|
||||
.addOption(
|
||||
new Option('--format <format>', 'Output format: json, table, csv')
|
||||
.choices(['json', 'table', 'csv'] as const)
|
||||
@@ -50,6 +68,7 @@ registerRulesCommand(program);
|
||||
registerSchedulesCommand(program);
|
||||
registerQueryCommand(program);
|
||||
registerServerCommand(program);
|
||||
registerSyncCommand(program);
|
||||
|
||||
function normalizeThrownMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
|
||||
159
packages/cli/src/lock.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { acquireExclusive, acquireShared } from './lock';
|
||||
|
||||
// In-memory stand-in for proper-lockfile. The real library spins up a
|
||||
// setTimeout loop to refresh lockfile mtimes; on some CI filesystems that
|
||||
// timer keeps Node's event loop alive even after tests complete, wedging the
|
||||
// test run. The mock behaves identically from our wrapper's perspective
|
||||
// (acquire, detect contention with ELOCKED, release) without touching the
|
||||
// filesystem or scheduling timers.
|
||||
const mockHeld = new Set<string>();
|
||||
|
||||
vi.mock('proper-lockfile', () => ({
|
||||
default: {
|
||||
lock: vi.fn(
|
||||
async (
|
||||
file: string,
|
||||
opts?: { lockfilePath?: string },
|
||||
): Promise<() => Promise<void>> => {
|
||||
const key = opts?.lockfilePath ?? file;
|
||||
if (mockHeld.has(key)) {
|
||||
const err = new Error('Lock is already held') as Error & {
|
||||
code?: string;
|
||||
};
|
||||
err.code = 'ELOCKED';
|
||||
throw err;
|
||||
}
|
||||
mockHeld.add(key);
|
||||
return async () => {
|
||||
mockHeld.delete(key);
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('acquireExclusive', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHeld.clear();
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the directory if it does not exist', async () => {
|
||||
const target = join(dir, 'nested', 'budget');
|
||||
const release = await acquireExclusive(target, { timeoutMs: 1000 });
|
||||
expect(existsSync(target)).toBe(true);
|
||||
await release();
|
||||
});
|
||||
|
||||
it('returns a release function that frees the lock', async () => {
|
||||
const release1 = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
await release1();
|
||||
const release2 = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
await release2();
|
||||
});
|
||||
|
||||
it('rejects with a user-friendly error when another holder has the lock', async () => {
|
||||
const release = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
await expect(acquireExclusive(dir, { timeoutMs: 100 })).rejects.toThrow(
|
||||
/holding the budget/,
|
||||
);
|
||||
await release();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireShared', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHeld.clear();
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('allows multiple concurrent shared holders', async () => {
|
||||
const r1 = await acquireShared(dir, { timeoutMs: 1000 });
|
||||
const r2 = await acquireShared(dir, { timeoutMs: 1000 });
|
||||
const readers = readdirSync(join(dir, 'readers'));
|
||||
expect(readers).toHaveLength(2);
|
||||
await r1();
|
||||
await r2();
|
||||
});
|
||||
|
||||
it('removes the reader marker on release', async () => {
|
||||
const release = await acquireShared(dir, { timeoutMs: 1000 });
|
||||
await release();
|
||||
const readers = readdirSync(join(dir, 'readers'));
|
||||
expect(readers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects when an exclusive lock is held', async () => {
|
||||
const releaseExclusive = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
await expect(acquireShared(dir, { timeoutMs: 100 })).rejects.toThrow(
|
||||
/holding the budget/,
|
||||
);
|
||||
await releaseExclusive();
|
||||
});
|
||||
|
||||
it('sweeps stale reader markers whose PIDs no longer exist', async () => {
|
||||
const readersDir = join(dir, 'readers');
|
||||
mkdirSync(readersDir, { recursive: true });
|
||||
writeFileSync(join(readersDir, '-1-abc'), '');
|
||||
|
||||
const release = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
expect(readdirSync(readersDir)).toHaveLength(0);
|
||||
await release();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writer-reader interaction', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHeld.clear();
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('exclusive waits for active shared holders to release', async () => {
|
||||
const readerRelease = await acquireShared(dir, { timeoutMs: 500 });
|
||||
|
||||
let writerAcquired = false;
|
||||
const writerPromise = acquireExclusive(dir, { timeoutMs: 1000 }).then(
|
||||
release => {
|
||||
writerAcquired = true;
|
||||
return release;
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
expect(writerAcquired).toBe(false);
|
||||
|
||||
await readerRelease();
|
||||
const writerRelease = await writerPromise;
|
||||
expect(writerAcquired).toBe(true);
|
||||
await writerRelease();
|
||||
});
|
||||
});
|
||||
149
packages/cli/src/lock.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import lockfile from 'proper-lockfile';
|
||||
|
||||
export type Release = () => Promise<void>;
|
||||
|
||||
export type AcquireOptions = {
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
const LOCKFILE_NAME = 'lock';
|
||||
const READERS_DIR_NAME = 'readers';
|
||||
const READER_POLL_INTERVAL_MS = 100;
|
||||
|
||||
function lockfilePath(dir: string): string {
|
||||
return join(dir, LOCKFILE_NAME);
|
||||
}
|
||||
|
||||
function readersDir(dir: string): string {
|
||||
return join(dir, READERS_DIR_NAME);
|
||||
}
|
||||
|
||||
function ensureDir(dir: string) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function retriesForTimeout(timeoutMs: number) {
|
||||
return {
|
||||
retries: Math.max(1, Math.floor(timeoutMs / 200)),
|
||||
minTimeout: 100,
|
||||
maxTimeout: 500,
|
||||
factor: 1.5,
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(err: unknown): string | undefined {
|
||||
if (err instanceof Error && 'code' in err) {
|
||||
const { code } = err as { code?: unknown };
|
||||
if (typeof code === 'string') return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLockedError(err: unknown): boolean {
|
||||
return errorCode(err) === 'ELOCKED';
|
||||
}
|
||||
|
||||
function lockedMessage(timeoutMs: number): string {
|
||||
return `Another CLI process is holding the budget (waited ${Math.round(
|
||||
timeoutMs / 1000,
|
||||
)}s). Retry, or use a different --data-dir.`;
|
||||
}
|
||||
|
||||
function pidIsAlive(pid: number): boolean {
|
||||
if (pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return errorCode(err) === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
function readReaderNames(readers: string): string[] {
|
||||
try {
|
||||
return readdirSync(readers);
|
||||
} catch (err) {
|
||||
if (errorCode(err) === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function sweepStaleReaders(dir: string) {
|
||||
const readers = readersDir(dir);
|
||||
for (const name of readReaderNames(readers)) {
|
||||
const pid = Number(name.split('-')[0]);
|
||||
if (!Number.isFinite(pid) || !pidIsAlive(pid)) {
|
||||
rmSync(join(readers, name), { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForReadersEmpty(dir: string, timeoutMs: number) {
|
||||
const readers = readersDir(dir);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
sweepStaleReaders(dir);
|
||||
if (readReaderNames(readers).length === 0) return;
|
||||
await new Promise(resolve => setTimeout(resolve, READER_POLL_INTERVAL_MS));
|
||||
}
|
||||
throw new Error(lockedMessage(timeoutMs));
|
||||
}
|
||||
|
||||
async function acquireGate(
|
||||
dir: string,
|
||||
timeoutMs: number,
|
||||
): Promise<() => Promise<void>> {
|
||||
ensureDir(dir);
|
||||
try {
|
||||
return await lockfile.lock(dir, {
|
||||
lockfilePath: lockfilePath(dir),
|
||||
retries: retriesForTimeout(timeoutMs),
|
||||
stale: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isLockedError(err)) throw new Error(lockedMessage(timeoutMs));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireExclusive(
|
||||
dir: string,
|
||||
{ timeoutMs }: AcquireOptions,
|
||||
): Promise<Release> {
|
||||
const start = Date.now();
|
||||
const release = await acquireGate(dir, timeoutMs);
|
||||
try {
|
||||
const remaining = Math.max(0, timeoutMs - (Date.now() - start));
|
||||
await waitForReadersEmpty(dir, remaining);
|
||||
} catch (err) {
|
||||
await release();
|
||||
throw err;
|
||||
}
|
||||
return () => release();
|
||||
}
|
||||
|
||||
export async function acquireShared(
|
||||
dir: string,
|
||||
{ timeoutMs }: AcquireOptions,
|
||||
): Promise<Release> {
|
||||
const gate = await acquireGate(dir, timeoutMs);
|
||||
let markerPath: string;
|
||||
try {
|
||||
const readers = readersDir(dir);
|
||||
ensureDir(readers);
|
||||
const markerName = `${process.pid}-${randomBytes(6).toString('hex')}`;
|
||||
markerPath = join(readers, markerName);
|
||||
writeFileSync(markerPath, '');
|
||||
} catch (err) {
|
||||
await gate();
|
||||
throw err;
|
||||
}
|
||||
await gate();
|
||||
return async () => {
|
||||
rmSync(markerPath, { force: true });
|
||||
};
|
||||
}
|
||||
@@ -18,3 +18,29 @@ export function parseIntFlag(value: string, flagName: string): number {
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseNonNegativeIntFlag(
|
||||
value: string,
|
||||
flagName: string,
|
||||
): number {
|
||||
const parsed = parseIntFlag(value, flagName);
|
||||
if (parsed < 0) {
|
||||
throw new Error(
|
||||
`Invalid ${flagName}: "${value}". Expected a non-negative integer.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseBoolEnv(
|
||||
raw: string | undefined,
|
||||
source: string,
|
||||
): boolean | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const lower = raw.toLowerCase();
|
||||
if (raw === '1' || lower === 'true') return true;
|
||||
if (raw === '0' || lower === 'false') return false;
|
||||
throw new Error(
|
||||
`Invalid ${source}: "${raw}". Expected "true", "false", "1", or "0".`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,5 +32,8 @@ export default defineConfig({
|
||||
plugins: [visualizer({ template: 'raw-data', filename: 'dist/stats.json' })],
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,14 +30,14 @@
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.21.4",
|
||||
"murmurhash": "^2.0.1",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-protobuf": "3.15.12",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"emitDeclarationOnly": false
|
||||
},
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
1
packages/desktop-client/.gitignore
vendored
@@ -8,6 +8,7 @@ coverage
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
.playwright-cli
|
||||
|
||||
# production
|
||||
build
|
||||
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 81 KiB |
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
9
packages/desktop-client/e2e/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -86,7 +86,10 @@
|
||||
'Arial',
|
||||
sans-serif
|
||||
);
|
||||
font-feature-settings: 'ss01', 'ss04';
|
||||
font-feature-settings:
|
||||
'ss01',
|
||||
'ss04',
|
||||
'calt' 0;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -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",
|
||||
@@ -191,7 +199,7 @@
|
||||
"sass": "^1.99.0",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "^8.0.5",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^4.1.2"
|
||||
|
||||
@@ -648,6 +648,13 @@ type ApplyBudgetActionPayload =
|
||||
args: {
|
||||
category: CategoryEntity['id'];
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'copy-until-year-end';
|
||||
month: string;
|
||||
args: {
|
||||
category: CategoryEntity['id'];
|
||||
};
|
||||
};
|
||||
|
||||
export function useBudgetActions() {
|
||||
@@ -777,6 +784,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)}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -208,6 +208,19 @@ export function BankSyncCheckboxOptions({
|
||||
<Trans>Reimport deleted transactions</Trans>
|
||||
</CheckboxOptionWithHelp>
|
||||
|
||||
<CheckboxOptionWithHelp
|
||||
id="form_update_dates"
|
||||
checked={updateDates}
|
||||
onChange={() => setUpdateDates(!updateDates)}
|
||||
disabled={!importTransactions}
|
||||
helpText={t(
|
||||
'By enabling this, the transaction date will be overwritten by the one provided by the bank.',
|
||||
)}
|
||||
helpMode={helpMode}
|
||||
>
|
||||
<Trans>Update Dates</Trans>
|
||||
</CheckboxOptionWithHelp>
|
||||
|
||||
<CheckboxOptionWithHelp
|
||||
id="form_import_transactions"
|
||||
checked={!importTransactions}
|
||||
@@ -219,18 +232,6 @@ export function BankSyncCheckboxOptions({
|
||||
>
|
||||
<Trans>Investment Account</Trans>
|
||||
</CheckboxOptionWithHelp>
|
||||
|
||||
<CheckboxOptionWithHelp
|
||||
id="form_update_dates"
|
||||
checked={updateDates}
|
||||
onChange={() => setUpdateDates(!updateDates)}
|
||||
helpText={t(
|
||||
'By enabling this, the transaction date will be overwritten by the one provided by the bank.',
|
||||
)}
|
||||
helpMode={helpMode}
|
||||
>
|
||||
<Trans>Update Dates</Trans>
|
||||
</CheckboxOptionWithHelp>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 “{{ name: error.name }}”</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’t run until it’s linked to a schedule.
|
||||
</Trans>
|
||||
);
|
||||
case 'refill-no-cap':
|
||||
return (
|
||||
<Trans>
|
||||
Refill automations must have a “Balance cap” 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 “{{ source: error.source }}” 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’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 “Whatever is left”.
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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: '' }
|
||||
: {}),
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||