Skip to content

FIN-20 · Plaid /transactions/sync loop (cursor atomicity, pending→posted, removed) #797

Description

@lbedner

Milestone: Finance M4 · Depends on: FIN-19.

Goal

/transactions/sync — THE ingestion loop. Cursor-correct, mutation-correct (added/modified/removed), pending→posted-correct. Most catastrophic-bug surface in the service; the invariants below are non-negotiable.

The four invariants (schema doc §2.2-2.3; violating any = data corruption)

  1. Cursor atomicity: the whole has_more loop's writes AND the finance_connection.sync_cursor advance commit in ONE DB transaction. Mid-loop failure → next run resumes from the last COMMITTED cursor. Never persist a cursor for a batch you didn't fully apply.
  2. added[] → dialect UPSERT on uq_finance_txn_external (account_id, source='plaid', external_id=transaction_id).
  3. modified[] → same upsert target, update-in-place (banks rewrite history; re-derive display fields from the new raw_payload). Do NOT clobber category_id when is_user_categorized=true (category_source precedence: provider < rule < user).
  4. removed[] → soft-delete: is_removed=true, removed_at=now, deleted_at=now, status='removed'. NEVER hard-delete (tombstone must survive re-sync).

Pending→posted collapse

Posted row arrives carrying pending_transaction_id (Plaid's id of the earlier pending row) → store in pending_provider_id; look up our pending row by (account_id, external_id=that id); set posted row's self-FK pending_transaction_id → its internal id; soft-delete the pending row. Phantom pre-auths (pending arrives in removed[], never posts) → just the soft-delete; no phantom spend.

Field mapping (adapter boundary; store raw JSON always)

amount: Plaid positive = money OUT → negate into house convention (outflow negative); raw_amount + raw_sign_convention='plaid'. date→date, authorized_date, datetime, name→original_description, merchant_name, merchant_entity_id, payment_channel, personal_finance_category.{primary,detailed,confidence_level}→pfc_* columns (TEXT), location/counterparties→JSON columns, full object→raw_payload. Category mapping: pfc_detailedfinance_category_alias lookup → category_id (category_source='provider').

Endpoint + service

POST /finance/connections/{id}/sync (manual trigger; auth: owner only) → runs the loop, returns {added, modified, removed, pages}. Each batch = a finance_import_batch row (source_type='plaid_sync', cursors before/after) for auditability.

Validation (sandbox)

Sandbox item from FIN-19 → first sync pulls fixture transactions. Then /sandbox/item/fire_webhook or /transactions/refresh to force changes; Plaid sandbox also simulates pending→posted transitions. Assert: second sync with no changes → 0/0/0 and UNCHANGED cursor semantics (idempotent replay if same page re-served).

Acceptance criteria

  • First sandbox sync ingests all fixture txns, signs house-normalized (a sandbox debit is NEGATIVE in our DB), PFC captured, raw_payload present.
  • Immediate re-sync → zero new rows.
  • Forced failure test: monkeypatch the page-2 apply to raise → cursor still at page-0 value; re-run completes cleanly with no dupes (THE atomicity test).
  • Pending→posted simulated → one visible row, linked, pending row tombstoned.
  • removed[] handling → tombstone, excluded from /finance/transactions, survives next sync.
  • User-categorized txn keeps its category through a modified[] update.

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):

  1. Copier templateaegis/templates/copier-aegis-project/{{ project_slug }}/…. Gating = literal {% if include_finance %} blocks in .jinja files + questions in copier.yml (repo root).
  2. Python registryaegis/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    financeFinance aggregator service (include_finance)

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions