{% extends "base.html" %} {% block title %}{{ _("Settings") }} · Wealth Dashboard{% endblock %} {% block heading %}{{ _("Settings") }}{% endblock %} {% block content %} {% if section == 'general' %}

{{ _("General") }}

{{ _("Changes the language of the app, and with it how numbers and dates are written. It does not touch what your bank sent: a transaction described in German stays in German.") }}

{{ _("Which units a sale sells decides what it made. FIFO is what Germany taxes on and what Portfolio Performance shows; average cost is the French prix moyen pondéré. Switzerland taxes no private capital gain, so a Swiss reader may pick either. Nothing is stored: switching recomputes every figure.") }}

{{ _("Local time of the machine this runs on. A day that was slept through — the machine was off at that hour — is caught up as soon as it is next awake.") }}

{{ _("Asks PyPI which version is the newest — one small request for a public page, with nothing about you in it. When there is a newer one, the version in the menu gets a dot and says what to do.") }}

{{ _("Where your bank sends you back after you authorise. This exact string must also be registered in the Enable Banking Control Panel — if the two differ by so much as a trailing slash, the bank refuses the handover and the error it shows names nothing useful.") }}

{% endif %} {% if section == 'banks' %}

{{ _("Bank sync") }}

{{ _n(connected_links, "{n} account connected", "{n} accounts connected") }}

{% if last_auto_sync %}{{ _f("Last automatic sync: {when}.", when=last_auto_sync[:16].replace('T', ' ')) }} {% else %}{{ _("No automatic sync has run yet.") }}{% endif %} {% if cfg.auto_sync %}{{ _f("The next one is at {time}.", time=cfg.sync_time) }}{% else %}{{ _("Automatic sync is off.") }}{% endif %}

{% endif %} {% if section == 'market' %}

{{ _("Exchange rates") }}

{{ _("European Central Bank") }}

{{ _("The ECB publishes euro reference rates every business day — free, without a key and without an account. They are what converts an amount in another currency into your base currency, and every total built from them names the day they were published.") }}

{% if rates.as_of %}

{{ _n(rates.count, "{n} currency, published {date}.", "{n} currencies, published {date}.", date=d(rates.as_of)) }} {% if rates.weekend %}{{ _("The ECB does not publish at the weekend, so this is Friday's — which is also the newest rate there is.") }}{% endif %}

{% else %}
{{ _("No rates yet, so amounts in another currency are reported beside your totals rather than inside them. Fetching them needs this machine to reach the internet once.") }}
{% endif %}

{{ _("Also updated on start-up, at most once a day, in the background. Nothing waits on it: a page renders whether or not the rates arrived.") }}

{% endif %} {% if section == 'banks' and payslip_mappings %}

{{ _("Payslip mappings") }}

{{ _n(payslip_mappings | length, "{n} payslip layout you mapped yourself", "{n} payslip layouts you mapped yourself") }}
{% for m in payslip_mappings %} {% endfor %}
{{ _("Name") }}{{ _("Employer") }}{{ _("Earner") }}{{ _("Lines") }}{{ _("Since") }}
{{ m.name }}{{ m.employer }}{{ m.employee }} {% for b, labels in m.mapping.buckets.items() %}{{ b }} ← {{ labels | join(' + ') }}{% if not loop.last %} · {% endif %}{% endfor %} {{ d(m.created_at[:10]) }}

{{ _("A sheet naming one of these employers and earners is read through its mapping without asking. Forget one and the next such sheet asks again — the payslips already imported stay.") }}

{% endif %} {% if section == 'banks' and csv_mappings %}

{{ _("CSV mappings") }}

{{ _n(csv_mappings | length, "{n} file layout you mapped yourself", "{n} file layouts you mapped yourself") }}
{% for m in csv_mappings %} {% endfor %}
{{ _("Name") }}{{ _("Columns") }}{{ _("Since") }}
{{ m.name }} {% for f, col in m.mapping.items() if f not in ('negate', 'currency_fixed') %}{{ f }} ← {{ col }}{% if not loop.last %} · {% endif %}{% endfor %} {{ d(m.created_at[:10]) }}

{{ _("A file with one of these headers is imported through its mapping without asking. Forget one and the next such file asks again — the rows already imported stay.") }}

{% endif %} {% if section == 'market' %}

{{ _("Market prices") }}

