From f826e633e42348b57b035d18a433faad46194aba Mon Sep 17 00:00:00 2001 From: Patrick Martin Date: Mon, 2 Mar 2026 11:41:47 +0100 Subject: [PATCH 1/3] feat: add organisation-level portfolio API support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `user_organizations.py` with 6 functions mirroring the `/organizations/{org_id}/…` endpoints (investments, cryptos, fonds_euro, real_estates, scpis, holdings_accounts). Add `get_family_org_id()` convenience helper to `user_me.py` that resolves the family organisation ID from the authenticated user's organisations list. Add `test_organizations` integration test that skips cleanly when the account has no family organisation. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- finary_uapi/user_me.py | 9 ++++++++ finary_uapi/user_organizations.py | 35 +++++++++++++++++++++++++++++++ tests/test_get.py | 29 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 finary_uapi/user_organizations.py diff --git a/finary_uapi/user_me.py b/finary_uapi/user_me.py index 1131467..2054f48 100644 --- a/finary_uapi/user_me.py +++ b/finary_uapi/user_me.py @@ -32,6 +32,15 @@ def get_user_me_subscription_details(session: requests.Session) -> Any: return get_and_print(session, url) +def get_family_org_id(session: requests.Session) -> str | None: + """Return the ID of the user's family organisation, or None.""" + data = get_user_me_organizations(session) + for org in data.get("result", []): + if org.get("organization_type") == "family": + return org["id"] + return None + + # convenience functions diff --git a/finary_uapi/user_organizations.py b/finary_uapi/user_organizations.py new file mode 100644 index 0000000..ad2a15a --- /dev/null +++ b/finary_uapi/user_organizations.py @@ -0,0 +1,35 @@ +from curl_cffi import requests +from typing import Any + +from .constants import API_ROOT +from .utils import get_and_print + + +def get_organization_investments(session: requests.Session, org_id: str) -> Any: + url = f"{API_ROOT}/organizations/{org_id}/portfolio/investments" + return get_and_print(session, url) + + +def get_organization_cryptos(session: requests.Session, org_id: str) -> Any: + url = f"{API_ROOT}/organizations/{org_id}/portfolio/cryptos" + return get_and_print(session, url) + + +def get_organization_fonds_euro(session: requests.Session, org_id: str) -> Any: + url = f"{API_ROOT}/organizations/{org_id}/fonds_euro" + return get_and_print(session, url) + + +def get_organization_real_estates(session: requests.Session, org_id: str) -> Any: + url = f"{API_ROOT}/organizations/{org_id}/real_estates" + return get_and_print(session, url) + + +def get_organization_scpis(session: requests.Session, org_id: str) -> Any: + url = f"{API_ROOT}/organizations/{org_id}/scpis" + return get_and_print(session, url) + + +def get_organization_holdings_accounts(session: requests.Session, org_id: str) -> Any: + url = f"{API_ROOT}/organizations/{org_id}/holdings_accounts" + return get_and_print(session, url) diff --git a/tests/test_get.py b/tests/test_get.py index 7f5ed60..f285ac8 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -81,6 +81,35 @@ def test_generic_test( assert len(result["result"]) > 0 +def test_organizations(session: requests.Session) -> None: + from finary_uapi.user_me import get_family_org_id + from finary_uapi.user_organizations import ( + get_organization_investments, + get_organization_cryptos, + get_organization_fonds_euro, + get_organization_real_estates, + get_organization_scpis, + get_organization_holdings_accounts, + ) + + org_id = get_family_org_id(session) + if org_id is None: + pytest.skip("No family organisation on this account") + + for fn in ( + get_organization_investments, + get_organization_cryptos, + get_organization_fonds_euro, + get_organization_real_estates, + get_organization_scpis, + get_organization_holdings_accounts, + ): + result = fn(session, org_id) + assert result + assert result.get("message") == "OK" + assert result.get("error") is None + + def test_get_security_error(session: requests.Session) -> None: securities = get_securities(session, "US5949181045XDE") assert securities From 6ab1f0fc64e3b6714829362a0cdfaba2103685ee Mon Sep 17 00:00:00 2001 From: Patrick Martin Date: Mon, 2 Mar 2026 12:05:04 +0100 Subject: [PATCH 2/3] fix: correct endpoint URLs and add get_organization_securities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_organization_cryptos: use /cryptos (flat list) instead of /portfolio/cryptos (aggregated dict) — consistent with all other flat-list org functions - get_organization_investments: keep as /portfolio/investments (dict), mirrors get_portfolio_investments in user_portfolio.py; add docstring to make the return shape explicit - get_organization_securities: new function for /securities (flat list), the actual flat-list counterpart to investments Test: assert isinstance(result["result"], list) for all flat-list functions and dict for get_organization_investments, so wrong endpoint shapes are caught immediately. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- finary_uapi/user_organizations.py | 8 +++++++- tests/test_get.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/finary_uapi/user_organizations.py b/finary_uapi/user_organizations.py index ad2a15a..7325164 100644 --- a/finary_uapi/user_organizations.py +++ b/finary_uapi/user_organizations.py @@ -6,12 +6,18 @@ def get_organization_investments(session: requests.Session, org_id: str) -> Any: + """Aggregated portfolio view — result is a dict, not a list.""" url = f"{API_ROOT}/organizations/{org_id}/portfolio/investments" return get_and_print(session, url) +def get_organization_securities(session: requests.Session, org_id: str) -> Any: + url = f"{API_ROOT}/organizations/{org_id}/securities" + return get_and_print(session, url) + + def get_organization_cryptos(session: requests.Session, org_id: str) -> Any: - url = f"{API_ROOT}/organizations/{org_id}/portfolio/cryptos" + url = f"{API_ROOT}/organizations/{org_id}/cryptos" return get_and_print(session, url) diff --git a/tests/test_get.py b/tests/test_get.py index f285ac8..b78180e 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -85,6 +85,7 @@ def test_organizations(session: requests.Session) -> None: from finary_uapi.user_me import get_family_org_id from finary_uapi.user_organizations import ( get_organization_investments, + get_organization_securities, get_organization_cryptos, get_organization_fonds_euro, get_organization_real_estates, @@ -96,8 +97,16 @@ def test_organizations(session: requests.Session) -> None: if org_id is None: pytest.skip("No family organisation on this account") + # portfolio-level aggregated view (result is a dict) + result = get_organization_investments(session, org_id) + assert result + assert result.get("message") == "OK" + assert result.get("error") is None + assert isinstance(result.get("result"), dict) + + # flat-list endpoints (result is a list) for fn in ( - get_organization_investments, + get_organization_securities, get_organization_cryptos, get_organization_fonds_euro, get_organization_real_estates, @@ -108,6 +117,7 @@ def test_organizations(session: requests.Session) -> None: assert result assert result.get("message") == "OK" assert result.get("error") is None + assert isinstance(result.get("result"), list) def test_get_security_error(session: requests.Session) -> None: From ce67783279b58981a232b7e33b2415fd06ded44a Mon Sep 17 00:00:00 2001 From: Patrick Martin Date: Mon, 2 Mar 2026 12:31:53 +0100 Subject: [PATCH 3/3] feat: add --org-id option to CLI read commands Adds an optional --org-id flag to all commands that have an organisation-level equivalent: finary_uapi fonds_euro [--org-id=] finary_uapi investments [--org-id=] finary_uapi cryptos [--org-id=] finary_uapi securities [--org-id=] finary_uapi holdings_accounts [...] [--org-id=] finary_uapi real_estates [--org-id=] finary_uapi scpis [--org-id=] Accepts either a UUID or the keyword 'family', which auto-resolves via get_family_org_id(). When --org-id is provided the command routes to the corresponding get_organization_*() function; otherwise falls through to the existing /users/me/ behaviour. Write operations (add, update, delete) are unaffected. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- finary_uapi/__main__.py | 58 ++++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/finary_uapi/__main__.py b/finary_uapi/__main__.py index 4ca71c6..e7f4fe2 100644 --- a/finary_uapi/__main__.py +++ b/finary_uapi/__main__.py @@ -7,16 +7,16 @@ finary_uapi organizations finary_uapi timeseries finary_uapi checking_accounts transactions [--page=] [--perpage=] [--account=] [--institution=] [--query=] [--start-date=] [--end-date=] [--marked=] - finary_uapi fonds_euro + finary_uapi fonds_euro [--org-id=] finary_uapi startups - finary_uapi investments + finary_uapi investments [--org-id=] finary_uapi investments dividends finary_uapi investments transactions [--page=] [--perpage=] [--account=] [--institution=] [--query=] [--start-date=] [--end-date=] [--marked=] finary_uapi crowdlendings finary_uapi crowdlendings distribution finary_uapi crowdlendings add finary_uapi crowdlendings delete - finary_uapi cryptos + finary_uapi cryptos [--org-id=] finary_uapi cryptos distribution finary_uapi cryptos add finary_uapi cryptos update @@ -25,7 +25,7 @@ finary_uapi precious_metals finary_uapi precious_metals add finary_uapi precious_metals delete - finary_uapi holdings_accounts [crypto | stocks | crowdlending | ] + finary_uapi holdings_accounts [crypto | stocks | crowdlending | ] [--org-id=] finary_uapi holdings_accounts add (crypto | stocks | crowdlending) finary_uapi holdings_accounts add (checking | saving) finary_uapi holdings_accounts delete @@ -40,18 +40,18 @@ finary_uapi fiat_currency search QUERY finary_uapi institutions search QUERY finary_uapi securities search QUERY - finary_uapi securities + finary_uapi securities [--org-id=] finary_uapi securities add finary_uapi securities delete finary_uapi credit_accounts transactions [--page=] [--perpage=] [--account=] [--institution=] [--query=] [--start-date=] [--end-date=] [--marked=] - finary_uapi real_estates + finary_uapi real_estates [--org-id=] finary_uapi real_estates add rent
[] finary_uapi real_estates add
[] finary_uapi real_estates update rent finary_uapi real_estates update finary_uapi real_estates delete finary_uapi scpis search QUERY - finary_uapi scpis + finary_uapi scpis [--org-id=] finary_uapi watches search QUERY finary_uapi import crowdlending_csv FILENAME [-d] [-f] finary_uapi import cryptocom FILENAME [(--new=NAME | --edit=account_id | --add=account_id)] @@ -73,6 +73,7 @@ --start-date= Start date for transactions (format: YYYY-MM-DD) --end-date= End date for transactions (format: YYYY-MM-DD) --marked= Filter marked transactions (true or false) + --org-id= Organisation ID (UUID or 'family' to auto-resolve); queries org-level endpoint instead of /users/me/ """ # noqa @@ -142,7 +143,16 @@ update_user_crypto_by_code, ) from .user_fonds_euro import get_user_fonds_euro -from .user_me import get_user_me, get_user_me_institution_connections +from .user_me import get_family_org_id, get_user_me, get_user_me_institution_connections +from .user_organizations import ( + get_organization_cryptos, + get_organization_fonds_euro, + get_organization_holdings_accounts, + get_organization_investments, + get_organization_real_estates, + get_organization_scpis, + get_organization_securities, +) from .user_precious_metals import ( add_user_precious_metals_by_name, delete_user_precious_metals, @@ -170,6 +180,12 @@ def main() -> int: # pragma: nocover result = signin(args["MFA_CODE"]) else: session = prepare_session() + org_id = args["--org-id"] + if org_id == "family": + org_id = get_family_org_id(session) + if org_id is None: + print("Error: no family organisation found for this account", file=sys.stderr) + return 1 if args["me"]: result = get_user_me(session) elif args["institution_connections"]: @@ -188,7 +204,10 @@ def main() -> int: # pragma: nocover marked=args["--marked"], ) elif args["fonds_euro"]: - result = get_user_fonds_euro(session) + if org_id: + result = get_organization_fonds_euro(session, org_id) + else: + result = get_user_fonds_euro(session) elif args["startups"]: result = get_user_startups(session) elif args["search"]: @@ -352,6 +371,8 @@ def main() -> int: # pragma: nocover elif args["cryptos"]: if args["distribution"]: result = get_portfolio_cryptos_distribution(session) + elif org_id: + result = get_organization_cryptos(session, org_id) else: result = get_user_cryptos(session) elif args["investments"]: @@ -369,6 +390,8 @@ def main() -> int: # pragma: nocover end_date=args["--end-date"], marked=args["--marked"], ) + elif org_id: + result = get_organization_investments(session, org_id) else: result = get_portfolio_investments(session) elif args["timeseries"]: @@ -378,6 +401,8 @@ def main() -> int: # pragma: nocover result = get_holdings_account_per_name_or_id( session, args[""] ) + elif org_id: + result = get_organization_holdings_accounts(session, org_id) else: holdings_account_types = ["crypto", "stocks", "crowdlending"] hats = [i for i in holdings_account_types if args[i]] @@ -390,7 +415,10 @@ def main() -> int: # pragma: nocover elif args["precious_metals"]: result = get_user_precious_metals(session) elif args["securities"]: - result = get_user_securities(session) + if org_id: + result = get_organization_securities(session, org_id) + else: + result = get_user_securities(session) elif args["credit_accounts"]: if args["transactions"]: result = get_portfolio_credit_accounts_transactions( @@ -405,9 +433,15 @@ def main() -> int: # pragma: nocover marked=args["--marked"], ) elif args["real_estates"]: - result = get_user_real_estates(session) + if org_id: + result = get_organization_real_estates(session, org_id) + else: + result = get_user_real_estates(session) elif args["scpis"]: - result = get_user_scpis(session) + if org_id: + result = get_organization_scpis(session, org_id) + else: + result = get_user_scpis(session) elif args["import"]: to_be_imported = [] if args["crowdlending_csv"]: