{% 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 %}
{% 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 %}

{{ _("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 %}