{% extends "base.html" %} {% block title %}{{ _("Settings") }} · Wealth Dashboard{% endblock %} {% block heading %}{{ _("Settings") }}{% endblock %} {% block content %} {% if section == 'general' %}
{% 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 %}
{{ _("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 %}{{ _("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.") }}
| {{ _("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.") }}
| {{ _("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.") }}
{{ _("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 %}| {{ _("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 %} |
{{ _("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.") }}
{{ _("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.") }}
{{ 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.") }}{{ _("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 %}{{ _("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.") }}
{{ _("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 %}{{ _("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 %}{{ _("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.") }}
{% if hooks %}| 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 %} |
{{ _("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 %}{{ _("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.") }}
{{ _("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.") }}
<application-id>.pem{% endset %}
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 }}
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.") }}
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 %}{{ _f("Credentials are stored. {test} — this makes one live call to Enable Banking.", test=test_link) | safe }}
{% if check and check.ok %}