securo
Applicationsecuro-finance/securo
Open-source self-hosted personal finance manager focused on privacy.
Overview
Securo is a self-hosted personal finance manager that gives users full control over their financial data. It supports multi-account management, transaction tracking, budgets, goals, asset tracking, and optional bank sync via providers like Pluggy, Enable Banking, and SimpleFIN. The app emphasizes privacy, with no third-party access to your data.
README Preview
\n \n\nSecuro\n\n \n \n \n \n \n \n \n \n Website · Demo · Roadmap · Docs · Discord · Talk to the maintainer\n\n\nFinance apps want your data. This one doesn't.\n\n\nWe believe personal finance should actually be personal. No corporation should sit between you and your financial data. Securo is an open-source finance manager that runs on your own infrastructure, giving you full visibility into your accounts, spending, and habits, without surrendering a single byte to third parties. Take back control.\n\n\n## Quick Start\n\n**Linux & macOS** (uses Docker or Podman; installs Docker if neither is present):\n\n```bash\ncurl -fsSL https://usesecuro.com/install.sh | bash\n```\n\n**Windows:** Install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:\n\n```bash\ngit clone https://github.com/securo-finance/securo.git && cd securo\ndocker compose up --build\n```\n\nOpen [http://localhost:3000](http://localhost:3000) and create an account. That's it.\n\n\n \n\n\n## Features\n\n- Multi-account management with running balances\n- Transaction management with search, filters, and CSV export\n- File import (OFX, QIF, CAMT, CSV)\n- Auto-categorization rules engine\n- Recurring transactions and budgets\n- Goals and savings targets with progress tracking\n- Asset management with valuation tracking and growth rules\n- Reports: Net Worth and Income vs Expenses with category sparklines\n- Bank sync via providers (Pluggy for Brazilian banks, Enable Banking for ~2500 European PSD2 banks, SimpleFIN for US and international banks, extensible)\n- Multi-currency support with automatic FX conversion\n- Multi-user support with admin panel and registration controls\n- Two-factor authentication (TOTP) with brute-force protection\n- OIDC login support for Authentik, Pocket ID, and other standard providers\n- AI Agents (optional): self-hosted LLM chat with tool-use over your data, plus a per-agent RAG knowledge base\n\n## Bank Sync (Optional)\n\nAdd credentials for
FAQ (5)
TroubleshootingWhy does SQLAlchemy raise "'float' object has no attribute 'replace'" when reading a UUID from SQLite?
SQLite gives a column declared as UUID NUMERIC affinity, so some UUID hex strings such as 1e999... are silently converted to REAL/float. SQLAlchemy's UUID result processor then receives a float instead of a string and calls .replace() on it.
Fix the SQLite test schema by compiling PostgreSQL UUID as CHAR(32), which has TEXT affinity:
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.ext.compiler import compiles
@compiles(PG_UUID, "sqlite")
def _uuid_as_char32(element, compiler, **kw):
return "CHAR(32)"
Register this before Base.metadata.create_all() or engine/table creation. Recreate any existing file-based SQLite test database; in-memory databases are recreated automatically. PostgreSQL remains unaffected and continues using its native uuid type.
TroubleshootingWhy does FRONTEND_URL get a '}' appended when using podman-compose?
This is caused by podman-compose versions older than 1.6.0, which cannot parse nested default values in compose files like ${FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3000}}. The parser stops at the first } and leaves the trailing brace attached to the value, so printenv FRONTEND_URL shows https://example.mydomain.com}. Fix: upgrade podman-compose to 1.6.0 or newer (e.g., pip install -U podman-compose), then recreate the containers with podman-compose up -d. Alternatively, update to the version of this project that removed the nested variable syntax so it works with any podman-compose version.
TroubleshootingWhy do tests fail with 'Due date cannot precede the issue date' error after a certain date?
The test suite uses a hardcoded frozen date (e.g. TODAY = date(2026, 8, 26)) while the service generates issue_date from the real UTC clock via datetime.now(timezone.utc).date(). Once real time passes the frozen due_date (TODAY + 10 days), validation due_date < issue_date rejects the invoice and all dependent tests fail permanently. Fix: anchor the test to the same clock the service uses — replace TODAY = date(2026, 8, 26) with TODAY = datetime.now(timezone.utc).date(). This was fixed upstream by PR #852, which pins the invoice test suite to its own clock; updating to a version including #852 also resolves it. Avoid just moving the literal forward (e.g. date(2027, 1, 1)) — it recreates the same failure later. Note: the test constant is evaluated at import time while the service reads the clock at call time, so keep a multi-day margin between issue and due dates to avoid flaky failures across UTC day boundaries.
TroubleshootingHow do I fix "One or more split members not found" when a cross-workspace group member saves a split in Securo?
This regression affects Securo v0.16.0. PR #375 added a strict Group.workspace_id == workspace_id check to split_service._validate_members, rejecting a user who is linked to a shared group from another workspace even though the UI displays the group.
Fix by upgrading to a release containing PR #972. If you need to patch v0.16.0 yourself, update split_service._validate_members to use the same visibility rule as group_service._visible_predicate: allow either a group in the current workspace or a group where the user is linked as a non-owner member:
member_groups = (
select(GroupMember.group_id)
.where(
GroupMember.linked_user_id == user_id,
GroupMember.is_self.is_(False),
)
.distinct()
)
result = await session.execute(
select(GroupMember, Group)
.join(Group, GroupMember.group_id == Group.id)
.where(
GroupMember.id.in_(member_ids),
or_(
Group.workspace_id == workspace_id,
Group.id.in_(member_groups),
),
)
)
Preferably reuse/import group_service._visible_predicate instead of duplicating the rule so read and write paths cannot diverge again. Rebuild/restart the self-hosted container after applying the patch. This still prevents arbitrary groups from other workspaces being referenced; only groups to which the requesting user is explicitly linked are accepted.
TroubleshootingWhy do Enable Banking transactions show "Transaction" instead of the creditor or debtor name?
This happens when remittance_information and additional_information are empty; Securo falls back directly to "Transaction" instead of using the counterparty name. Fix the Enable Banking import mapping so that, after remittance and additional information are checked, it uses creditor.name for debit (DBIT) transactions or debtor.name for credit (CRDT) transactions, then the opposite counterparty, and only finally "Transaction":
description = _join_remittance(raw.get("remittance_information")) or (
raw.get("additional_information") or ""
)
if not description:
creditor = (raw.get("creditor") or {}).get("name") or raw.get("creditor_name")
debtor = (raw.get("debtor") or {}).get("name") or raw.get("debtor_name")
if indicator == "DBIT":
description = creditor or debtor or ""
else:
description = debtor or creditor or ""
description = description.strip()[:500] or "Transaction"
Apply the Enable Banking counterparty-fallback patch or upgrade to a release containing PR fix(enable-banking): fall back to counterparty name when description is missing. Existing transactions may need to be reimported or manually corrected after the fix.