Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ flowchart LR

- **Local-first ledger** — SQLite is the source of truth; no hosted telemetry or cloud model requirement is implemented.
- **Deterministic finance engine** — income, spending, savings rate, budgets, goals, recurring evidence, anomalies, trends, and scenarios are calculated in Python from integer minor units.
- **Statement import studio** — upload CSV or PDF statements in the browser, preview extracted rows,
- **Statement import studio** — upload CSV, HDFC India Delimited `.txt`, or PDF statements in the browser, preview extracted rows,
review warnings/errors, exclude rows, state the statement's sign convention, and commit explicitly.
Commit is blocked while any included row has a blocking error or the sign convention is unanswered.
- **PDF + OCR path** — digital PDFs use `pdfplumber`; scanned PDF pages can fall back to local Tesseract OCR when installed.
Expand Down Expand Up @@ -131,13 +131,13 @@ PFA is strict about money because small mistakes corrupt advice:
| Path | Supported now | Notes |
| --- | --- | --- |
| CLI | Local UTF-8 CSV files | `uv run pfa import <path.csv>`; `--dry-run` validates without persistence. |
| Browser/API preview | CSV and PDF uploads | `POST /imports/preview` stages a bounded local upload, extracts candidates, and deletes raw uploaded bytes after extraction. |
| Browser/API preview | CSV, HDFC Delimited `.txt`, and PDF uploads | `POST /imports/preview` stages a bounded local upload, extracts candidates, and deletes raw uploaded bytes after extraction. |
| Digital PDF | Yes, best-effort | Uses `pdfplumber` table/word extraction with source-page provenance. |
| Scanned PDF | Basic local OCR fallback | Requires Tesseract installed on `PATH`; OCR-derived rows carry review warnings, and low-confidence date/amount fields block commit. |

Upload limits default to 15 MiB, 100 PDF pages, 10,000 candidate rows, and a 24-hour TTL for uncommitted normalized batches. Committed batches keep metadata and transaction IDs, not raw statement bytes.

CSV imports accept common aliases for date, description, amount, account, and transaction ID. They support signed `amount` columns, debit/credit columns, comma/semicolon/tab delimiters, UTF-8 BOM, row-level errors, duplicate detection, and manual review for unresolved classifications.
CSV imports accept common aliases for date, description, amount, account, and transaction ID. They support signed `amount` columns, debit/credit columns, comma/semicolon/tab delimiters, UTF-8 BOM, row-level errors, duplicate detection, and manual review for unresolved classifications. HDFC India Delimited exports are content-detected from their exact seven-column header, require a confirmed INR Current/Savings account, and reconcile ordered closing balances before commit.

For unsigned credit-card-style exports, the preview API supports an explicit `amount_sign` patch (`as_written` or `debit_positive`) so PFA does not silently guess whether positive values are purchases or credits.