{{ _("Yahoo Finance") }}

{{ _("Every holding is valued at its last market price — free, without a key — and every total built from prices names their day. A broker export gives an ISIN and a price source wants a ticker, so the ticker is looked up once and kept. Where the lookup fails or picks the wrong exchange, type the ticker Yahoo uses, like IWDA.AS; what you type is never replaced by a lookup.") }}

{% if securities %}
{% for x in securities %} {% endfor %}
{{ _("Security") }}{{ _("Ticker") }}{{ _("Price") }}
{{ x.name or x.isin }}{{ x.isin }}
{% if x.manual %}{{ _("typed in") }}{% endif %}
{% if x.price %}{{ qty(x.price) }} {{ x.currency }}{{ d(x.as_of) }} {% else %}—{% endif %} {% if x.error %}{{ x.error }}{% endif %}
{% else %}

{{ _("Nothing to price yet — holdings appear here once a broker export or a trade typed in by hand has given you one.") }}

{% endif %}

{{ _("Also updated on start-up and every few hours in the background. A holding no price could be found for is valued at your last trade, and the pages say so.") }}

{% endif %} {% if section == 'banks' %}

Saxo Bank

{% if saxo_state.connected and saxo_state.alive %}{{ _f("connected · client {id}", id=saxo_state.client_id) }} {% elif saxo_state.connected %}{{ _("login lapsed") }} {% elif saxo_state.configured %}{{ _("credentials saved, nothing connected yet") }} {% else %}{{ _("not set up") }}{% endif %} {% if saxo_state.environment == 'sim' %} · {{ _("simulation") }}{% endif %}

{{ _("Saxo's OpenAPI is OAuth: you register an application of your own in Saxo's developer portal, paste its AppKey and AppSecret here, and connect an account from its page — Saxo's login, then straight back. The tokens Saxo hands out die within the hour, so the app renews them every five minutes while it runs; if it was down for longer, the account page says so and connecting again is one click.") }}

  1. {{ _("At developer.saxo → Apps, create an application: Live (or Simulation, to try it against Saxo's demo account), grant type Authorization Code, and this exact redirect URL:") }}
    {{ saxo_state.redirect_uri }}
    {{ _("It is your dashboard's address plus /saxo/callback, taken from the redirect URL above. If Saxo will not accept it, register it anyway and use “Finish by hand” on the account page.") }}
  2. {{ _("Paste the AppKey and the AppSecret below.") }}
  3. {{ _("Open a broker account here and press Connect Saxo.") }}
{% if saxo_state.configured %}
{% endif %}
{% endif %} {% if section == 'banks' %}

Kraken

{% if kraken_state.configured %}{{ _("key saved") }}{% else %}{{ _("not set up") }}{% endif %}

{{ _("Kraken needs an API key of your own: kraken.com → Settings → API → Add key. Give it only Query Funds, Query Closed Orders & Trades and Query Ledger Entries — nothing that can trade, withdraw or stake. A key that can only read cannot lose you a coin. Paste the key and the private key here; the private key is shown once when the key is created and is kept 0600 beside the bank key.") }}

{% if kraken_state.configured %}
{% endif %}

Interactive Brokers

{% if ibkr_state.configured %}{{ _("token saved") }}{% else %}{{ _("not set up") }}{% endif %}

{{ _("No login and no app: IBKR's Flex Web Service. In Account Management → Reports → Flex Queries, make an Activity Flex Query with Trades, Cash Transactions, Open Positions and Cash Report ticked, period Last 365 Days, XML; note its id. Under Reports → Settings → Flex Web Service, switch it on and copy the token. The token can only run Flex queries — it cannot see your login, trade or move money. For what lies before the year the query covers, run it once for a custom period and drop the XML on the import page.") }}

{% if ibkr_state.configured %}
{% endif %}

Trading 212

{% if t212_state.configured %}{{ _("key saved") }}{% if t212_state.environment == 'demo' %} · {{ _("practice account") }}{% endif %}{% else %}{{ _("not set up") }}{% endif %}

{{ _("In the Trading 212 app: Settings → API (Beta) → Generate key. Give it only the read scopes — account data, portfolio, history — and paste the key and the secret here; the secret is shown once. Invest and Stocks ISA accounts only. The API allows six history calls a minute, so a first sync of years of orders takes a few minutes; later syncs are quick.") }}

