Milestone: Finance M5 · Depends on: FIN-24 (investments pipeline), FIN-01 (SnapTrade Fidelity enablement APPROVED — hard external gate).
Goal
SnapTrade adapter — the Fidelity path (and Schwab/Robinhood/Webull/etc.). Proves the provider abstraction: a SnapTrade-connected brokerage lands in the SAME tables and UI with zero schema changes.
SnapTrade model (differs from Plaid — read first)
- Auth: partner
clientId + consumerKey (settings). Per end-user: register a SnapTrade user (POST /snapTrade/registerUser) → userId + userSecret (encrypt userSecret at rest: context=f"finance_connection:{id}:access_token" — it IS the access credential; store in access_token_encrypted, connection_type='aggregator_token').
- Connect: generate a redirect URI to the SnapTrade connection portal (
/snapTrade/login → redirectURI); user completes brokerage OAuth there (Fidelity = Fidelity Access flow); portal redirects back. Connection = "authorization" (provider_item_id = authorization id).
- No cursor/webhook-driven deltas like Plaid transactions/sync: poll
GET /accounts, GET /accounts/{id}/positions, GET /accounts/{id}/balances, GET /activities (date-windowed). Respect the launch-guide polling budget: holdings ≤4x/day, activities 1x/24h — our nightly job is well inside it.
- SDK:
snaptrade-python-sdk (official) — pyproject.jinja {%- if include_finance and finance_snaptrade %} block + pyproject_deps parity.
providers/snaptrade.py implementing the FIN-19 protocol: create_link → portal redirect URI; exchange → persist authorization as connection; fetch_accounts → NormalizedAccount (type=investment/brokerage, balances → cents); fetch_holdings → positions mapped to securities (dedup by symbol/FIGI fields SnapTrade provides — SAME upsert helper as FIN-24, shared, not copied) + dated holdings; fetch_investment_transactions → /activities mapped to finance_trade (source='snaptrade', activity id → external_id; canonicalize their type names: BUY/SELL/DIVIDEND/CONTRIBUTION/…).
- Endpoints:
POST /finance/snaptrade/connect → {redirect_uri}; GET /finance/snaptrade/callback handler completing the connection; both flag-gated.
- Wire into nightly job + CLI (capability-gated: SnapTrade connections sync holdings/activities, not card transactions).
Acceptance criteria
Shared context (read this first — identical in every FIN ticket)
What we're building: a Finance Service for aegis-stack — a personal-finance aggregator (Empower/Quicken class: linked bank/credit/brokerage accounts, Quicken/OFX/CSV import, net-worth-over-time, "wasting money" insights). It is a gated template service like payment/insights: a new include_finance copier flag + a SERVICES["finance"] registry entry. Nothing is bolted into any one generated project.
Authoritative design docs (in this repo):
docs/plans/finance-service/finance-service-plan.md — the full plan.
docs/plans/finance-service/finance-schema-canonical.md — THE schema: 33 tables, every column/FK/index/unique/check, dedup contract, ER diagram. Schema tickets inline their slice, but this file is the tiebreaker.
docs/plans/finance-service/finance-research/ — Plaid/OFX/product research briefs.
The two parallel systems (keep them in sync — this is the #1 thing to understand):
- Copier template —
aegis/templates/copier-aegis-project/{{ project_slug }}/…. Gating = literal {% if include_finance %} blocks in .jinja files + questions in copier.yml (repo root).
- Python registry —
aegis/core/services.py → the SERVICES dict. Single source of truth for: post-gen file pruning (aegis/core/post_gen_tasks.py:139-146 removes every path in a spec's FileManifest.primary when its flag is off), aegis add-service, migrations, aegis update disk-detection (marker_path), and aegis init service listing. Copy the payment spec (services.py:677-763) as the reference.
Migrations are GENERATED, not hand-written. Table definitions live as declarative specs in aegis/core/migration_generator.py: TableSpec / ColumnSpec / IndexSpec(name, columns, unique, where=…) (a where= renders BOTH sqlite_where and postgresql_where — partial uniques work on both engines) / ForeignKeySpec(columns, ref_table, ref_columns, ondelete=…, ref_schema=…) / CheckConstraintSpec(name, sqltext) / AlterTableSpec (for circular FKs; runs in one op.batch_alter_table, SQLite-safe). Revision IDs are auto-assigned at generation time (get_next_revision_id, max+1 zero-padded) — NEVER hardcode a revision number. Which migrations generate is decided by get_services_needing_migrations(context) (migration_generator.py:~1771) — finance gets a block there mirroring payment's (:~1839).
Generated-project conventions (non-negotiable, from the schema doc §conventions):
int autoincrement PKs (id: int | None = Field(default=None, primary_key=True)) — NO UUIDs.
- Money = int minor units (cents) + a
currency code column. NO Decimal/Numeric/float. Fractional share quantities = quantity_e8 (int, ×1e8); prices = int + price_scale; FX = rate_e8.
- Timestamps = naive UTC via a
utcnow_naive() helper (datetime.now(UTC).replace(tzinfo=None)).
- Enums we own =
String column + CheckConstraint (never native PG enum). Provider taxonomies that grow (Plaid account subtype, PFC categories, security_type) = plain TEXT, no check.
- JSON via
sa_column=Column("name", JSON); the attr metadata_ maps to DB column "metadata" (SQLModel reserves metadata).
- Every user-scoped row:
owner_user_id FK → user.id (CASCADE, indexed) + nullable organization_id (SET NULL, indexed). These FKs target the auth service's tables — finance declares required_services=["auth"]. Cross-schema FK precedent: payment.payment_customer → auth.user uses ForeignKeySpec(..., ref_schema="auth").
- Every FK indexed. ONE documented exception:
currency FKs (low-cardinality) stay unindexed, ON DELETE RESTRICT.
- All finance tables are
finance_-prefixed; explicit __tablename__; index names ix_…, uniques uq_…, checks ck_….
- Services:
class FinanceService: def __init__(self, db: AsyncSession); queries via sqlmodel.select + await self.db.exec(…); writes self.db.add(…) + await self.db.flush() — services NEVER commit (the request-scoped get_async_db dependency commits).
- Dedup = real UNIQUE constraints + dialect-dispatched idempotent UPSERT (
sqlite.insert(...).on_conflict_do_update(...) vs postgresql.insert(...) — pick by db.bind.dialect.name).
Dev workflow / how to validate (all from the aegis-stack repo root):
make check # repo's own lint + typecheck + tests — must stay green
make test-template # generate a project from the template + validate it
make test-stacks-quick # 3 representative stacks: base, everything, insights
make clean-test-projects # remove generated test projects
# Generate a throwaway project from your UNCOMMITTED working tree:
uv run aegis init fin-smoke --dev --no-interactive -y \
-o /tmp/aegis-fin-test \
-c database,scheduler -s auth,finance
# (…and a second one WITHOUT `finance` in -s to prove pruning.)
Generated-project checks (run inside the generated project): make test, make lint, boot the app, hit endpoints with curl.
Postgres matters: the template supports sqlite AND postgres. FK enforcement, partial-unique behavior, and ON CONFLICT semantics must be validated against postgres too (generate with a postgres database answer, or run the migration against a local postgres). SQLite test sessions don't enforce FKs — don't trust green sqlite tests for constraint behavior.
Ticket codes: FIN-01…FIN-27. Dependencies are cited by code. Do not start a ticket whose dependencies aren't merged.
Milestone: Finance M5 · Depends on: FIN-24 (investments pipeline), FIN-01 (SnapTrade Fidelity enablement APPROVED — hard external gate).
Goal
SnapTrade adapter — the Fidelity path (and Schwab/Robinhood/Webull/etc.). Proves the provider abstraction: a SnapTrade-connected brokerage lands in the SAME tables and UI with zero schema changes.
SnapTrade model (differs from Plaid — read first)
clientId+consumerKey(settings). Per end-user: register a SnapTrade user (POST /snapTrade/registerUser) →userId+userSecret(encrypt userSecret at rest:context=f"finance_connection:{id}:access_token"— it IS the access credential; store inaccess_token_encrypted,connection_type='aggregator_token')./snapTrade/login→ redirectURI); user completes brokerage OAuth there (Fidelity = Fidelity Access flow); portal redirects back. Connection = "authorization" (provider_item_id = authorization id).GET /accounts,GET /accounts/{id}/positions,GET /accounts/{id}/balances,GET /activities(date-windowed). Respect the launch-guide polling budget: holdings ≤4x/day, activities 1x/24h — our nightly job is well inside it.snaptrade-python-sdk(official) — pyproject.jinja{%- if include_finance and finance_snaptrade %}block +pyproject_depsparity.providers/snaptrade.pyimplementing the FIN-19 protocol:create_link→ portal redirect URI;exchange→ persist authorization as connection;fetch_accounts→ NormalizedAccount (type=investment/brokerage, balances → cents);fetch_holdings→ positions mapped to securities (dedup by symbol/FIGI fields SnapTrade provides — SAME upsert helper as FIN-24, shared, not copied) + dated holdings;fetch_investment_transactions→/activitiesmapped tofinance_trade(source='snaptrade', activity id → external_id; canonicalize their type names: BUY/SELL/DIVIDEND/CONTRIBUTION/…).POST /finance/snaptrade/connect→{redirect_uri};GET /finance/snaptrade/callbackhandler completing the connection; both flag-gated.Acceptance criteria
provider='snaptrade', secret encrypted (v2:prefix, masked repr).finance_security/finance_holding/finance_trade— run the FIN-18 modal: the brokerage renders identically to a Plaid investment account (screenshot in PR).finance_securityrow (cross-provider dedup proven with a fixture).finance_snaptrade=falseproject: no SDK dep, no endpoints, everything else green.Shared context (read this first — identical in every FIN ticket)
What we're building: a Finance Service for aegis-stack — a personal-finance aggregator (Empower/Quicken class: linked bank/credit/brokerage accounts, Quicken/OFX/CSV import, net-worth-over-time, "wasting money" insights). It is a gated template service like
payment/insights: a newinclude_financecopier flag + aSERVICES["finance"]registry entry. Nothing is bolted into any one generated project.Authoritative design docs (in this repo):
docs/plans/finance-service/finance-service-plan.md— the full plan.docs/plans/finance-service/finance-schema-canonical.md— THE schema: 33 tables, every column/FK/index/unique/check, dedup contract, ER diagram. Schema tickets inline their slice, but this file is the tiebreaker.docs/plans/finance-service/finance-research/— Plaid/OFX/product research briefs.The two parallel systems (keep them in sync — this is the #1 thing to understand):
aegis/templates/copier-aegis-project/{{ project_slug }}/…. Gating = literal{% if include_finance %}blocks in.jinjafiles + questions incopier.yml(repo root).aegis/core/services.py→ theSERVICESdict. Single source of truth for: post-gen file pruning (aegis/core/post_gen_tasks.py:139-146removes every path in a spec'sFileManifest.primarywhen its flag is off),aegis add-service, migrations,aegis updatedisk-detection (marker_path), andaegis initservice listing. Copy thepaymentspec (services.py:677-763) as the reference.Migrations are GENERATED, not hand-written. Table definitions live as declarative specs in
aegis/core/migration_generator.py:TableSpec/ColumnSpec/IndexSpec(name, columns, unique, where=…)(awhere=renders BOTHsqlite_whereandpostgresql_where— partial uniques work on both engines) /ForeignKeySpec(columns, ref_table, ref_columns, ondelete=…, ref_schema=…)/CheckConstraintSpec(name, sqltext)/AlterTableSpec(for circular FKs; runs in oneop.batch_alter_table, SQLite-safe). Revision IDs are auto-assigned at generation time (get_next_revision_id, max+1 zero-padded) — NEVER hardcode a revision number. Which migrations generate is decided byget_services_needing_migrations(context)(migration_generator.py:~1771) — finance gets a block there mirroring payment's (:~1839).Generated-project conventions (non-negotiable, from the schema doc §conventions):
intautoincrement PKs (id: int | None = Field(default=None, primary_key=True)) — NO UUIDs.currencycode column. NO Decimal/Numeric/float. Fractional share quantities =quantity_e8(int, ×1e8); prices = int +price_scale; FX =rate_e8.utcnow_naive()helper (datetime.now(UTC).replace(tzinfo=None)).Stringcolumn +CheckConstraint(never native PG enum). Provider taxonomies that grow (Plaid account subtype, PFC categories, security_type) = plain TEXT, no check.sa_column=Column("name", JSON); the attrmetadata_maps to DB column"metadata"(SQLModel reservesmetadata).owner_user_idFK →user.id(CASCADE, indexed) + nullableorganization_id(SET NULL, indexed). These FKs target the auth service's tables — finance declaresrequired_services=["auth"]. Cross-schema FK precedent:payment.payment_customer → auth.userusesForeignKeySpec(..., ref_schema="auth").currencyFKs (low-cardinality) stay unindexed,ON DELETE RESTRICT.finance_-prefixed; explicit__tablename__; index namesix_…, uniquesuq_…, checksck_….class FinanceService: def __init__(self, db: AsyncSession); queries viasqlmodel.select+await self.db.exec(…); writesself.db.add(…)+await self.db.flush()— services NEVER commit (the request-scopedget_async_dbdependency commits).sqlite.insert(...).on_conflict_do_update(...)vspostgresql.insert(...)— pick bydb.bind.dialect.name).Dev workflow / how to validate (all from the aegis-stack repo root):
Generated-project checks (run inside the generated project):
make test,make lint, boot the app, hit endpoints with curl.Postgres matters: the template supports sqlite AND postgres. FK enforcement, partial-unique behavior, and
ON CONFLICTsemantics must be validated against postgres too (generate with a postgres database answer, or run the migration against a local postgres). SQLite test sessions don't enforce FKs — don't trust green sqlite tests for constraint behavior.Ticket codes: FIN-01…FIN-27. Dependencies are cited by code. Do not start a ticket whose dependencies aren't merged.