{% 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 %}{{ _("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 %}{{ _("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 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.") }}
{{ _("After step 1.") }}
{% endif %}{{ _("After step 2.") }}
{% 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 %}{{ _f("Last pull {when}.", when=archive_state.last.at[:16].replace('T', ' ')) }}
{% endif %} {% if archive_state.filters %}| {{ _("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 }} |
{{ _("No account pulls anything yet — open an account's edit page and say which documents are its.") }}
{% endif %} {% if archive_state.unread %}{{ _("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 %}{{ _("An app on your phone asks for this dashboard's address and a six-digit code. The code is good for five minutes and for one device; it is exchanged once for the token above, over the same address you are reading this on. A token shown on a screen is a token anyone who photographs the screen owns, which is why the screen shows a code instead.") }}
{% if pair_code %}{{ pair_code.code }}
{{ _n(pair_code.expires_in, "{n} second left", "{n} seconds left") }}
{% endif %}{{ _("The address to type into the app:") }} {{ mcp_url[:-4] }}
{{ _("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 %}{{ _("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 %}{{ _f("Last sent {when}.", when=report_state.last_sent[:16].replace('T', ' ')) }}
{% endif %} {% 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.") }}
{{ _("Choose ntfy instead and the body is a line of prose with a title and a priority — which is all a push server needs to put the message on a phone's lock screen. The URL is then your topic, say https://ntfy.sh/my-secret-topic or your own server's; anyone who knows the topic can read it, so make it long and private.") }}
{% if hooks %}| URL | {{ _("Speaks") }} | {{ _("Events") }} | {{ _("Secret") }} | {{ _("Last") }} | |
|---|---|---|---|---|---|
| {{ h.url[:60] }} | {{ h.kind or 'json' }} | {{ h.events.replace(',', ', ') }} | {% if (h.kind or 'json') == 'json' %}{{ h.secret }}{% else %}—{% endif %} |
{% 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 %}