{% if t212_state.configured %}
{% endif %}

Trade Republic

{% if tr_state.logged_in %}{{ _("logged in") }}{% elif tr_state.configured %}{{ _("not logged in") }}{% else %}{{ _("not set up") }}{% endif %} · {{ _("unofficial") }}

{{ _("Trade Republic publishes no API. This uses the interface its own web app uses — phone number, PIN, the app's approval, then the same WebSocket — and it may stop working any day, as it has before; the statement PDFs always read. The phone number and PIN are kept 0600 beside the other keys; a login lasts until Trade Republic ends it, and the account page says when it has to be renewed.") }}

{% if tr_state.configured %}

{{ _("Then log in from the account page: the app asks to approve, or for a code.") }}

{% endif %}
{% endif %} {% if section == 'banks' %}

{{ _("Document archive") }}

{% if archive_state.configured %}Paperless-ngx · {{ archive_state.url }}{% else %}{{ _("not set up") }}{% endif %}

{{ _("If your statements live in Paperless-ngx, the app can pull them itself. Give it the archive's address and an API token (Paperless → My Profile → the circular arrow makes one), then say on each account's edit page which documents are its — by tag, correspondent or query. Every new document is fetched on the daily sync and run through the same readers an upload gets; each one is remembered, so nothing comes in twice. The archive is read, never written.") }} {{ _("The pull is generic, the reading is not: a document becomes transactions only when there is a reader for its layout. One no reader understood is listed below with a link back, not skipped in silence.") }}

{% if archive_state.configured %}
{% if archive_state.unread %}
{% endif %}
{% if archive_state.last %}

{{ _f("Last pull {when}.", when=archive_state.last.at[:16].replace('T', ' ')) }}

{% endif %} {% if archive_state.filters %}
{% for f in archive_state.filters %} {% endfor %}
{{ _("Account") }}{{ _("Pulls") }}{{ _("Read") }}{{ _("Not read") }}{{ _("Failed") }}
{{ f.account }} {% if f.tags %}{{ _("tags") }} {{ f.tags }}{% endif %}{% if f.correspondent %} · {{ _("from") }} {{ f.correspondent }}{% endif %}{% if f.query %} · “{{ f.query }}”{% endif %} {{ f.imported or 0 }}{{ f.unread or 0 }}{{ f.failed or 0 }}
{% else %}

{{ _("No account pulls anything yet — open an account's edit page and say which documents are its.") }}

{% endif %} {% if archive_state.unread %}

{{ _("Could not be read") }}

{% endif %} {% endif %}
{% endif %} {% if section == 'market' %}

{{ _("Share Ideas") }}

{% if ideas.running %}{{ _("refreshing now") }} {% elif ideas.last_run %}{{ _f("last refreshed {when}", when=ideas.last_run[:16].replace('T', ' ')) }} {% else %}{{ _("never refreshed") }}{% endif %}

{{ _("The four boards under Share Ideas rank a fixed list of shares and ETFs on figures fetched from Yahoo — free, without a key. The cache is refreshed once a day in the background; the first refresh runs a minute after start-up. A refresh is a few hundred requests with a pause between them and takes a few minutes, so it runs on its own and the boards fill in as it goes.") }}

{{ _f("Shares: {n} cached, {errors} with a fetch error.", n=ideas.shares.n or 0, errors=ideas.shares.errors or 0) }} {{ _f("ETFs: {n} cached, {errors} with a fetch error.", n=ideas.etfs.n or 0, errors=ideas.etfs.errors or 0) }}

{{ _("To screen more names, or to correct an ETF's TER, edit screener_universe.json and screener_etf_universe.json in the data folder; thresholds live in screener.json beside them. All three are read on every page load.") }}

{% endif %} {% if section == 'assistants' %}

{{ _("Claude and other assistants (MCP)") }}

{% if mcp_token %}{{ _("a token exists") }}{% else %}{{ _("off — no token") }}{% endif %}

{{ _("An assistant that speaks MCP can read this dashboard and do the chores that are slow by hand — categorise the queue and teach the rules, set budgets, type in a transaction, star a share idea, start a sync. It cannot delete an account, change settings, or see your bank credentials. Access is by a token, which stands in for your password: keep it as private, and revoke it here the moment you are unsure.") }}

{% if mcp_token %}

{{ _("For Claude Code on your network, this is the whole setup:") }}

