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.
- networthy-0.1.0/PKG-INFO +235 -0
- networthy-0.1.0/README.md +209 -0
- networthy-0.1.0/app/__init__.py +3 -0
- networthy-0.1.0/app/analytics.py +139 -0
- networthy-0.1.0/app/auth.py +272 -0
- networthy-0.1.0/app/classify.py +119 -0
- networthy-0.1.0/app/demo.py +124 -0
- networthy-0.1.0/app/digest.py +257 -0
- networthy-0.1.0/app/expenses.py +126 -0
- networthy-0.1.0/app/goals.py +125 -0
- networthy-0.1.0/app/launcher.py +122 -0
- networthy-0.1.0/app/mailer.py +54 -0
- networthy-0.1.0/app/main.py +2006 -0
- networthy-0.1.0/app/models.py +78 -0
- networthy-0.1.0/app/networth.py +213 -0
- networthy-0.1.0/app/parser/__init__.py +7 -0
- networthy-0.1.0/app/parser/_common.py +56 -0
- networthy-0.1.0/app/parser/cams_cas.py +257 -0
- networthy-0.1.0/app/parser/nsdl_cas.py +402 -0
- networthy-0.1.0/app/prices.py +223 -0
- networthy-0.1.0/app/projection.py +318 -0
- networthy-0.1.0/app/static/apple-touch-icon.png +0 -0
- networthy-0.1.0/app/static/chart.js +158 -0
- networthy-0.1.0/app/static/favicon-32.png +0 -0
- networthy-0.1.0/app/static/favicon.svg +6 -0
- networthy-0.1.0/app/static/og-image.png +0 -0
- networthy-0.1.0/app/static/plan-chart.js +82 -0
- networthy-0.1.0/app/static/retire.js +165 -0
- networthy-0.1.0/app/static/standing.js +426 -0
- networthy-0.1.0/app/static/style.css +1389 -0
- networthy-0.1.0/app/storage.py +1749 -0
- networthy-0.1.0/app/templates/_footer.html +21 -0
- networthy-0.1.0/app/templates/_head_meta.html +27 -0
- networthy-0.1.0/app/templates/_notes.html +7 -0
- networthy-0.1.0/app/templates/about.html +61 -0
- networthy-0.1.0/app/templates/admin.html +122 -0
- networthy-0.1.0/app/templates/base.html +78 -0
- networthy-0.1.0/app/templates/cams_import.html +129 -0
- networthy-0.1.0/app/templates/expenses.html +179 -0
- networthy-0.1.0/app/templates/goals.html +152 -0
- networthy-0.1.0/app/templates/index.html +134 -0
- networthy-0.1.0/app/templates/landing.html +173 -0
- networthy-0.1.0/app/templates/login.html +21 -0
- networthy-0.1.0/app/templates/marketing_base.html +34 -0
- networthy-0.1.0/app/templates/networth.html +169 -0
- networthy-0.1.0/app/templates/networth_node.html +952 -0
- networthy-0.1.0/app/templates/networth_overview.html +81 -0
- networthy-0.1.0/app/templates/plan.html +234 -0
- networthy-0.1.0/app/templates/privacy.html +62 -0
- networthy-0.1.0/app/templates/retire.html +224 -0
- networthy-0.1.0/app/templates/standing.html +243 -0
- networthy-0.1.0/app/templates/terms.html +58 -0
- networthy-0.1.0/app/templates/upload.html +124 -0
- networthy-0.1.0/app/templates/verify.html +26 -0
- networthy-0.1.0/app/wealth.py +314 -0
- networthy-0.1.0/networthy.egg-info/PKG-INFO +235 -0
- networthy-0.1.0/networthy.egg-info/SOURCES.txt +87 -0
- networthy-0.1.0/networthy.egg-info/dependency_links.txt +1 -0
- networthy-0.1.0/networthy.egg-info/entry_points.txt +2 -0
- networthy-0.1.0/networthy.egg-info/requires.txt +7 -0
- networthy-0.1.0/networthy.egg-info/top_level.txt +1 -0
- networthy-0.1.0/pyproject.toml +52 -0
- networthy-0.1.0/setup.cfg +4 -0
- networthy-0.1.0/tests/test_alt.py +25 -0
- networthy-0.1.0/tests/test_analytics.py +103 -0
- networthy-0.1.0/tests/test_auth.py +73 -0
- networthy-0.1.0/tests/test_cams_import.py +109 -0
- networthy-0.1.0/tests/test_cams_parser.py +119 -0
- networthy-0.1.0/tests/test_classify.py +53 -0
- networthy-0.1.0/tests/test_demo.py +84 -0
- networthy-0.1.0/tests/test_digest.py +99 -0
- networthy-0.1.0/tests/test_edit.py +135 -0
- networthy-0.1.0/tests/test_equity_threshold.py +58 -0
- networthy-0.1.0/tests/test_expenses.py +151 -0
- networthy-0.1.0/tests/test_foreign.py +94 -0
- networthy-0.1.0/tests/test_goals.py +129 -0
- networthy-0.1.0/tests/test_gold.py +39 -0
- networthy-0.1.0/tests/test_landing.py +51 -0
- networthy-0.1.0/tests/test_liabilities.py +43 -0
- networthy-0.1.0/tests/test_local_mode.py +193 -0
- networthy-0.1.0/tests/test_manual.py +41 -0
- networthy-0.1.0/tests/test_networth.py +161 -0
- networthy-0.1.0/tests/test_parser.py +210 -0
- networthy-0.1.0/tests/test_prices.py +120 -0
- networthy-0.1.0/tests/test_projection.py +356 -0
- networthy-0.1.0/tests/test_seo.py +216 -0
- networthy-0.1.0/tests/test_storage.py +498 -0
- networthy-0.1.0/tests/test_trend.py +83 -0
- networthy-0.1.0/tests/test_wealth.py +208 -0
networthy-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
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
|
+
|  |  |
|
|
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
|
+
|  |  |
|
|
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
|
+
|  | |
|
|
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
|
+

|
|
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
|
+
|  |  |
|
|
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
|
+
|  |  |
|
|
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
|
+
|  | |
|
|
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,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
|
+
}
|