networthy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. networthy-0.1.0/PKG-INFO +235 -0
  2. networthy-0.1.0/README.md +209 -0
  3. networthy-0.1.0/app/__init__.py +3 -0
  4. networthy-0.1.0/app/analytics.py +139 -0
  5. networthy-0.1.0/app/auth.py +272 -0
  6. networthy-0.1.0/app/classify.py +119 -0
  7. networthy-0.1.0/app/demo.py +124 -0
  8. networthy-0.1.0/app/digest.py +257 -0
  9. networthy-0.1.0/app/expenses.py +126 -0
  10. networthy-0.1.0/app/goals.py +125 -0
  11. networthy-0.1.0/app/launcher.py +122 -0
  12. networthy-0.1.0/app/mailer.py +54 -0
  13. networthy-0.1.0/app/main.py +2006 -0
  14. networthy-0.1.0/app/models.py +78 -0
  15. networthy-0.1.0/app/networth.py +213 -0
  16. networthy-0.1.0/app/parser/__init__.py +7 -0
  17. networthy-0.1.0/app/parser/_common.py +56 -0
  18. networthy-0.1.0/app/parser/cams_cas.py +257 -0
  19. networthy-0.1.0/app/parser/nsdl_cas.py +402 -0
  20. networthy-0.1.0/app/prices.py +223 -0
  21. networthy-0.1.0/app/projection.py +318 -0
  22. networthy-0.1.0/app/static/apple-touch-icon.png +0 -0
  23. networthy-0.1.0/app/static/chart.js +158 -0
  24. networthy-0.1.0/app/static/favicon-32.png +0 -0
  25. networthy-0.1.0/app/static/favicon.svg +6 -0
  26. networthy-0.1.0/app/static/og-image.png +0 -0
  27. networthy-0.1.0/app/static/plan-chart.js +82 -0
  28. networthy-0.1.0/app/static/retire.js +165 -0
  29. networthy-0.1.0/app/static/standing.js +426 -0
  30. networthy-0.1.0/app/static/style.css +1389 -0
  31. networthy-0.1.0/app/storage.py +1749 -0
  32. networthy-0.1.0/app/templates/_footer.html +21 -0
  33. networthy-0.1.0/app/templates/_head_meta.html +27 -0
  34. networthy-0.1.0/app/templates/_notes.html +7 -0
  35. networthy-0.1.0/app/templates/about.html +61 -0
  36. networthy-0.1.0/app/templates/admin.html +122 -0
  37. networthy-0.1.0/app/templates/base.html +78 -0
  38. networthy-0.1.0/app/templates/cams_import.html +129 -0
  39. networthy-0.1.0/app/templates/expenses.html +179 -0
  40. networthy-0.1.0/app/templates/goals.html +152 -0
  41. networthy-0.1.0/app/templates/index.html +134 -0
  42. networthy-0.1.0/app/templates/landing.html +173 -0
  43. networthy-0.1.0/app/templates/login.html +21 -0
  44. networthy-0.1.0/app/templates/marketing_base.html +34 -0
  45. networthy-0.1.0/app/templates/networth.html +169 -0
  46. networthy-0.1.0/app/templates/networth_node.html +952 -0
  47. networthy-0.1.0/app/templates/networth_overview.html +81 -0
  48. networthy-0.1.0/app/templates/plan.html +234 -0
  49. networthy-0.1.0/app/templates/privacy.html +62 -0
  50. networthy-0.1.0/app/templates/retire.html +224 -0
  51. networthy-0.1.0/app/templates/standing.html +243 -0
  52. networthy-0.1.0/app/templates/terms.html +58 -0
  53. networthy-0.1.0/app/templates/upload.html +124 -0
  54. networthy-0.1.0/app/templates/verify.html +26 -0
  55. networthy-0.1.0/app/wealth.py +314 -0
  56. networthy-0.1.0/networthy.egg-info/PKG-INFO +235 -0
  57. networthy-0.1.0/networthy.egg-info/SOURCES.txt +87 -0
  58. networthy-0.1.0/networthy.egg-info/dependency_links.txt +1 -0
  59. networthy-0.1.0/networthy.egg-info/entry_points.txt +2 -0
  60. networthy-0.1.0/networthy.egg-info/requires.txt +7 -0
  61. networthy-0.1.0/networthy.egg-info/top_level.txt +1 -0
  62. networthy-0.1.0/pyproject.toml +52 -0
  63. networthy-0.1.0/setup.cfg +4 -0
  64. networthy-0.1.0/tests/test_alt.py +25 -0
  65. networthy-0.1.0/tests/test_analytics.py +103 -0
  66. networthy-0.1.0/tests/test_auth.py +73 -0
  67. networthy-0.1.0/tests/test_cams_import.py +109 -0
  68. networthy-0.1.0/tests/test_cams_parser.py +119 -0
  69. networthy-0.1.0/tests/test_classify.py +53 -0
  70. networthy-0.1.0/tests/test_demo.py +84 -0
  71. networthy-0.1.0/tests/test_digest.py +99 -0
  72. networthy-0.1.0/tests/test_edit.py +135 -0
  73. networthy-0.1.0/tests/test_equity_threshold.py +58 -0
  74. networthy-0.1.0/tests/test_expenses.py +151 -0
  75. networthy-0.1.0/tests/test_foreign.py +94 -0
  76. networthy-0.1.0/tests/test_goals.py +129 -0
  77. networthy-0.1.0/tests/test_gold.py +39 -0
  78. networthy-0.1.0/tests/test_landing.py +51 -0
  79. networthy-0.1.0/tests/test_liabilities.py +43 -0
  80. networthy-0.1.0/tests/test_local_mode.py +193 -0
  81. networthy-0.1.0/tests/test_manual.py +41 -0
  82. networthy-0.1.0/tests/test_networth.py +161 -0
  83. networthy-0.1.0/tests/test_parser.py +210 -0
  84. networthy-0.1.0/tests/test_prices.py +120 -0
  85. networthy-0.1.0/tests/test_projection.py +356 -0
  86. networthy-0.1.0/tests/test_seo.py +216 -0
  87. networthy-0.1.0/tests/test_storage.py +498 -0
  88. networthy-0.1.0/tests/test_trend.py +83 -0
  89. networthy-0.1.0/tests/test_wealth.py +208 -0
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.4
2
+ Name: networthy
3
+ Version: 0.1.0
4
+ Summary: Track your complete net worth on your own machine — NSDL/CAMS statement parsing, live prices, retirement projections. India-first, private by design.
5
+ Author: Manoj Awasthi
6
+ License: MIT
7
+ Project-URL: Homepage, https://networthyhq.com
8
+ Project-URL: Source, https://github.com/awmanoj/networthy
9
+ Keywords: net-worth,personal-finance,india,nsdl,cas,mutual-funds,fire
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Office/Business :: Financial
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: fastapi==0.115.6
20
+ Requires-Dist: uvicorn[standard]==0.34.0
21
+ Requires-Dist: jinja2==3.1.5
22
+ Requires-Dist: python-multipart==0.0.20
23
+ Requires-Dist: pdfplumber==0.11.5
24
+ Requires-Dist: pikepdf==9.4.2
25
+ Requires-Dist: httpx==0.28.1
26
+
27
+ # Networthy HQ
28
+
29
+ **A private, self-hosted net-worth tracker for Indian investors.** Upload your
30
+ **NSDL CAS** and **CAMS/KFintech** statements, add everything else by hand — property,
31
+ gold, foreign equity, crypto, bank balances, loans — and see your complete net worth
32
+ in one place, priced live where it can be.
33
+
34
+ > **Privacy is the whole point.** Statements are parsed **on your own server** and the
35
+ > parsed database lives under `data/` (gitignored). Nothing about your holdings ever
36
+ > leaves the machine — the *only* things that egress are public price lookups by symbol
37
+ > (a ticker, a currency pair, a coin), never a value, quantity, PAN, or identity.
38
+
39
+ **Try it without signing up:** the landing page has an **Explore the live demo** button
40
+ (`GET /demo`) that drops you into a fully-loaded demo account — no email required.
41
+
42
+ ![Dashboard](docs/screenshots/dashboard.png)
43
+
44
+ ---
45
+
46
+ ## What it does
47
+
48
+ - **Parses password-protected CAS PDFs** — NSDL e-CAS (demat holdings across NSDL +
49
+ CDSL, plus mutual-fund folios) and CAMS/KFintech CAS (all-AMC mutual funds). Decrypt
50
+ → extract → classify by asset class, all locally.
51
+ - **A complete Assets & Liabilities tree** — every asset class as a navigable leaf:
52
+ mutual funds, direct & foreign equity, crypto, fixed income (PPF/EPF/FDs/bonds/NPS),
53
+ gold & silver, physical gold, real estate, alternate investments, private business,
54
+ bank & cash, forex — netted against every kind of loan.
55
+ - **Live valuation** — equities from Yahoo Finance (by ticker), mutual-fund NAVs from
56
+ AMFI's public bulk feed (looked up locally), USD/FX and crypto and gold priced live,
57
+ all cached and fail-soft so a slow endpoint never breaks a page.
58
+ - **Expenses planner** — recurring spend normalised to a monthly/annual burn, with the
59
+ net-worth connection: runway and a FIRE target (25×, the 4% rule).
60
+ - **Goals** — target-by-date planning that computes the **monthly SIP** needed to get
61
+ there, plus a read-only Retirement (FIRE) goal mirrored from your expenses.
62
+ - **"Where do you stand?"** — rank your net worth among adults in India, the US, and the
63
+ world (computed client-side; nothing you type is sent anywhere).
64
+ - **Multi-user** — sign in with email + a one-time code. Each account's data is isolated.
65
+ - **Email digests** — an optional daily net-worth pulse and a weekly breakdown.
66
+
67
+ ## Screens
68
+
69
+ | | |
70
+ |---|---|
71
+ | ![Net worth tree](docs/screenshots/networth.png) | ![Net worth over time](docs/screenshots/nsdl-cas.png) |
72
+ | **Net worth** — the full Assets/Liabilities tree, values rolled up and priced live. | **NSDL CAS** — net worth over time from each uploaded statement. |
73
+ | ![Mutual funds leaf](docs/screenshots/mutual-funds.png) | ![Goals](docs/screenshots/goals.png) |
74
+ | **A data-backed leaf** — holdings from your CAMS import, live NAVs and values. | **Goals** — target, date, and the monthly SIP to reach it. |
75
+ | ![Expenses](docs/screenshots/expenses.png) | |
76
+ | **Expenses** — monthly/annual burn, category breakdown, runway & FIRE. | |
77
+
78
+ ## How it works
79
+
80
+ ```
81
+ upload CAS PDF(s) → parse (decrypt + extract + classify) → SQLite (under data/)
82
+
83
+ manual entries (property, gold, loans, …) ──────────────────────┤
84
+
85
+ live pricing (Yahoo / AMFI, by symbol only) → Dashboard · Net worth · Goals
86
+ ```
87
+
88
+ 1. Download your CAS — NSDL e-CAS from [nsdl.co.in](https://nsdl.co.in), or a CAMS CAS
89
+ from [camsonline.com](https://www.camsonline.com/Investors/Statements/Consolidated-Account-Statement).
90
+ It arrives as a **password-protected PDF** (password is usually your PAN in CAPITALS).
91
+ 2. Upload it (the app remembers your PAN as the password so you don't retype it).
92
+ 3. Add anything a statement doesn't cover — property, physical gold, bank balances,
93
+ foreign equity, crypto, loans — by hand.
94
+ 4. The Dashboard, Net-worth tree, and Goals reflect it all, valued live where possible.
95
+
96
+ ## Tech stack
97
+
98
+ Server-rendered **FastAPI + Jinja2**, **SQLite** (stdlib `sqlite3`), no frontend
99
+ framework and a single hand-written CSS design system ("Ink Navy & Copper", light + dark).
100
+ PDF parsing via **pikepdf** (decrypt) + **pdfplumber** (text). Python 3.11+.
101
+
102
+ ## Run it on your own machine
103
+
104
+ One command. No account, no sign-in, no server — your statements are parsed on your
105
+ laptop and the database never leaves it.
106
+
107
+ ```bash
108
+ uvx networthy
109
+ ```
110
+
111
+ That's it: it starts on `http://127.0.0.1:8321`, opens your browser, and signs you in
112
+ automatically (there's nobody else to authenticate against on your own machine).
113
+
114
+ <details>
115
+ <summary>Don't have <code>uv</code>?</summary>
116
+
117
+ ```bash
118
+ curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
119
+ # Windows: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
120
+ ```
121
+
122
+ Or use pipx: `pipx run networthy`. Or plain pip: `pip install networthy && networthy`.
123
+ </details>
124
+
125
+ **Options**
126
+
127
+ ```bash
128
+ networthy --port 9000 # pick a port (default 8321, or any free one)
129
+ networthy --data-dir ~/my-finances # where the database lives
130
+ networthy --no-browser # don't open a tab
131
+ ```
132
+
133
+ Your data is stored at:
134
+
135
+ | macOS | `~/Library/Application Support/Networthy/` |
136
+ |---------|--------------------------------------------|
137
+ | Linux | `~/.local/share/networthy/` |
138
+ | Windows | `%APPDATA%\Networthy\` |
139
+
140
+ It's a single SQLite file — back it up by copying it, move machines by moving it.
141
+
142
+ ### With Docker instead
143
+
144
+ ```bash
145
+ docker run -p 8321:8321 -v networthy:/app/data \
146
+ -e NETWORTHY_LOCAL=1 -e APP_PORT=8321 awmanoj/networthy
147
+ ```
148
+
149
+ ### Developing on it
150
+
151
+ ```bash
152
+ python3 -m venv .venv
153
+ source .venv/bin/activate
154
+ pip install -r requirements.txt
155
+
156
+ NETWORTHY_LOCAL=1 uvicorn app.main:app --reload # http://127.0.0.1:8000
157
+ ```
158
+
159
+ Without `NETWORTHY_LOCAL=1` you get the hosted behaviour — sign in with your email, and
160
+ the one-time code is printed to the server log (no email provider needed).
161
+
162
+ ## Testing
163
+
164
+ Tests target the fragile logic — parsers, classification, pricing, the net-worth math,
165
+ and the web routes — without needing a real password-protected PDF.
166
+
167
+ ```bash
168
+ python -m pytest # all
169
+ python -m pytest tests/test_parser.py # one file
170
+ python -m pytest tests/test_goals.py::test_plan_active_required_monthly # one test
171
+ ```
172
+
173
+ > Use `python -m pytest` (not bare `pytest`) so the repo root is on `sys.path`.
174
+
175
+ ## Deployment
176
+
177
+ Containerised, designed to sit behind a reverse proxy (e.g. Caddy for auto-TLS). The
178
+ container port is set by `APP_PORT` (default 8000); the SQLite DB persists in a Docker
179
+ volume mounted at `/app/data`.
180
+
181
+ ```bash
182
+ docker build -t networthy .
183
+ DOCKERHUB_USER=<name> ./deploy.sh [tag] # build + push to Docker Hub
184
+ DOCKERHUB_USER=<name> ./run.sh [tag] # run on the server (published on :8321)
185
+ ```
186
+
187
+ **Email digests** (optional) recompute every user's net worth live, record a daily
188
+ history point, and email a change summary (no-ops to a log without `RESEND_API_KEY`):
189
+
190
+ ```cron
191
+ 30 12 * * 1-6 docker exec networthy python -m app.digest daily # 6 PM IST, Mon–Sat
192
+ 30 12 * * 0 docker exec networthy python -m app.digest weekly # Sunday
193
+ ```
194
+
195
+ **Backups** — `backup.sh` takes a consistent SQLite online backup, gzips it, and prunes
196
+ old copies. Cron it every few hours and copy the archives off-box.
197
+
198
+ ## Privacy invariant
199
+
200
+ This is load-bearing, not a footnote:
201
+
202
+ - Statement contents and parsed financial data are **never written anywhere outside
203
+ `data/`**, and `data/`, `*.pdf`, `*.db` are gitignored.
204
+ - The **one sanctioned egress** is `app/prices.py`, kept deliberately narrow: it sends
205
+ only a **public symbol** to a price API — an equity ticker to Yahoo, a currency pair,
206
+ a coin, a gold symbol — and looks mutual-fund NAVs up **locally** from AMFI's bulk
207
+ file. Never units, values, holdings, PAN, or identity. Every lookup fails soft, so the
208
+ view always falls back to the statement value.
209
+
210
+ ## Project structure
211
+
212
+ ```
213
+ app/
214
+ main.py FastAPI routes (dashboard, net-worth tree, goals, expenses, CAS)
215
+ models.py Dataclasses shared across parser / storage / web
216
+ storage.py SQLite persistence, per-user isolation
217
+ classify.py Asset-class rule engine (section > ISIN > keywords)
218
+ prices.py The ONLY module that egresses (live prices, by symbol)
219
+ networth.py The declarative Assets/Liabilities tree + roll-up
220
+ expenses.py Recurring-spend model (burn, FIRE)
221
+ goals.py Target-by-date planning (required monthly SIP)
222
+ wealth.py Net-worth percentile ranking
223
+ digest.py Daily/weekly email digests
224
+ parser/
225
+ nsdl_cas.py NSDL CAS parsing (the fragile core)
226
+ cams_cas.py CAMS/KFintech CAS parsing
227
+ _common.py Shared decrypt / text / float helpers
228
+ templates/ Jinja2 templates
229
+ static/ CSS + a tiny bit of JS (theme toggle, chart)
230
+ data/ SQLite DB + uploads (gitignored)
231
+ tests/ Parser, classify, pricing, net-worth, and route tests
232
+ ```
233
+
234
+ For a deeper architectural tour — the parsing pipeline, the classification traps, the
235
+ net-worth roll-up, and the design-system conventions — see [`CLAUDE.md`](CLAUDE.md).
@@ -0,0 +1,209 @@
1
+ # Networthy HQ
2
+
3
+ **A private, self-hosted net-worth tracker for Indian investors.** Upload your
4
+ **NSDL CAS** and **CAMS/KFintech** statements, add everything else by hand — property,
5
+ gold, foreign equity, crypto, bank balances, loans — and see your complete net worth
6
+ in one place, priced live where it can be.
7
+
8
+ > **Privacy is the whole point.** Statements are parsed **on your own server** and the
9
+ > parsed database lives under `data/` (gitignored). Nothing about your holdings ever
10
+ > leaves the machine — the *only* things that egress are public price lookups by symbol
11
+ > (a ticker, a currency pair, a coin), never a value, quantity, PAN, or identity.
12
+
13
+ **Try it without signing up:** the landing page has an **Explore the live demo** button
14
+ (`GET /demo`) that drops you into a fully-loaded demo account — no email required.
15
+
16
+ ![Dashboard](docs/screenshots/dashboard.png)
17
+
18
+ ---
19
+
20
+ ## What it does
21
+
22
+ - **Parses password-protected CAS PDFs** — NSDL e-CAS (demat holdings across NSDL +
23
+ CDSL, plus mutual-fund folios) and CAMS/KFintech CAS (all-AMC mutual funds). Decrypt
24
+ → extract → classify by asset class, all locally.
25
+ - **A complete Assets & Liabilities tree** — every asset class as a navigable leaf:
26
+ mutual funds, direct & foreign equity, crypto, fixed income (PPF/EPF/FDs/bonds/NPS),
27
+ gold & silver, physical gold, real estate, alternate investments, private business,
28
+ bank & cash, forex — netted against every kind of loan.
29
+ - **Live valuation** — equities from Yahoo Finance (by ticker), mutual-fund NAVs from
30
+ AMFI's public bulk feed (looked up locally), USD/FX and crypto and gold priced live,
31
+ all cached and fail-soft so a slow endpoint never breaks a page.
32
+ - **Expenses planner** — recurring spend normalised to a monthly/annual burn, with the
33
+ net-worth connection: runway and a FIRE target (25×, the 4% rule).
34
+ - **Goals** — target-by-date planning that computes the **monthly SIP** needed to get
35
+ there, plus a read-only Retirement (FIRE) goal mirrored from your expenses.
36
+ - **"Where do you stand?"** — rank your net worth among adults in India, the US, and the
37
+ world (computed client-side; nothing you type is sent anywhere).
38
+ - **Multi-user** — sign in with email + a one-time code. Each account's data is isolated.
39
+ - **Email digests** — an optional daily net-worth pulse and a weekly breakdown.
40
+
41
+ ## Screens
42
+
43
+ | | |
44
+ |---|---|
45
+ | ![Net worth tree](docs/screenshots/networth.png) | ![Net worth over time](docs/screenshots/nsdl-cas.png) |
46
+ | **Net worth** — the full Assets/Liabilities tree, values rolled up and priced live. | **NSDL CAS** — net worth over time from each uploaded statement. |
47
+ | ![Mutual funds leaf](docs/screenshots/mutual-funds.png) | ![Goals](docs/screenshots/goals.png) |
48
+ | **A data-backed leaf** — holdings from your CAMS import, live NAVs and values. | **Goals** — target, date, and the monthly SIP to reach it. |
49
+ | ![Expenses](docs/screenshots/expenses.png) | |
50
+ | **Expenses** — monthly/annual burn, category breakdown, runway & FIRE. | |
51
+
52
+ ## How it works
53
+
54
+ ```
55
+ upload CAS PDF(s) → parse (decrypt + extract + classify) → SQLite (under data/)
56
+
57
+ manual entries (property, gold, loans, …) ──────────────────────┤
58
+
59
+ live pricing (Yahoo / AMFI, by symbol only) → Dashboard · Net worth · Goals
60
+ ```
61
+
62
+ 1. Download your CAS — NSDL e-CAS from [nsdl.co.in](https://nsdl.co.in), or a CAMS CAS
63
+ from [camsonline.com](https://www.camsonline.com/Investors/Statements/Consolidated-Account-Statement).
64
+ It arrives as a **password-protected PDF** (password is usually your PAN in CAPITALS).
65
+ 2. Upload it (the app remembers your PAN as the password so you don't retype it).
66
+ 3. Add anything a statement doesn't cover — property, physical gold, bank balances,
67
+ foreign equity, crypto, loans — by hand.
68
+ 4. The Dashboard, Net-worth tree, and Goals reflect it all, valued live where possible.
69
+
70
+ ## Tech stack
71
+
72
+ Server-rendered **FastAPI + Jinja2**, **SQLite** (stdlib `sqlite3`), no frontend
73
+ framework and a single hand-written CSS design system ("Ink Navy & Copper", light + dark).
74
+ PDF parsing via **pikepdf** (decrypt) + **pdfplumber** (text). Python 3.11+.
75
+
76
+ ## Run it on your own machine
77
+
78
+ One command. No account, no sign-in, no server — your statements are parsed on your
79
+ laptop and the database never leaves it.
80
+
81
+ ```bash
82
+ uvx networthy
83
+ ```
84
+
85
+ That's it: it starts on `http://127.0.0.1:8321`, opens your browser, and signs you in
86
+ automatically (there's nobody else to authenticate against on your own machine).
87
+
88
+ <details>
89
+ <summary>Don't have <code>uv</code>?</summary>
90
+
91
+ ```bash
92
+ curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
93
+ # Windows: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
94
+ ```
95
+
96
+ Or use pipx: `pipx run networthy`. Or plain pip: `pip install networthy && networthy`.
97
+ </details>
98
+
99
+ **Options**
100
+
101
+ ```bash
102
+ networthy --port 9000 # pick a port (default 8321, or any free one)
103
+ networthy --data-dir ~/my-finances # where the database lives
104
+ networthy --no-browser # don't open a tab
105
+ ```
106
+
107
+ Your data is stored at:
108
+
109
+ | macOS | `~/Library/Application Support/Networthy/` |
110
+ |---------|--------------------------------------------|
111
+ | Linux | `~/.local/share/networthy/` |
112
+ | Windows | `%APPDATA%\Networthy\` |
113
+
114
+ It's a single SQLite file — back it up by copying it, move machines by moving it.
115
+
116
+ ### With Docker instead
117
+
118
+ ```bash
119
+ docker run -p 8321:8321 -v networthy:/app/data \
120
+ -e NETWORTHY_LOCAL=1 -e APP_PORT=8321 awmanoj/networthy
121
+ ```
122
+
123
+ ### Developing on it
124
+
125
+ ```bash
126
+ python3 -m venv .venv
127
+ source .venv/bin/activate
128
+ pip install -r requirements.txt
129
+
130
+ NETWORTHY_LOCAL=1 uvicorn app.main:app --reload # http://127.0.0.1:8000
131
+ ```
132
+
133
+ Without `NETWORTHY_LOCAL=1` you get the hosted behaviour — sign in with your email, and
134
+ the one-time code is printed to the server log (no email provider needed).
135
+
136
+ ## Testing
137
+
138
+ Tests target the fragile logic — parsers, classification, pricing, the net-worth math,
139
+ and the web routes — without needing a real password-protected PDF.
140
+
141
+ ```bash
142
+ python -m pytest # all
143
+ python -m pytest tests/test_parser.py # one file
144
+ python -m pytest tests/test_goals.py::test_plan_active_required_monthly # one test
145
+ ```
146
+
147
+ > Use `python -m pytest` (not bare `pytest`) so the repo root is on `sys.path`.
148
+
149
+ ## Deployment
150
+
151
+ Containerised, designed to sit behind a reverse proxy (e.g. Caddy for auto-TLS). The
152
+ container port is set by `APP_PORT` (default 8000); the SQLite DB persists in a Docker
153
+ volume mounted at `/app/data`.
154
+
155
+ ```bash
156
+ docker build -t networthy .
157
+ DOCKERHUB_USER=<name> ./deploy.sh [tag] # build + push to Docker Hub
158
+ DOCKERHUB_USER=<name> ./run.sh [tag] # run on the server (published on :8321)
159
+ ```
160
+
161
+ **Email digests** (optional) recompute every user's net worth live, record a daily
162
+ history point, and email a change summary (no-ops to a log without `RESEND_API_KEY`):
163
+
164
+ ```cron
165
+ 30 12 * * 1-6 docker exec networthy python -m app.digest daily # 6 PM IST, Mon–Sat
166
+ 30 12 * * 0 docker exec networthy python -m app.digest weekly # Sunday
167
+ ```
168
+
169
+ **Backups** — `backup.sh` takes a consistent SQLite online backup, gzips it, and prunes
170
+ old copies. Cron it every few hours and copy the archives off-box.
171
+
172
+ ## Privacy invariant
173
+
174
+ This is load-bearing, not a footnote:
175
+
176
+ - Statement contents and parsed financial data are **never written anywhere outside
177
+ `data/`**, and `data/`, `*.pdf`, `*.db` are gitignored.
178
+ - The **one sanctioned egress** is `app/prices.py`, kept deliberately narrow: it sends
179
+ only a **public symbol** to a price API — an equity ticker to Yahoo, a currency pair,
180
+ a coin, a gold symbol — and looks mutual-fund NAVs up **locally** from AMFI's bulk
181
+ file. Never units, values, holdings, PAN, or identity. Every lookup fails soft, so the
182
+ view always falls back to the statement value.
183
+
184
+ ## Project structure
185
+
186
+ ```
187
+ app/
188
+ main.py FastAPI routes (dashboard, net-worth tree, goals, expenses, CAS)
189
+ models.py Dataclasses shared across parser / storage / web
190
+ storage.py SQLite persistence, per-user isolation
191
+ classify.py Asset-class rule engine (section > ISIN > keywords)
192
+ prices.py The ONLY module that egresses (live prices, by symbol)
193
+ networth.py The declarative Assets/Liabilities tree + roll-up
194
+ expenses.py Recurring-spend model (burn, FIRE)
195
+ goals.py Target-by-date planning (required monthly SIP)
196
+ wealth.py Net-worth percentile ranking
197
+ digest.py Daily/weekly email digests
198
+ parser/
199
+ nsdl_cas.py NSDL CAS parsing (the fragile core)
200
+ cams_cas.py CAMS/KFintech CAS parsing
201
+ _common.py Shared decrypt / text / float helpers
202
+ templates/ Jinja2 templates
203
+ static/ CSS + a tiny bit of JS (theme toggle, chart)
204
+ data/ SQLite DB + uploads (gitignored)
205
+ tests/ Parser, classify, pricing, net-worth, and route tests
206
+ ```
207
+
208
+ For a deeper architectural tour — the parsing pipeline, the classification traps, the
209
+ net-worth roll-up, and the design-system conventions — see [`CLAUDE.md`](CLAUDE.md).
@@ -0,0 +1,3 @@
1
+ """Networthy — net worth tracking from NSDL CAS statements."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,139 @@
1
+ """Business analytics — adoption metrics for the owner, computed live from the app's
2
+ own tables. First-party by design: nothing here egresses, and it deliberately reads
3
+ only account/usage **metadata** (signups, logins, which features were touched), never
4
+ any financial values — so this surface holds no holdings data even if it leaked.
5
+
6
+ The shared demo account is excluded from every number.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import date, datetime, timedelta
12
+
13
+ from . import storage
14
+ from .demo import DEMO_EMAIL
15
+
16
+ # (label, table, extra WHERE condition or None) — a "feature" is used once a user has
17
+ # at least one row here. Counts distinct users, demo excluded.
18
+ _FEATURES: list[tuple[str, str, str | None]] = [
19
+ ("Uploaded NSDL CAS", "snapshots", None),
20
+ ("Imported CAMS", "networth_holdings", "source = 'cams'"),
21
+ ("Set a goal", "goals", None),
22
+ ("Tracked expenses", "expenses", None),
23
+ ("Real estate", "property_holdings", None),
24
+ ("Crypto", "crypto_holdings", None),
25
+ ("US equity", "foreign_holdings", None),
26
+ ("Fixed income (manual)", "manual_holdings", None),
27
+ ("Bank & cash", "bank_cash", None),
28
+ ("Physical gold", "gold_items", None),
29
+ ("Liabilities", "liabilities", None),
30
+ ("Alternate investments", "alt_investments", None),
31
+ ]
32
+
33
+ # Tables that mean "this user put real data in" — for the activation funnel.
34
+ _ASSET_TABLES = [
35
+ "snapshots", "networth_holdings", "property_holdings", "bank_cash",
36
+ "manual_holdings", "gold_items", "alt_investments", "crypto_holdings",
37
+ "foreign_holdings", "forex_holdings", "business_holdings", "liabilities",
38
+ "goals", "expenses",
39
+ ]
40
+
41
+
42
+ def _iso(days_ago: int) -> str:
43
+ return (datetime.utcnow() - timedelta(days=days_ago)).strftime("%Y-%m-%d %H:%M:%S")
44
+
45
+
46
+ def overview() -> dict:
47
+ with storage._connect() as conn:
48
+ demo = conn.execute("SELECT id FROM users WHERE email = ?", (DEMO_EMAIL,)).fetchone()
49
+ demo_id = demo["id"] if demo else -1
50
+
51
+ def scalar(sql: str, params: tuple = ()) -> int:
52
+ return conn.execute(sql, params).fetchone()[0]
53
+
54
+ total = scalar("SELECT COUNT(*) FROM users WHERE id != ?", (demo_id,))
55
+ new_today = scalar(
56
+ "SELECT COUNT(*) FROM users WHERE id != ? AND created_at >= ?",
57
+ (demo_id, _iso(1)))
58
+ new_7d = scalar(
59
+ "SELECT COUNT(*) FROM users WHERE id != ? AND created_at >= ?",
60
+ (demo_id, _iso(7)))
61
+ new_30d = scalar(
62
+ "SELECT COUNT(*) FROM users WHERE id != ? AND created_at >= ?",
63
+ (demo_id, _iso(30)))
64
+
65
+ # Sign-ins (from the durable login_events log).
66
+ logins_total = scalar("SELECT COUNT(*) FROM login_events WHERE user_id != ?", (demo_id,))
67
+ logins_24h = scalar(
68
+ "SELECT COUNT(DISTINCT user_id) FROM login_events WHERE user_id != ? AND created_at >= ?",
69
+ (demo_id, _iso(1)))
70
+ logins_7d = scalar(
71
+ "SELECT COUNT(DISTINCT user_id) FROM login_events WHERE user_id != ? AND created_at >= ?",
72
+ (demo_id, _iso(7)))
73
+ logins_30d = scalar(
74
+ "SELECT COUNT(DISTINCT user_id) FROM login_events WHERE user_id != ? AND created_at >= ?",
75
+ (demo_id, _iso(30)))
76
+ # Returning = signed in on ≥2 distinct days.
77
+ returning = scalar(
78
+ """
79
+ SELECT COUNT(*) FROM (
80
+ SELECT user_id FROM login_events WHERE user_id != ?
81
+ GROUP BY user_id HAVING COUNT(DISTINCT date(created_at)) >= 2
82
+ )
83
+ """, (demo_id,))
84
+
85
+ # Cumulative signups over time (for the growth chart).
86
+ rows = conn.execute(
87
+ "SELECT date(created_at) d, COUNT(*) n FROM users WHERE id != ? GROUP BY d ORDER BY d",
88
+ (demo_id,)).fetchall()
89
+ cum, chart = 0, []
90
+ for r in rows:
91
+ cum += r["n"]
92
+ chart.append({"date": r["d"], "value": cum})
93
+
94
+ # Feature adoption.
95
+ features = []
96
+ for label, table, cond in _FEATURES:
97
+ where = f"user_id != ?" + (f" AND {cond}" if cond else "")
98
+ n = scalar(f"SELECT COUNT(DISTINCT user_id) FROM {table} WHERE {where}", (demo_id,))
99
+ features.append({"label": label, "users": n,
100
+ "pct": (n / total * 100.0) if total else 0.0})
101
+ features.sort(key=lambda f: f["users"], reverse=True)
102
+
103
+ # Activation funnel: signed up → put in real data → came back.
104
+ union = " UNION ".join(
105
+ f"SELECT user_id FROM {t} WHERE user_id != {demo_id}" for t in _ASSET_TABLES)
106
+ activated = scalar(f"SELECT COUNT(*) FROM (SELECT DISTINCT user_id FROM ({union}))")
107
+ funnel = [
108
+ {"label": "Signed up", "users": total, "pct": 100.0},
109
+ {"label": "Added real data", "users": activated,
110
+ "pct": (activated / total * 100.0) if total else 0.0},
111
+ {"label": "Came back (≥2 days)", "users": returning,
112
+ "pct": (returning / total * 100.0) if total else 0.0},
113
+ ]
114
+
115
+ # Recent signups with a last-seen — the email listing.
116
+ recent = conn.execute(
117
+ """
118
+ SELECT u.email, u.created_at,
119
+ (SELECT MAX(created_at) FROM login_events e WHERE e.user_id = u.id) last_seen
120
+ FROM users u WHERE u.id != ?
121
+ ORDER BY u.created_at DESC LIMIT 200
122
+ """, (demo_id,)).fetchall()
123
+
124
+ def fmt(s: str | None) -> str | None:
125
+ if not s:
126
+ return None
127
+ try:
128
+ return datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S").strftime("%d %b %Y")
129
+ except ValueError:
130
+ return s
131
+
132
+ return {
133
+ "total_users": total, "new_today": new_today, "new_7d": new_7d, "new_30d": new_30d,
134
+ "returning": returning, "logins_total": logins_total,
135
+ "logins_24h": logins_24h, "logins_7d": logins_7d, "logins_30d": logins_30d,
136
+ "signup_chart": chart, "features": features, "funnel": funnel,
137
+ "recent": [{"email": r["email"], "joined": fmt(r["created_at"]),
138
+ "last_seen": fmt(r["last_seen"])} for r in recent],
139
+ }