claude mcp add --transport http wealth {{ mcp_url }} --header "Authorization: Bearer {{ mcp_token }}"

{{ _("For Claude Desktop, which only speaks to local processes, the mcp-remote bridge carries the same URL and header. This goes into claude_desktop_config.json under mcpServers:") }}

"wealth": {
  "command": "npx",
  "args": ["-y", "mcp-remote", "{{ mcp_url }}", "--transport", "http-only",
           "--header", "Authorization: Bearer {{ mcp_token }}"]
}

{{ _("Two things that cost people an afternoon. The URL is the one the browser reaches the dashboard at: behind a reverse proxy that is the https:// address, not the container's http:// one — the address above is what this page was opened at, so it is right if this page was. And --transport http-only matters: without it mcp-remote first tries the older SSE transport, which this endpoint does not speak, and reports a connection failure that is not one.") }}

{% endif %}
{% if mcp_token %}{% endif %}
{% endif %} {% if section == 'assistants' %}

{{ _("REST API") }}

{{ _("the same tools over plain HTTP") }}

{{ _("Every tool the assistant has is also a URL, for a script or an automation that speaks no MCP — the same token, the same answers, one registry. GET lists the tools with their schemas; GET or POST calls one, arguments as query parameters or a JSON body.") }}

{% if mcp_token %}
curl -H "Authorization: Bearer {{ mcp_token }}" {{ api_url }}
curl -H "Authorization: Bearer {{ mcp_token }}" "{{ api_url }}/net_worth"
curl -H "Authorization: Bearer {{ mcp_token }}" "{{ api_url }}/transactions?q=amazon&limit=5"
curl -H "Authorization: Bearer {{ mcp_token }}" -X POST -H "Content-Type: application/json" \
     -d '{"txn_id": 123, "category": "groceries"}' "{{ api_url }}/set_category"
{% else %}

{{ _("Create a token above and the examples appear here.") }}

{% endif %}

{{ _("Weekly e-mail") }}

{% if report_state.configured and report_state.enabled %}{{ _f("every {day}", day=weekdays[report_state.weekday]) }}{% elif report_state.configured %}{{ _("set up, switched off") }}{% else %}{{ _("not set up") }}{% endif %}

{{ _("Once a week, a mail with the week: net worth and its change, every broker and crypto account with its value, price move, gain since purchase, holdings, dividends of the last thirty days and the trailing year, and the indices the app tracks. Sent through an SMTP server of yours — Gmail with an app password works — after the daily sync on the day you pick. It is the one thing this app sends anywhere but your bank: to your own mailbox. The password is kept beside the bank key.") }} {{ _("Preview today's mail") }}

{% if report_state.configured %}
{% if report_state.last_sent %}

{{ _f("Last sent {when}.", when=report_state.last_sent[:16].replace('T', ' ')) }}

{% endif %} {% endif %}

{{ _("Webhooks") }}

{{ _n(hooks | length, "{n} receiver", "{n} receivers") }}

{{ _("A POST to a URL of yours when something happened: a sync ran or failed, a bill is past due. Home Assistant, n8n, a bot, a script. The body is JSON — event, time, data — and the X-Wealth-Signature header is an HMAC-SHA256 of it with the receiver's secret, so it can tell this app from anyone who found the URL. One attempt, five seconds; a receiver that is down misses that event and the list says so.") }}

{% for e in hook_events %}{% endfor %}
{% if hooks %}
{% for h in hooks %} {% endfor %}
URL{{ _("Events") }}{{ _("Secret") }}{{ _("Last") }}
{{ h.url[:60] }} {{ h.events.replace(',', ', ') }} {{ h.secret }} {% if h.last_at %}{{ h.last_at[:16].replace('T', ' ') }}{% if h.last_error %} · {{ h.last_error }}{% endif %}{% else %}—{% endif %}
{% endif %}
{% endif %} {% if section == 'people' %}

{{ _("People") }}

{{ _("whose accounts are whose") }}

{{ _("A birthday is optional; with one, the Forecast page adds a retirement outlook for that person.") }} {{ _("Add the people in your household, then tick on each account who it belongs to — one person, or several for a joint account. A switch appears in the header: Everyone shows the whole household, a name shows only that person's accounts on every page. An account ticked for nobody shows under Everyone only. This is a lens, not a lock: anyone who can sign in can flip it.") }}

