Cross-session backlog. Check items off as completed, add new ones as they're discovered. Organised by phase — work roughly top to bottom, but phases aren't strictly sequential.
- Commit the currently uncommitted work:
tests/,pytest.ini,import_routes.py,report_routes.py, the squashed alembic migration, and the modified core/api/schema files - Fix route prefix collision:
transaction_routes.pyandimport_routes.pyboth registerAPIRouter(prefix="/transactions")— added:intpath converters totransaction_routes.py's{transaction_id}routes so they can never shadow/transactions/import. Verified: full pytest suite green (105 passed) using the project's.venv(Python 3.14) - Rename
core/dependacies.py→core/dependencies.py(typo) - Fix
DockerfileCMD — points atserver.wsgi:application(Django-style, doesn't exist); now runsuvicorn src.pyfinbot.pyfinbot:app, matchingdocker-compose.yml - Fix
Dockerfilebind address typo000.0.0.0:8001→0.0.0.0:8001(fixed as part of the CMD rewrite above)
- Fix CSV/Excel import building
Transactiondirectly with a raw date string, bypassing all date parsing —Transaction.model_post_initcrashed computingfy('str' object has no attribute 'month') on every row. Fixed by extractingparse_transaction_dateout ofTransactionBase's validator into a shared function (schemas/transaction_schemas.py) and calling it fromimport_routes.py - Fix CSV/Excel import treating empty cells as pandas
NaN(truthy, unlikeNone) instead ofNone— brokevalue or defaultfallbacks and violated thefeescolumn'sNOT NULLconstraint. Fixed in_parse_dataframeby casting toobjectdtype before replacingNaNwithNone(plain.where(pd.notna(df), None)doesn't stick on numeric-dtype columns — pandas silently re-coercesNoneback toNaN) - Wire app startup to run Alembic migrations (
alembic upgrade head) instead ofinit_db()callingSQLModel.metadata.create_all—db/session.py'sinit_db()now runsalembic.command.upgrade(cfg, "head")off the event loop viaasyncio.to_thread, withscript_location/prepend_sys_pathoverridden to absolute paths so it's not dependent on the process's CWD (unlike the documented CLI workflow). Verified with an isolated SQLite smoke test (migration runs, creates the 3 tables +alembic_version, is idempotent on a second run) — not run against the real.env-configured database - Make SQL echo logging (
create_async_engine(..., echo=True)indb/session.py) env-driven instead of hardcoded on — addedDB_ECHO: bool = Falsetocore/settings.py, defaulting off (was unconditionally logging full SQL + parameter values, a real concern in production) - Add dedupe/idempotency check to
import_routes.pyso re-uploading the same file doesn't create duplicate transactions — app-level pre-insert check only (not a DB-wide unique constraint, to avoid blocking legitimate identical re-entries via the manual create endpoint), keyed on(user_id, stock_id, transaction_date, type, units, price, fees); catches both re-uploads and duplicate rows within the same file, since it queries against the session after each row's flush - Move the inline
BaseModelschemas inreport_routes.py/import_routes.pyintoschemas/, matching the Base/Create/Update/Read pattern used elsewhere — newschemas/report_schemas.py(HoldingItem,HoldingsReport,CapitalGainsItem,CapitalGainsReport) andschemas/import_schemas.py(ImportSummary), both wired intoschemas/__init__.py; pure relocation, no behavior change - Minor:
Stock.search()(models/stock_models.py:32) usessession.execute()instead of SQLModel'ssession.exec(), andstock_routes.py/transaction_routes.py's list endpoints call fastapi-pagination's deprecatedpaginate()instead ofapaginate()— both swapped over (.scalar_one_or_none()→.one_or_none()to match, sincesession.exec()returns aScalarResultnot aResult). Warning count dropped from 66 to 4 per test run; the remaining 4 originate inside thefastapi_paginationlibrary itself (ext/sqlalchemy.py:310), not app code — out of scope
- Decide on an auth approach (session/JWT/OAuth2) for multi-user support — JWT bearer tokens via
OAuth2PasswordBearer(FastAPI's own idiomatic pattern): stateless, no session-store infra needed, free/docsSwagger "Authorize" button - Wire up the existing but unused
hash_password/verify_passwordincore/security.py— now called fromuser_routes.py(register/change password) andauth_routes.py(login) - Add login/token issuance endpoints —
POST /api/auth/login(auth_routes.py),OAuth2PasswordRequestForm, issues a JWT signed withSECRET_KEY(ALGORITHM = "HS256",ACCESS_TOKEN_EXPIRE_MINUTES = 1440). Registration reuses the existingPOST /users/rather than a separate/auth/register—UserCreate.password/UserUpdate.passwordadded instead of a parallel endpoint - Replace the spoofable
X-User-IDheader mechanism with enforced authentication on user/transaction routes —x_user_id_depremoved entirely;get_current_user(core/dependencies.py) decodes the JWT and loads the realUser, used across all 8 former call sites (transaction_routes.py×5,report_routes.py×2,import_routes.py×1) plus newly added touser_routes.py's own CRUD (previously had zero ownership checks). Also closed a real spoofing gap:create_transactionused to trust a client-suppliedtransaction_in.user_idover the header —user_idis now removed fromTransactionBaseentirely and always comes from the authenticated token. The_ensureUser/_ensure_userauto-create-on-first-transaction functions were removed (dead once every route requires an already-registered, authenticated user) - Add CORS and env-mode (dev/prod) settings to
core/settings.py— newENVIRONMENT("development"/"production") andCORS_ORIGINS(comma-separated allow-list) settings, plusCORSMiddlewareregistered inpyfinbot.py.developmentallows all origins whenCORS_ORIGINSis unset (frictionless local/Swagger testing);productionallows none by default and emits a startup warning ifCORS_ORIGINSis left unset, mirroring the existingSECRET_KEYunsafe-default pattern. Also extracted the.envvariable layout out of README's inlined prose into a tracked.env.example
GET /users/(list_users) requires a valid token but returns every user unfiltered — no admin/RBAC system was built; deliberate scope boundary, not an oversight- Stateless JWT, 24h expiry, no refresh/revocation — a leaked token is valid for up to 24h with no way to force-logout. Acceptable for a personal-use app; would need a token blocklist or short-lived+refresh tokens to harden
SECRET_KEYdefaults to a random value generated fresh on every process start (not a hardcoded/empty fallback, which would be a silent security hole) — a real deployment that needs tokens to survive a restart must setSECRET_KEYexplicitly in.env(a startup warning fires when it's unset)
- Fix
tests/conftest.py'senginefixture callingSQLModel.metadata.create_allbefore any model module was ever imported, so it silently created zero tables (sqlite3.OperationalError: no such table: user). Fixed by importingpyfinbot.modelsat module load time inconftest.py - Fix per-test DB rollback not actually isolating tests (data from one test was visible in the next) — root cause is a well-known pysqlite/aiosqlite quirk where the driver autocommits outside of DML statements, defeating SAVEPOINT-based isolation unless the SQLAlchemy-documented event-listener workaround is applied. Added to the
enginefixture inconftest.py - Fix
conftest.pyimporting plainsqlalchemy.ext.asyncio.AsyncSessioninstead ofsqlmodel.ext.asyncio.session.AsyncSession— causedAttributeError: 'AsyncSession' object has no attribute 'exec'in every route that callssession.exec(...)(the app itself uses the SQLModel session everywhere) - Re-enable
.github/workflows/general_tests.yml(currently fully commented out) and update it to run the pytest suite, not the staleunittest discovercommand — also bumpedactions/checkout/actions/setup-pythonfromv2tov4/v5(the old versions rely on deprecated GitHub Actions runtimes) - Add lint/type-checking (e.g. ruff + mypy) and wire into CI — new
pyproject.tomlwith[tool.ruff]/[tool.mypy]config, separatelintjob. Ruff is blocking and fully clean (found and fixed genuine issues: unused imports in__init__.pyre-exports viaasalias, an unused exception variable, missingTYPE_CHECKINGforward-ref imports onStock/Usermodels,alembic/env.pyexcluded as Alembic-generated scaffolding). Mypy is advisory only (continue-on-error: true) — it reports 21 pre-existing findings, all confirmed to be inherent SQLModel/SQLAlchemy typing-system friction (even with SQLAlchemy's own mypy plugin enabled), not real bugs; making it blocking would need either scattering many targeted# type: ignores across ORM code or a real typing refactor, disproportionate to this task - Add test coverage reporting —
pytest-covadded,pytest.ininow runs with--cov-report=term-missing --cov-fail-under=80(measured baseline was 87%; 80% is a regression guard, not an aspirational target) - Make mypy blocking in CI (currently advisory, see above) — needs either targeted
# type: ignores at the 21 known SQLModel/SQLAlchemy-typing-friction call sites (Field(sa_type=...)overloads,Model.id.in_(...),selectinload(Model),apaginate(session, stmt),order_by(col, col)), or waiting for better upstream SQLModel type stubs
Mirrors and supersedes the README's "Planned Milestones" list, which is now out of date.
- MVP — schema design, transaction insertion, SQL-based queries
- Import System — CSV/Excel import of stock transactions (
import_routes.py) - Reporting Module — FY-based holdings & capital-gains reports (
report_routes.py, average cost basis) - FIFO Method Support — accurate gain/loss computation using FIFO (and per-parcel tracking), as an alternative/addition to average cost basis
- CLI Interface — interact via command line with exportable summaries
- Web Dashboard (optional) — simple front end to view/interact with data
- Update README's Planned Milestones section to match Phase 4 above (Import/Reporting are done, not "planned")
- Update README's Testing section — replace
python -m unittest discover -s testswith the pytest invocation - Note in README that
todo.mdis the live project backlog - Broader README pass: fixed the dead CI badge link, added License/Python/FastAPI/SQLModel badges, and added Tech Stack, Project Structure, Getting Started, and API Overview sections (previously had no setup instructions at all)
- Add Gmail IMAP settings to
core/settings.py—GMAIL_ADDRESS,GMAIL_APP_PASSWORD,GMAIL_IMAP_HOST(defaultimap.gmail.com),GMAIL_IMAP_PORT(default993),GMAIL_MAILBOX(defaultINBOX),COMMSEC_SENDER(defaultbounceback@commsec.com.au, confirmed from a real Commsec confirmation email'sFrom:header). Uses a Gmail App Password overimaplib(stdlib), not the OAuth Gmail API — simplest option, no Google Cloud project needed - Extract
_is_duplicateout ofimport_routes.pyintocore/dedupe.py(is_duplicate_transaction) so both the CSV/Excel importer and the new email importer share one dedup implementation instead of one importing the other's_-prefixed internal - Add
core/email_sync.py— synchronous IMAP fetch (fetch_commsec_emails, run viaasyncio.to_thread), multipart body extraction withtext/plain→ BeautifulSoup-strippedtext/htmlfallback (extract_body), andreceived_at()which parses the email'sDateheader as a trade-date stand-in (Commsec's confirmation emails have no explicit trade-date field, but are sent promptly after the trade) - Add
core/commsec_parser.py(parse_commsec_email) — built and verified against two real Commsec confirmation emails (a buy and a sell). This email format has no contract-note/reference number, so dedup relies on content matching (user_id, stock_id, transaction_date, type, units, price, fees) rather than a unique reference; brokerage isn't stated directly and is derived as|total_settlement − units×price|; subject and body are cross-checked (action/symbol/units) and raiseCommsecParseErroron mismatch or missing fields. Other Commsec email types (partial fills, DRP, managed funds, corporate actions) are untested against this parser and will surface as errors rather than being mis-parsed - Add
POST /api/emails/sync-commsec(api/email_routes.py, optional?include_seen=for debugging) — fetches unseen Commsec emails, parses each, resolves the stock via_searchForStockasASX:{symbol}, builds aTransaction, dedupes, commits once, marks only successfully-processed messages\Seen(so parse/lookup failures keep retrying next sync rather than being silently dropped). ReturnsEmailSyncSummary; 503 if Gmail credentials unset, 502 on IMAP failure. Matches the manual-trigger pattern ofPOST /api/stocks/sync/{market}— no scheduler/cron added - Add
tests/fixtures/commsec_emails/{bought_rmd,sold_wow}.txt(built directly from the real sample emails),tests/test_commsec_parser.py(15 tests: field extraction, derived brokerage, error cases),tests/test_email_sync.py(8 tests: IMAP mocked, dedup on resync, 503/502 error paths, unknown-stock and unparseable-email handling). Verified: full pytest suite green
- Add
Dividendmodel (models/dividend_models.py) — stock-scoped, not user-scoped:stock_idFK,ex_date,pay_date,amount_per_share(Numeric(18,6)),source, unique on(stock_id, ex_date). A dividend is a stock-level market fact shared across all holders; per-user "amount received" is computed on read inreport_routes.pyrather than stored, matching how holdings/capital-gains are already computed live fromTransactionrows instead of cached. Wired intoStock.dividendsrelationship andmodels/__init__.py - Generate Alembic migration
a7ab2f6a51dc_add_dividend_table.pyviaalembic revision --autogenerateagainst an isolated SQLite smoke DB (not the real Postgres DB) — verifiedalembic upgrade headandalembic downgrade -1both apply cleanly - Add
core/holdings.py(units_held_as_of) — the weighted BUY−SELL logic extracted fromget_holdings, reused for dividend-total calculations so it isn't reimplemented a third time - Add
core/dividend_sync.py—MARKET_TO_YF_SUFFIXregistry (ASX only, matchingmarket_sync.py's existing market-coverage limitation),fetchDividendsForSymbol(yfinanceTicker.dividends),syncDividendsupsert keyed on(stock_id, ex_date), followingmarket_sync.py's injectable-fetcher pattern - Add
POST /api/dividends/sync(api/dividend_routes.py, optional?stock_id=) — defaults to every stock the calling user has ever transacted (not the fullStocktable, to avoid a slow/rate-limited full-market yfinance fetch) - Add
tests/test_dividend_sync.py(7 tests: create/update/no-op/isolation/error handling, mocked fetcher) andtests/test_dividends.py(4 tests: endpoint integration, stock-id targeting). Verified: full pytest suite green
- Extract
au_fiscal_yearintocore/fiscal_year.py;Transaction.model_post_initnow calls it instead of an inline calc — mechanical, no behavior change, verified against the existingtest_models.py/test_transactions.pysuite - Add
GET /api/reports/dividends?fy=(report_routes.py) — for eachDividendbelonging to a stock the user has ever transacted, computes units held on the ex-date and the resulting amount received; FY-scoped byau_fiscal_year(ex_date)whenfygiven, all-time otherwise. NewDividendItem/DividendsReportschemas inschemas/report_schemas.py, mirroringget_capital_gains's FY-scoping style - Add
total_dividends_receivedtoHoldingItem/HoldingsReport— sum of dividends withex_date <= as_of, each weighted by units held on its specific ex-date; additive/backward-compatible field (defaults to0.0, not null) - Extend
tests/test_reports.pywithTestDividendsReport(9 tests: before/mid/after-holding weighting, FY filter, multi-stock totals, per-user scoping) and 2 new dividend cases inTestHoldings. Verified: full pytest suite green (test_reports.pynow 26 tests)