Papita Transactions Data Model
Welcome to the backbone of financial data integrity for the save-ma-money monorepo. The papita-txnsmodel package (papita-transactions-model) is the system of record: SQLModel schemas, Alembic migrations, repositories, services, and ingestion handlers. The sibling papita-txnsapi package will expose HTTP routes that call these services — business rules live here, not in FastAPI routers.
Entity-relationship diagram for schema papita_transactions: v3 core tables (users, accounts, categories, transaction templates, transactions, account extensions, financing) plus materialized balance views (account_balances, owner_*_balances). DDL authority: docs/design/ARCHITECTURE.md#part-ii--target-schema-v1v3-ppt-031-a2a4-32. Post-MVP additive tables (budgets, splits, recurrence, reconciliation): docs/design/ARCHITECTURE.md#part-iii--post-mvp-v4-extensions-ppt-031-track-a.
Overview
Personal and small-business finance rarely starts in one clean database. Data arrives as bank CSVs, card portal exports, broker statements, spreadsheets, and manual corrections — each with its own column names, sign conventions, and category vocabulary. Without a shared domain layer, every import script re-implements validation, tenancy, and balance logic differently, and an API built on top inherits those inconsistencies.
papita-txnsmodel solves that by treating finance as structured, tenant-scoped domain data in PostgreSQL (schema papita_transactions). Every path — bulk CSV ingest via handlers, direct service calls in notebooks, or future REST endpoints — flows through the same DTO validation, repository upserts, and service rules.
The problem space
- Data fragmentation — the same merchant appears as
AMZN MKTP,Amazon.com, or a custom spreadsheet label; categories and dates drift between sources - Silent import failures — weak validation lets malformed rows persist until month-end reconciliation
- Tenancy risk — household or multi-user finance requires hard boundaries; global lookup tables and shared deterministic IDs can leak or collide across users
- Schema complexity — the legacy v0 model used an
accounts_indexerhub with eight nullable foreign keys to subtype tables; routing logic lived in Python instead of the database - Spec drift — API documents described
balance,category_type, and/movements/*fields that did not exist on SQLModel tables
Our solution
- v3 consolidated schema (PPT-031) — accounts carry an
account_kinddiscriminator; at most one 1:1 extension row; categories replace v0types; transfers aretransaction_kind = TRANSFER(no separate movements table) - Strict type safety — Pydantic v2 DTOs at the access layer; SQLModel + PostgreSQL CHECK constraints at persistence
- PostgreSQL only — Docker Postgres locally (B0); Supabase pooler for hosted (B1). DuckDB is deprecated — see platform decision
- Conflict-aware ingestion — idempotent bulk loads via
PostgreSQLUpserterand tolerance-awareupsert_records - API-ready services (PPT-041) — account extension orchestration, transfer helpers, FR-12 report aggregations, materialized balance views, live-DB tenancy tests
Core philosophy
- Explicit over implicit — relationships, enums, and denormalizations are documented in design specs and migrations, not inferred at runtime
- Built-in traceability — soft delete via
activeanddeleted_atonBaseSQLModel; repositories default to soft delete - Single domain layer — services own invariants; API schemas map to DTOs without duplicating validators
- Layered architecture — Model → Access → Service → Handler; see
.strata/docs/ARCHITECTURE.mdfor the repo codemap
From v0 to v3
PPT-031 (#28) redesigned the model for Third Normal Form, API alignment, and PostgreSQL-only operation. The v3 baseline ships as Alembic revision a75354933e79 (see migration runbook).
| v0 pattern | v3 replacement | Why it changed |
|---|---|---|
accounts + accounts_indexer + 6 subtype tables |
accounts + optional 1:1 *_account_details |
Eliminate 8-FK sparse matrix (FR-03) |
types (ASSETS / LIABILITIES / TRANSACTIONS) |
categories with category_kind INCOME / EXPENSE |
API /categories/* vocabulary (FR-13) |
identified_transactions |
transaction_templates |
Clear template vs posted split (FR-05) |
| Single-sided transactions only | transaction_kind INCOME / EXPENSE / TRANSFER |
/movements/* maps to TRANSFER rows (NF-01) |
Phantom balance column on accounts |
account_balances materialized view |
Read model without denormalizing writes (FR-12) |
| DuckDB + PostgreSQL dual dialect | PostgreSQL only | Platform decision B0/B1 |
Design authority: docs/design/ARCHITECTURE.md#part-ii--target-schema-v1v3-ppt-031-a2a4-32 · as-is audit: docs/design/ARCHITECTURE.md#part-i--v0-data-model-audit-ppt-031-a1-30
Entity relationship (v3)
users
└── owner_id on hot tables (accounts, categories, transactions, transaction_templates, account_financing)
accounts (account_kind, ledger_side, currency, initial_value, current_value)
├── banking_account_details (CHECKING, SAVINGS, CASH)
├── trading_account_details (INVESTMENT_BROKERAGE)
├── real_estate_account_details (REAL_ESTATE)
├── credit_card_account_details (CREDIT_CARD)
└── loan_account_details (LOAN_MORTGAGE)
account_financing ── links asset accounts to loan accounts (financing_share)
categories (parent_id, category_kind) ── hierarchical income/expense taxonomy
transaction_templates ── planned / recurring setups
transactions ── posted ledger (monthly partitions on transaction_ts)
INCOME | EXPENSE | TRANSFER + optional template_id
account_balances, owner_yearly_balances, owner_*_balances ── materialized views
Account kinds
account_kind |
ledger_side |
Extension table | Typical use |
|---|---|---|---|
CHECKING |
ASSET | banking_account_details |
Day-to-day bank account |
SAVINGS |
ASSET | banking_account_details |
Savings account |
CASH |
ASSET | banking_account_details |
Petty cash |
INVESTMENT_BROKERAGE |
ASSET | trading_account_details |
Brokerage / trading |
REAL_ESTATE |
ASSET | real_estate_account_details |
Property |
CREDIT_CARD |
LIABILITY | credit_card_account_details |
Revolving credit |
LOAN_MORTGAGE |
LIABILITY | loan_account_details |
Mortgage / term loan |
OTHER_ASSET |
ASSET | — | Generic asset shell |
OTHER_LIABILITY |
LIABILITY | — | Generic liability shell |
Extension routing for create/update: services/account_extension_routing.py → AccountsService.create_account / update_account.
Transaction kinds
transaction_kind |
Account FKs | API surface |
|---|---|---|
INCOME |
to_account_id required |
/transactions POST |
EXPENSE |
from_account_id required |
/transactions POST |
TRANSFER |
both from_account_id and to_account_id |
/transactions and /movements/* alias |
Templates (transaction_templates) hold planned name, amount, and schedule; posted rows in transactions may reference template_id when matched from recurrence.
Tenancy and security
Strategy B (denormalized owner_id) on hot tables — fast tenant-filtered scans without joining through accounts on every ledger query. Extension detail tables derive tenancy via account_id → accounts.owner_id and do not carry their own owner_id.
| Rule | Enforcement |
|---|---|
All owned writes require owner=UsersDTO |
BaseService._ensure_owner; OwnedTableRepository |
| Cross-tenant ID access | Repository filters return empty / upsert denied |
Global categories (owner_id IS NULL) |
Readable by all tenants; writes blocked in CategoriesService |
| Category identity (FR-15) | Composite unique (owner_id, name, category_kind) with NULLS NOT DISTINCT |
| Auth (Track E) | UsersService.register / verify_credentials; see auth contract |
Live-DB tenancy tests: tests/tests_papita_txnsmodel/integration/test_tenancy_live_db.py (require DATABASE_URL PostgreSQL).
Row-level security (Supabase B3) is deferred — documented in the Supabase brief, not implemented in v3.
Architectural layers
flowchart TB
subgraph ingest [Ingestion]
H[handlers/]
end
subgraph domain [papita_txnsmodel]
SV[services/]
RP[access/*/repository.py]
DTO[access/*/dto.py]
SM[model/]
end
subgraph read [Read models]
MV[views/balance_reports/ MVs]
RPT[ReportService]
end
DB[(PostgreSQL papita_transactions)]
H --> SV
SV --> RP --> DTO --> SM --> DB
SV --> MV
RPT --> SV
MV --> DB
1. Model layer (src/papita_txnsmodel/model/)
SQLModel tables on BaseSQLModel: active, deleted_at, created_at, updated_at, schema papita_transactions.
- Enums in
model/enums.py—AccountKind,LedgerSide,TransactionKind,TransactionStatus,CategoryKind - Partitioning —
transactionsuses monthly RANGE partitions (config/transaction_partitions.py; ops scriptbin/transaction_partitions.sh) - Intentional denormalizations — e.g.
transactions.owner_idfor hot-path tenant scans; documented in schema §6
2. Access layer (src/papita_txnsmodel/access/)
| Component | Purpose |
|---|---|
TableDTO / OwnedTableDTO |
Pydantic validation; from_dao() / to_dao() |
BaseRepository |
CRUD, soft delete, pandas get_records, upsert |
OwnedTableRepository |
Tenant-scoped reads/writes with owner context |
access/balance_reports/ |
DTOs for YAML-registered balance report MVs |
Repositories use @SQLDatabaseConnector.connect — never open sessions manually in application code.
3. Service layer (src/papita_txnsmodel/services/)
The primary API for application code. Instantiate with optional shared connector; pass owner=UsersDTO for tenant scope.
Core CRUD services
| Service | Domain |
|---|---|
UsersService |
Users; register, verify_credentials, get_owner |
AccountsService |
Accounts + extension orchestration |
CategoriesService |
Income/expense categories |
TransactionTemplatesService |
Recurring/planned templates |
TransactionsService |
Posted ledger + transfer helpers |
AccountFinancingService |
Asset–loan financing links |
*AccountDetailsService |
Per-kind 1:1 extension CRUD |
API-readiness (PPT-041)
| Method / module | Purpose |
|---|---|
AccountsService.create_account |
Atomic account + extension upsert by account_kind |
AccountsService.get_with_extension |
Account shell + detail row for API GET |
AccountsService.get_balance |
Balance from account_balances MV |
TransactionsService.list_transfers |
Filter transaction_kind = TRANSFER |
TransactionsService.create_transfer |
Two-legged TRANSFER with validation |
TransactionsService.complete_transfer / cancel |
Status transitions |
ReportService.spending / cash_flow / trends / export |
FR-12 MVP report aggregations |
refresh_balance_materialized_views |
Called after transaction create/delete/upsert |
Endpoint mapping: docs/design/ARCHITECTURE.md#part-iv--api--model-mapping-ppt-031-c-33 (32 MVP routes → these services).
4. Handler layer (src/papita_txnsmodel/handlers/)
Bulk load/dump pipelines for ETL and file ingest. Handlers resolve dependencies (accounts, categories) and delegate to services.
| Handler | Labels (examples) |
|---|---|
UsersTableHandler |
users |
AccountsTableHandler |
accounts |
CategoriesTableHandler |
categories |
TransactionTemplatesTableHandler |
transaction_templates |
TransactionsHandler |
transactions |
*_account_details handlers |
per extension table |
AccountFinancingTableHandler |
account_financing |
BalanceReportsHandler |
balance_reports, reports (read-only export) |
Register via HandlerFactory.load("papita_txnsmodel.handlers"). Legacy v0 labels (types, identified_transactions) warn through handlers/compat.py.
Balance reports and materialized views
Balances are derived, not stored on accounts rows:
| View | Purpose |
|---|---|
account_balances |
Per-account ledger balance |
owner_yearly_balances |
Combined yearly totals by currency |
owner_monthly_balances / owner_quarterly_balances / owner_biannual_balances |
Period rollups |
- SQL definitions:
src/papita_txnsmodel/views/balance_reports/ - Registry:
config/data/balance_report_filters.yaml(fivereport_ids) - Refresh: event-driven on
TransactionsService.create,delete, andupsert_recordsviarefresh_balance_materialized_views - Reads:
BalanceReportsService/BalanceReportsHandlerfor registered report exports
Database integration
SQLDatabaseConnector
from papita_txnsmodel.database.connector import SQLDatabaseConnector
# Always pass a dict when using a URL string (avoids legacy DuckDB path detection)
SQLDatabaseConnector.establish(
connection={"url": "postgresql+psycopg2://user:pass@localhost:5432/papita_transactions"}
)
- Singleton engine;
@SQLDatabaseConnector.connectmanages session lifecycle and rollback on errors - PostgreSQL only for new work; DuckDB upsert/connector paths emit deprecation warnings
Upsert engine
Bulk idempotent loads use PostgreSQLUpserter with OnUpsertConflictDo (UPDATE, NOTHING, RAISE). Services expose missing_upsertions_tol — exceed the threshold during bulk upsert and a RuntimeError raises to prevent silent partial failure.
Usage examples
Register a user and resolve tenant context
from papita_txnsmodel.services.users import UsersService
users = UsersService()
users.ensure_password_manager() # Argon2 bootstrap (required before hash)
registered = users.register(username="demo_user", email="demo@example.com", password="SecurePass1!")
owner = users.get_owner(owner_id=registered.id)
Create account with banking extension
from papita_txnsmodel.services.accounts import AccountsService
accounts = AccountsService(owner=owner)
account = accounts.create_account(
obj={
"name": "Checking",
"account_kind": "CHECKING",
"currency": "USD",
"initial_value": 1000.0,
},
extension={"entity": "Example Bank", "account_number": "****1234"},
)
balance = accounts.get_balance(account.id)
shell, detail = accounts.get_with_extension(account.id)
Record a transfer between accounts
from papita_txnsmodel.services.transactions import TransactionsService
txns = TransactionsService(owner=owner)
transfer = txns.create_transfer(
obj={
"value": 250.0,
"currency": "USD",
"from_account_id": checking_id,
"to_account_id": savings_id,
"description": "Monthly savings",
}
)
Spending report for a date window
from datetime import datetime, timezone
from papita_txnsmodel.services.reports import ReportService
reports = ReportService()
summary = reports.spending(
owner=owner,
start_date=datetime(2026, 1, 1, tzinfo=timezone.utc),
end_date=datetime(2026, 6, 30, tzinfo=timezone.utc),
)
reports.close()
Bulk CSV upsert
import pandas as pd
from papita_txnsmodel.services.transactions import TransactionsService
txns = TransactionsService(owner=owner)
txns.upsert_records(
df=pd.read_csv("bank_export.csv"),
on_conflict_do="UPDATE",
missing_upsertions_tol=0.01,
)
Install
PyPI name: papita-transactions-model · Import: papita_txnsmodel
| Mode | Command |
|---|---|
| Published (when released) | pip install papita-transactions-model |
| Poetry | poetry add papita-transactions-model |
| Monorepo path (this repo) | Root pyproject.toml path dep — poetry install from repo root |
| TestPyPI | pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ papita-transactions-model |
Alembic migrations remain in the git checkout (modules/model/alembic/); the published wheel is the importable library. Run migrations via bin/alembic.sh from this repository (or Docker).
Build / version / publish (PPT-024)
Automatic versioning uses python-semantic-release on main (release-model.yml):
- Updates
modules/model/CHANGELOG.mdonly (never rootCHANGELOG.md— that file is owned byauto-updates.yml) - Bumps
pyproject.tomland tagsmodel-v{version} - PyPI upload:
publish-model.ymlon those tags
Use Conventional Commits with a model scope so bumps are detected:
feat(model): add report window helper
fix(model): correct soft-delete filter
# Manual bump (escape hatch; prefer semantic-release on main)
./bin/version.sh --mod model --version 0.1.14 --skip-install
# Build sdist + wheel into dist/ (model only)
./bin/package.sh --mod model
# or: make package-model
# Local smoke
python -m venv /tmp/model-smoke && /tmp/model-smoke/bin/pip install dist/papita_transactions_model-*.whl
/tmp/model-smoke/bin/python -c "import papita_txnsmodel; print(papita_txnsmodel.__version__)"
| Trigger | Target |
|---|---|
Merge conventional *(model): / model-path commits to main |
release-model.yml → tag model-v* + model CHANGELOG |
Tag model-vX.Y.Z |
PyPI via publish-model.yml |
Actions → Publish model package → target=testpypi |
TestPyPI (OIDC) |
Configure Trusted Publishers for this repo, publish-model.yml, and Environments testpypi / pypi. Do not commit API tokens.
Package layout
modules/model/
├── alembic/ # Migrations (alembic.ini at module root)
├── src/papita_txnsmodel/
│ ├── model/ # SQLModel tables
│ ├── access/ # DTOs + repositories per domain
│ ├── services/ # Business logic
│ ├── handlers/ # Load/dump pipelines
│ ├── database/ # Connector, upsert helpers
│ ├── views/ # Balance report MV SQL + index registry
│ └── config/ # Partition + MV + balance report specs
└── tests/
├── tests_papita_txnsmodel/ # Unit tests (mocked DB)
└── integration/ # Live PostgreSQL tenancy tests
Database migrations
Alembic targets schema papita_transactions on PostgreSQL. v3 seed baseline: a75354933e79.
# From repository root — Docker Postgres
/bin/bash ./bin/alembic.sh upgrade --docker-local --docker-rm
# Explicit URL (local or Supabase session/direct — not transaction pooler)
/bin/bash ./bin/alembic.sh upgrade --url "postgresql+psycopg2://user:pass@host:5432/db"
/bin/bash ./bin/alembic.sh downgrade --url "..." # defaults to head^1
Environment templates: .env.example · Docker Compose: docker/database/docker-compose.yml.
CI migration gate
.github/workflows/migration-check.yml: upgrade head → downgrade -1 → upgrade head → alembic check on PostgreSQL 15.
Testing
351 tests — layered coverage across access, database, services, handlers, and views.
| Suite | Location | Requirements |
|---|---|---|
| Unit (default) | tests/tests_papita_txnsmodel/ |
Mocked DB; no DATABASE_URL needed |
| Integration | tests/.../integration/ |
DATABASE_URL must be PostgreSQL; 4 tests skipped otherwise |
| PPT-041 services | tests/.../services/test_ppt041_services.py |
Account orchestration, transfers, reports, category guards |
# Standard gate (from repo root)
poetry run pytest modules/model/tests
/bin/bash ./bin/test.sh
# Live-DB tenancy suite
DATABASE_URL="postgresql+psycopg2://user:pass@localhost:5435/papita" \
poetry run pytest modules/model/tests/tests_papita_txnsmodel/integration/
Related documentation
| Document | Description |
|---|---|
docs/design/README.md |
PPT-031 design program index |
docs/design/ARCHITECTURE.md#part-ii--target-schema-v1v3-ppt-031-a2a4-32 |
v3 frozen schema, constraints, G1 checklist |
docs/design/ARCHITECTURE.md#part-i--v0-data-model-audit-ppt-031-a1-30 |
v0 inventory, 3NF analysis, NF register |
docs/design/ARCHITECTURE.md#part-iv--api--model-mapping-ppt-031-c-33 |
Endpoint → Service → DTO (API epic #42) |
docs/design/ARCHITECTURE.md#part-vi--auth-contract-ppt-031-track-e |
Local JWT + UsersService flows |
docs/design/ARCHITECTURE.md#part-vii--migration-runbook-ppt-031-d-34 |
B0/B1 validation, rollback, FR-14 |
docs/issues/README.md#part-ii--ppt-031-c-supabase--fastapi-decision-31 |
B0/B1 platform; B2/B3 deferred |
docs/design/ARCHITECTURE.md#part-iii--post-mvp-v4-extensions-ppt-031-track-a |
Budgets, splits, recurrence (post-MVP) |
modules/api/README.md |
FastAPI scaffold and target REST contract |
README.md |
Monorepo overview and quick start |
CHANGELOG.md |
Issue and release tracker |
Package version: pyproject.toml (papita-transactions-model).
Metadata
Release files for papita-transactions-model 1.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| papita_transactions_model-1.0.1.tar.gz | 117.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| papita_transactions_model-1.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 278.2 kB
Release files / papita_transactions_model-1.0.1.tar.gz
| Download URL | papita_transactions_model-1.0.1.tar.gz |
|---|---|
| Size | 117.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e215f96dbc69fa69ef89587c6cffed25371c6d5c2d4b2dbda7a0846f3ecb091d
|
|
BLAKE2b-256 checksum How to use checksums |
a9925f8fec0d76923997ce1d99c1f07a36a576468c1bf5fb1475b9aba4e9c593
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 27, 2026.
Transparency logRelease files / papita_transactions_model-1.0.1-py3-none-any.whl
| Download URL | papita_transactions_model-1.0.1-py3-none-any.whl |
|---|---|
| Size | 160.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3a1fa6e4b4b7e767d590936f98ddc6b41fb20012c96607fb08edd36c833b578c
|
|
BLAKE2b-256 checksum How to use checksums |
4c85ab5f0ad22d8d3c63b0a2f128806b36351e88ee1ed82a55ed7907ede95e33
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 27, 2026.
Transparency log