{% if people_list %}
{% for p in people_list %}
{{ _n(p.accounts, "{n} account", "{n} accounts") }}
{% endfor %}
{% endif %}
{% endif %} {% if section == 'categories' %}

{{ _("Categories") }}

{{ _("renaming one keeps every transaction it holds") }}

{{ _("A category is identified internally by the name it was created with, so renaming or recolouring one never re-files a transaction — the Groceries you already sorted stay sorted whatever you call them. What “counts as” decides is whether Cash Flow and Budget treat the money as spent, or merely as moved: pay for lunch and it is spending, move €500 to your broker and it is not — or as income, which Cash Flow adds up by category, so a salary, a rent coming in and interest each show as their own.") }}

{% for c in catalogue %}
{% if c.group_locked %} {{ _("income") if c.group == 'income' else _("not spending") }} {% else %} {% endif %}
{{ _n(c.transactions, "{n} transaction", "{n} transactions") }}{% if c.rules %} · {{ _n(c.rules, "{n} rule", "{n} rules") }}{% endif %} {% if c.deletable %} {% set warning = _n(c.transactions, "Delete this category? {n} transaction moves to Uncategorised.", "Delete this category? {n} transactions move to Uncategorised.") %} {% if c.rules %}{% set warning = warning ~ " " ~ _n(c.rules, "{n} rule is deleted with it.", "{n} rules are deleted with it.") %}{% endif %}
{% else %} {{ _("needed") }} {% endif %}
{% endfor %}
{% endif %} {% if section == 'banks' %}

Enable Banking

{% if error %}
{{ error }}
{% endif %}

{{ _("Your own application, your own key. Nothing here is shared with anyone — the key never leaves this machine and is only used to sign your own requests.") }}

    {% set cp_link %}enablebanking.com/cp/applications{% endset %} {% set pem_file %}<application-id>.pem{% endset %}
  1. {{ _("Create an application") }} {{ _f("at {control_panel}.", control_panel=cp_link) | safe }} {{ _("Environment Production — restricted mode is a state of a production app, not a separate environment.") }}
  2. {{ _("Find your Application ID.") }} {{ _f("It is on the application's page in the Control Panel — a UUID like {example}.", example="0f3a…-…-…") | safe }} {{ _f("If you chose Generate for the key, it is also the filename of the file your browser downloaded: {file}.", file=pem_file) | safe }}
  3. {{ _("Find your private key.") }} {{ _("Which file depends on the choice you made when creating the application:") }}
    • {{ _("You chose “Generate”") }} ({{ _("the usual case") }}) — {{ _f("your browser downloaded {file}.", file=pem_file) | safe }} {{ _("That file is the private key.") }} {{ _("Open it in a text editor and copy everything, including the BEGIN and END lines. There is nothing to generate yourself.") }}
    • {{ _("You provided your own key") }} — {{ _f("then you already ran the commands below and want {file}.", file="enablebanking_private.key") | safe }}

    {{ _("Do not paste enablebanking_public.pem, or anything you uploaded to Enable Banking. That is the public half; they have it, you need the other one.") }}

  4. {{ _("Paste both below") }} {{ _("and save. The app checks them immediately against Enable Banking and tells you what it finds.") }}
{{ _("Only if you want to supply your own key instead of letting the Control Panel generate one") }}
openssl genrsa -out enablebanking_private.key 4096
openssl rsa -in enablebanking_private.key -pubout -out enablebanking_public.pem

{{ _("Upload enablebanking_public.pem in the Control Panel; paste enablebanking_private.key below.") }}

{{ _f("The key is stored at {path} with permissions 0600.", path=secrets_dir) }}

{% if secrets_inside_data %}
{{ _("Your credentials live inside the data folder, so every backup of that folder carries your bank key with it. Set WD_SECRETS_DIR to a folder outside it if that matters to you.") }}
{% endif %}
{% if configured %} {% set test_link %}{{ _("Test them") }}{% endset %}

{{ _f("Credentials are stored. {test} — this makes one live call to Enable Banking.", test=test_link) | safe }}

{% if check and check.ok %}
{{ _("Working. Registered redirect URLs:") }} {{ (check.application.redirect_urls or [_('none')])|join(', ') }}
{% elif check %}
{{ check.error }}
{% endif %} {% endif %}
{% endif %} {% endblock %}