Expand Down
21 changes: 21 additions & 0 deletions alembic/versions/0007_adapter_currency_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""persist generic statement-adapter currency metadata"""

import sqlalchemy as sa
from alembic import op

revision = "0007_adapter_currency_metadata"
down_revision = "0006_transfer_events"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column("import_batches", sa.Column("suggested_currency", sa.String(length=3)))
op.add_column("import_batches", sa.Column("currency_evidence", sa.String(length=40)))
op.add_column("import_batches", sa.Column("compatible_account_types_json", sa.Text()))


def downgrade() -> None:
op.drop_column("import_batches", "compatible_account_types_json")
op.drop_column("import_batches", "currency_evidence")
op.drop_column("import_batches", "suggested_currency")
30 changes: 29 additions & 1 deletion src/pfa/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ class NewAccountRequest(BaseModel):
last4: str | None = Field(default=None, min_length=4, max_length=4, pattern=r"^\d{4}$")
opening_balance_minor: int = 0
opening_balance_as_of: date | None = None
opening_balance_confirmed: bool = False
currency_confirmed: bool = False


class AccountMetadataUpdateRequest(BaseModel):
institution: Literal["hdfc_bank"]


class CandidateIssueResponse(BaseModel):
Expand All @@ -113,6 +119,7 @@ class CandidateResponse(BaseModel):
normalized_description: str
amount_minor: int | None
direction: str | None
direction_explicit: bool
currency: str
account_hint: str | None
account_id: int | None
Expand Down Expand Up @@ -148,6 +155,9 @@ class ImportBatchResponse(BaseModel):
detection_reason_codes: list[str]
detected_institution: str | None
detected_account_hint: str | None
suggested_currency: str | None
currency_evidence: str | None
compatible_account_types: list[str]
reconciliation: dict[str, object] | None
semantic_totals: dict[str, int]
amount_sign: str | None
Expand All @@ -170,16 +180,21 @@ class ImportBatchPatchRequest(BaseModel):
account: str | None = None # deprecated label compatibility
destination_account_id: int | None = Field(default=None, gt=0)
new_account: NewAccountRequest | None = None
account_metadata_update: AccountMetadataUpdateRequest | None = None
excluded_candidate_ids: list[str] | None = None

@model_validator(mode="after")
def one_binding(self) -> ImportBatchPatchRequest:
if self.destination_account_id is not None and self.new_account is not None:
raise ValueError("choose destination_account_id or new_account, not both")
if self.account is not None and (
self.destination_account_id is not None or self.new_account is not None
self.destination_account_id is not None
or self.new_account is not None
or self.account_metadata_update is not None
):
raise ValueError("account is a legacy alias; use one stable binding")
if self.account_metadata_update is not None and self.destination_account_id is None:
raise ValueError("account_metadata_update requires destination_account_id")
return self

# Both are closed sets: an unrecognised value is a 422, not a silent no-op.
Expand Down Expand Up @@ -264,6 +279,7 @@ def _candidate_response(candidate: CandidateTransaction) -> CandidateResponse:
normalized_description=candidate.normalized_description,
amount_minor=candidate.amount_minor,
direction=candidate.direction,
direction_explicit=candidate.direction_explicit,
currency=candidate.currency,
account_hint=candidate.account_hint,
account_id=candidate.account_id,
Expand Down Expand Up @@ -309,6 +325,13 @@ def _batch_response(batch: ImportBatchModel) -> ImportBatchResponse:
),
detected_institution=batch.detected_institution,
detected_account_hint=batch.detected_account_hint,
suggested_currency=batch.suggested_currency,
currency_evidence=batch.currency_evidence,
compatible_account_types=(
json.loads(batch.compatible_account_types_json)
if batch.compatible_account_types_json
else []
),
reconciliation=(
json.loads(batch.reconciliation_json) if batch.reconciliation_json else None
),
Expand Down Expand Up @@ -482,6 +505,11 @@ def patch_import_batch(batch_id: str, request: ImportBatchPatchRequest) -> Impor
else None
),
excluded_candidate_ids=request.excluded_candidate_ids,
account_metadata_update=(
request.account_metadata_update.model_dump()
if request.account_metadata_update
else None
),
amount_mode=request.amount_mode,
amount_sign=request.amount_sign,
),
Expand Down
3 changes: 3 additions & 0 deletions src/pfa/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ class ImportBatchModel(Base):
detection_reason_codes_json: Mapped[str | None] = mapped_column(Text, nullable=True)
detected_institution: Mapped[str | None] = mapped_column(String(120), nullable=True)
detected_account_hint: Mapped[str | None] = mapped_column(String(40), nullable=True)
suggested_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
currency_evidence: Mapped[str | None] = mapped_column(String(40), nullable=True)
compatible_account_types_json: Mapped[str | None] = mapped_column(Text, nullable=True)
reconciliation_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# The sign convention the user declared for this source, kept so the preview can be
# rebuilt after a refresh and so a committed batch records how it read its amounts.
Expand Down
8 changes: 7 additions & 1 deletion src/pfa/db/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,17 @@ def create(
raise ValueError(f"unsupported account currency {currency!r}")
if last4 is not None and (len(last4) != 4 or not last4.isdigit()):
raise ValueError("last4 must contain exactly four digits")
institution_value = institution.strip() if institution else None
if institution_value and institution_value.casefold().replace(" ", "_") in {
"hdfc",
"hdfc_bank",
}:
institution_value = "hdfc_bank"
account = AccountModel(
name=name.strip(),
currency=currency,
account_type=account_type,
institution=institution.strip() if institution else None,
institution=institution_value,
last4=last4,
opening_balance_minor=opening_balance_minor,
opening_balance_as_of=opening_balance_as_of,
Expand Down
Loading