diff --git a/backend/app/config.py b/backend/app/config.py index 1270d0e..3b168f9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,5 +1,5 @@ from pydantic import model_validator -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): @@ -39,8 +39,7 @@ class Settings(BaseSettings): raise ValueError("ADMIN_PASSWORD must be set and must not be 'admin'") return self - class Config: - env_file = ".env" + model_config = SettingsConfigDict(env_file=".env") settings = Settings() diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1 @@ +pytest diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..35315b1 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,48 @@ +"""Shared fixtures. Env vars are set before any app import so config.Settings() +fail-fast validation passes with deterministic test values regardless of the +developer's .env.""" + +import os + +os.environ.setdefault( + "SECRET_KEY", "test-secret-key-0123456789abcdef0123456789abcdef" +) +os.environ.setdefault("ADMIN_USERNAME", "testadmin") +os.environ.setdefault("ADMIN_PASSWORD", "test-password") +os.environ.setdefault("DATABASE_URL", "sqlite://") + +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +import app.models.models # noqa: F401 — register all tables on the metadata + + +@pytest.fixture() +def engine(): + eng = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(eng) + yield eng + eng.dispose() + + +@pytest.fixture() +def session(engine): + with Session(engine) as s: + yield s + + +@pytest.fixture(autouse=True) +def fixed_exchange_rates(monkeypatch): + """Never hit the network: CRC passthrough, USD pinned at 500.""" + from app.services import exchange_rate + + monkeypatch.setattr( + exchange_rate, + "get_crc_multipliers", + lambda session: {"CRC": 1.0, "USD": 500.0}, + ) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..3b53da4 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,154 @@ +"""JWT lifecycle, API-token validation, constant-time creds, login rate limit.""" + +from datetime import timedelta + +import pytest +from fastapi import HTTPException, Request +from jose import jwt + +from app import auth as auth_mod +from app.auth import ( + ALGORITHM, + LOGIN_RATE_LIMIT, + _login_attempts, + _validate_token, + check_login_rate_limit, + create_access_token, + hash_token, + verify_admin_credentials, +) +from app.config import settings +from app.models.models import APIToken +from app.timeutil import utcnow + + +def make_request(ip: str) -> Request: + return Request( + { + "type": "http", + "headers": [(b"x-forwarded-for", ip.encode())], + "client": ("127.0.0.1", 1234), + } + ) + + +class TestJWT: + def test_roundtrip_and_seven_day_expiry(self): + token = create_access_token("charlie") + assert _validate_token(token) == "charlie" + + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[ALGORITHM] + ) + # exp is a true UTC epoch; a naive datetime's .timestamp() would be + # interpreted as local time, so compare against an aware now. + from datetime import datetime, timezone + + lifetime = payload["exp"] - datetime.now(timezone.utc).timestamp() + assert lifetime == pytest.approx(7 * 24 * 3600, abs=60) + + def test_expired_token_rejected(self, engine, monkeypatch): + # The API-token fallback path opens a DB session; point it at the + # test engine so the lookup runs (and finds nothing). + monkeypatch.setattr("app.db.engine", engine) + expired = jwt.encode( + {"sub": "charlie", "exp": utcnow() - timedelta(minutes=1)}, + settings.SECRET_KEY, + algorithm=ALGORITHM, + ) + with pytest.raises(HTTPException) as exc: + _validate_token(expired) + assert exc.value.status_code == 401 + + def test_garbage_token_rejected(self, engine, monkeypatch): + monkeypatch.setattr("app.db.engine", engine) + with pytest.raises(HTTPException): + _validate_token("not-a-jwt") + + +class TestAPIToken: + def test_active_api_token_accepted(self, engine, session, monkeypatch): + monkeypatch.setattr("app.db.engine", engine) + session.add( + APIToken(name="n8n", token_hash=hash_token("tok-123"), is_active=True) + ) + session.commit() + assert _validate_token("tok-123") == "api:n8n" + + def test_expired_api_token_rejected(self, engine, session, monkeypatch): + monkeypatch.setattr("app.db.engine", engine) + session.add( + APIToken( + name="old", + token_hash=hash_token("tok-old"), + is_active=True, + expires_at=utcnow() - timedelta(days=1), + ) + ) + session.commit() + with pytest.raises(HTTPException) as exc: + _validate_token("tok-old") + assert exc.value.status_code == 401 + + def test_revoked_api_token_rejected(self, engine, session, monkeypatch): + monkeypatch.setattr("app.db.engine", engine) + session.add( + APIToken( + name="revoked", + token_hash=hash_token("tok-rev"), + is_active=False, + ) + ) + session.commit() + with pytest.raises(HTTPException): + _validate_token("tok-rev") + + +class TestCredentials: + def test_correct_credentials_pass(self): + verify_admin_credentials( + settings.ADMIN_USERNAME, settings.ADMIN_PASSWORD + ) # no exception + + @pytest.mark.parametrize( + "user,pw", + [ + ("wrong", "test-password"), + ("testadmin", "wrong"), + ("", ""), + ], + ) + def test_wrong_credentials_rejected(self, user, pw): + with pytest.raises(HTTPException) as exc: + verify_admin_credentials(user, pw) + assert exc.value.status_code == 401 + + +class TestLoginRateLimit: + def setup_method(self): + _login_attempts.clear() + + def test_limit_allows_then_blocks(self): + req = make_request("203.0.113.1") + for _ in range(LOGIN_RATE_LIMIT): + check_login_rate_limit(req) + with pytest.raises(HTTPException) as exc: + check_login_rate_limit(req) + assert exc.value.status_code == 429 + assert "Retry-After" in exc.value.headers + + def test_limit_is_per_ip(self): + for _ in range(LOGIN_RATE_LIMIT): + check_login_rate_limit(make_request("203.0.113.2")) + # A different IP is unaffected. + check_login_rate_limit(make_request("203.0.113.3")) + + def test_window_slides(self): + req = make_request("203.0.113.4") + for _ in range(LOGIN_RATE_LIMIT): + check_login_rate_limit(req) + # Age every recorded attempt past the window; the next attempt passes. + attempts = _login_attempts["203.0.113.4"] + for i in range(len(attempts)): + attempts[i] -= auth_mod.LOGIN_RATE_WINDOW_SECONDS + 1 + check_login_rate_limit(req) # no exception diff --git a/backend/tests/test_budget_projection.py b/backend/tests/test_budget_projection.py new file mode 100644 index 0000000..b3fcbb7 --- /dev/null +++ b/backend/tests/test_budget_projection.py @@ -0,0 +1,196 @@ +"""Projection engine: recurring-item schedules, no-double-count, cumulative +balances with overrides and the 2026-03 fresh start. Also serves as the +float-behavior baseline ahead of the Phase 2 Decimal migration (plan 1.5).""" + +from datetime import datetime + +import pytest +from sqlmodel import Session + +from app.models.models import ( + BalanceOverride, + Category, + RecurringFrequency, + RecurringItem, + RecurringItemType, + Transaction, + TransactionSource, + TransactionType, +) +from app.services.budget_projection import ( + compute_monthly_projection, + compute_yearly_projection_with_cumulative, + get_effective_amount, +) + + +def make_item(**kw) -> RecurringItem: + defaults = dict( + name="item", + amount=1000.0, + item_type=RecurringItemType.EXPENSE, + frequency=RecurringFrequency.MONTHLY, + ) + defaults.update(kw) + return RecurringItem(**defaults) + + +class TestEffectiveAmount: + def test_monthly_base_and_override(self): + item = make_item(amount=100.0, override_amounts={"4": 250.0}) + assert get_effective_amount(item, 3, 2026) == 100.0 + assert get_effective_amount(item, 4, 2026) == 250.0 + + def test_weekly_counts_weekday_occurrences(self): + # day_of_month stores weekday, 0=Monday. March 2026 has 5 Mondays + # (2, 9, 16, 23, 30); April 2026 has 4 (6, 13, 20, 27). + item = make_item( + amount=100.0, frequency=RecurringFrequency.WEEKLY, day_of_month=0 + ) + assert get_effective_amount(item, 3, 2026) == 500.0 + assert get_effective_amount(item, 4, 2026) == 400.0 + + def test_quarterly_active_in_quarter_end_months_only(self): + item = make_item(frequency=RecurringFrequency.QUARTERLY, amount=900.0) + for month in (3, 6, 9, 12): + assert get_effective_amount(item, month, 2026) == 900.0 + for month in (1, 2, 4, 5, 7, 8, 10, 11): + assert get_effective_amount(item, month, 2026) is None + + def test_biannual_base_and_six_months_later(self): + item = make_item( + frequency=RecurringFrequency.BIANNUAL, month_of_year=2, amount=70.0 + ) + assert get_effective_amount(item, 2, 2026) == 70.0 + assert get_effective_amount(item, 8, 2026) == 70.0 + assert get_effective_amount(item, 5, 2026) is None + + late = make_item( + frequency=RecurringFrequency.BIANNUAL, month_of_year=9, amount=70.0 + ) + assert get_effective_amount(late, 9, 2026) == 70.0 + assert get_effective_amount(late, 3, 2026) == 70.0 + + def test_yearly_defaults_to_december(self): + item = make_item(frequency=RecurringFrequency.YEARLY, amount=50.0) + assert get_effective_amount(item, 12, 2026) == 50.0 + assert get_effective_amount(item, 6, 2026) is None + july = make_item( + frequency=RecurringFrequency.YEARLY, month_of_year=7, amount=50.0 + ) + assert get_effective_amount(july, 7, 2026) == 50.0 + + +class TestMonthlyProjection: + def _seed(self, session: Session): + groceries = Category(name="Groceries") + other = Category(name="Other") + session.add(groceries) + session.add(other) + session.commit() + session.refresh(groceries) + session.refresh(other) + + session.add( + RecurringItem( + name="Salary", + amount=500_000.0, + item_type=RecurringItemType.INCOME, + frequency=RecurringFrequency.MONTHLY, + ) + ) + session.add( + RecurringItem( + name="Groceries budget", + amount=100_000.0, + item_type=RecurringItemType.EXPENSE, + frequency=RecurringFrequency.MONTHLY, + category_id=groceries.id, + ) + ) + session.commit() + return groceries, other + + def _cc(self, date, amount, category_id=None): + return Transaction( + amount=amount, + merchant="X", + date=date, + transaction_type=TransactionType.COMPRA, + source=TransactionSource.CREDIT_CARD, + category_id=category_id, + ) + + def test_actuals_replace_projection_for_covered_category(self, session): + groceries, _ = self._seed(session) + # April budget ⇒ cycle [Mar 18, Apr 18) + session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id)) + session.commit() + + data = compute_monthly_projection(session, 2026, 4) + grocery_line = next( + i for i in data["expense_items"] if i["name"] == "Groceries budget" + ) + assert grocery_line["used_actual"] is True + assert grocery_line["amount"] == pytest.approx(80_000.0) + assert grocery_line["projected_amount"] == pytest.approx(100_000.0) + assert data["projected_fixed_expenses"] == pytest.approx(80_000.0) + + def test_projection_used_when_no_actuals(self, session): + self._seed(session) + data = compute_monthly_projection(session, 2026, 4) + assert data["projected_fixed_expenses"] == pytest.approx(100_000.0) + assert data["projected_income"] == pytest.approx(500_000.0) + assert data["net_balance"] == pytest.approx(400_000.0) + + def test_uncovered_and_uncategorized_actuals_add_up(self, session): + groceries, other = self._seed(session) + session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id)) + session.add(self._cc(datetime(2026, 4, 2), 5_000.0, other.id)) + session.add(self._cc(datetime(2026, 4, 3), 2_000.0, None)) + session.commit() + + data = compute_monthly_projection(session, 2026, 4) + assert data["uncovered_actual"] == pytest.approx(7_000.0) + assert data["gran_total_egresos"] == pytest.approx(87_000.0) + assert data["net_balance"] == pytest.approx(500_000.0 - 87_000.0) + + +class TestYearlyCumulative: + def _income_only(self, session: Session, amount=100.0): + session.add( + RecurringItem( + name="Income", + amount=amount, + item_type=RecurringItemType.INCOME, + frequency=RecurringFrequency.MONTHLY, + ) + ) + session.commit() + + def test_fresh_start_zeroes_months_before_march_2026(self, session): + self._income_only(session) + months = compute_yearly_projection_with_cumulative(session, 2026) + assert months[0]["cumulative_balance"] == 0.0 # January + assert months[1]["cumulative_balance"] == 0.0 # February + assert months[2]["cumulative_balance"] == pytest.approx(100.0) # March + assert months[11]["cumulative_balance"] == pytest.approx(1000.0) # Dec + + def test_override_resets_cumulative_chain(self, session): + self._income_only(session) + session.add(BalanceOverride(year=2026, month=4, override_balance=500.0)) + session.commit() + + months = compute_yearly_projection_with_cumulative(session, 2026) + april, may = months[3], months[4] + assert april["balance_overridden"] is True + assert april["cumulative_balance"] == pytest.approx(500.0) + assert may["carryover_balance"] == pytest.approx(500.0) + assert may["cumulative_balance"] == pytest.approx(600.0) + + def test_next_year_carries_december_forward(self, session): + self._income_only(session) + months_2027 = compute_yearly_projection_with_cumulative(session, 2027) + # 2026 accrues 10 months (Mar–Dec) of 100 → Jan 2027 carries 1000. + assert months_2027[0]["carryover_balance"] == pytest.approx(1000.0) + assert months_2027[0]["cumulative_balance"] == pytest.approx(1100.0) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..daca865 --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,44 @@ +"""Fail-fast settings validation (SEC-01 / BE-15).""" + +import pytest +from pydantic import ValidationError + +from app.config import Settings + +GOOD = { + "SECRET_KEY": "f" * 64, + "ADMIN_USERNAME": "charlie", + "ADMIN_PASSWORD": "a-real-password", +} + + +def make(**overrides) -> Settings: + return Settings(_env_file=None, **{**GOOD, **overrides}) + + +def test_valid_settings_boot(): + s = make() + assert s.ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24 * 7 + assert s.COOKIE_SECURE is True + assert s.cors_origins_list[0] == "https://wealth.cescalante.dev" + + +@pytest.mark.parametrize( + "overrides", + [ + {"SECRET_KEY": "change-me-in-production"}, + {"SECRET_KEY": "short"}, + {"SECRET_KEY": ""}, + {"ADMIN_PASSWORD": "admin"}, + {"ADMIN_PASSWORD": ""}, + {"ADMIN_USERNAME": ""}, + ], +) +def test_weak_or_missing_secrets_rejected(overrides): + with pytest.raises(ValidationError): + make(**overrides) + + +def test_cors_origins_parse_and_strip(): + s = make(CORS_ORIGINS=" https://a.example , http://b.example ,") + assert s.cors_origins_list == ["https://a.example", "http://b.example"] diff --git a/backend/tests/test_cycle_math.py b/backend/tests/test_cycle_math.py new file mode 100644 index 0000000..2303f07 --- /dev/null +++ b/backend/tests/test_cycle_math.py @@ -0,0 +1,202 @@ +"""Characterization tests for billing-cycle math (review finding BE-06). + +VERDICT: BE-06 is a false positive. The system is internally consistent: + +- `get_cycle_range(Y, M)` returns the cycle that STARTS on the 18th of M: + [M/18, (M+1)/18). +- Budget month M covers the credit-card cycle that ENDS on the 18th of M, + i.e. `get_previous_cycle(M)` then `get_cycle_range(M-1)` = [(M-1)/18, M/18) — + exactly what the comment in compute_actuals_by_source says. +- The frontend (Budget.tsx) follows the same convention: viewing month M it + queries cycle_month = M-1. + +These tests pin that convention so it can never silently shift. +""" + +from datetime import datetime + +import pytest +from sqlmodel import Session + +from app.models.models import ( + Currency, + Transaction, + TransactionSource, + TransactionType, +) +from app.services.budget_projection import ( + compute_actuals_by_source, + get_cycle_range, + get_month_range, + get_previous_cycle, +) + + +def cc_tx( + date: datetime, + amount: float = 1000.0, + *, + tx_type: TransactionType = TransactionType.COMPRA, + source: TransactionSource = TransactionSource.CREDIT_CARD, + currency: Currency = Currency.CRC, + deferred: bool = False, +) -> Transaction: + return Transaction( + amount=amount, + currency=currency, + merchant="TEST", + date=date, + transaction_type=tx_type, + source=source, + deferred_to_next_cycle=deferred, + ) + + +def cc_net(session: Session, year: int, month: int) -> float: + return compute_actuals_by_source(session, year, month)["CREDIT_CARD"]["net"] + + +class TestRangeHelpers: + def test_cycle_starts_on_the_18th_of_its_label_month(self): + assert get_cycle_range(2026, 3) == ( + datetime(2026, 3, 18), + datetime(2026, 4, 18), + ) + + def test_december_cycle_wraps_into_next_year(self): + assert get_cycle_range(2026, 12) == ( + datetime(2026, 12, 18), + datetime(2027, 1, 18), + ) + + def test_month_range_normal_and_december(self): + assert get_month_range(2026, 4) == ( + datetime(2026, 4, 1), + datetime(2026, 5, 1), + ) + assert get_month_range(2026, 12) == ( + datetime(2026, 12, 1), + datetime(2027, 1, 1), + ) + + def test_previous_cycle_january_wraps_to_prior_december(self): + assert get_previous_cycle(2026, 1) == (2025, 12) + assert get_previous_cycle(2026, 7) == (2026, 6) + + @pytest.mark.parametrize("month", [0, 13, -1, 99]) + def test_invalid_month_rejected(self, month): + with pytest.raises(ValueError): + get_cycle_range(2026, month) + with pytest.raises(ValueError): + get_month_range(2026, month) + + +class TestBudgetMonthCycleConvention: + """Budget month M ← credit-card cycle [(M-1)/18, M/18).""" + + def test_april_budget_covers_march18_to_april17(self, session): + included = [ + cc_tx(datetime(2026, 3, 18, 0, 0), 100.0), # first instant of cycle + cc_tx(datetime(2026, 4, 1, 12, 0), 200.0), # mid-cycle + cc_tx(datetime(2026, 4, 17, 23, 59), 400.0), # last day of cycle + ] + excluded = [ + cc_tx(datetime(2026, 3, 17, 23, 59), 7000.0), # previous cycle + cc_tx(datetime(2026, 4, 18, 0, 0), 9000.0), # next cycle + ] + for tx in included + excluded: + session.add(tx) + session.commit() + + assert cc_net(session, 2026, 4) == pytest.approx(700.0) + # The boundary transactions land in the adjacent budget months instead. + assert cc_net(session, 2026, 3) == pytest.approx(7000.0) + assert cc_net(session, 2026, 5) == pytest.approx(9000.0) + + def test_january_budget_reaches_into_prior_year(self, session): + session.add(cc_tx(datetime(2025, 12, 20), 500.0)) + session.commit() + assert cc_net(session, 2026, 1) == pytest.approx(500.0) + + def test_cash_uses_calendar_month_not_cycle(self, session): + session.add( + cc_tx( + datetime(2026, 4, 17), + 300.0, + source=TransactionSource.CASH, + ) + ) + session.commit() + actuals = compute_actuals_by_source(session, 2026, 4) + assert actuals["CASH"]["net"] == pytest.approx(300.0) + # Not in March's calendar month, despite being in March's... no — + # Apr 17 belongs to April calendar month only. + assert compute_actuals_by_source(session, 2026, 3)["CASH"][ + "net" + ] == pytest.approx(0.0) + + +class TestDeferredTransactions: + def test_deferred_moves_to_next_budget_month(self, session): + # 2026-03-10 lies in cycle [Feb 18, Mar 18) → budget month 3. + # Deferred, it must count in budget month 4 instead. + session.add(cc_tx(datetime(2026, 3, 10), 1500.0, deferred=True)) + session.commit() + + assert cc_net(session, 2026, 3) == pytest.approx(0.0) + assert cc_net(session, 2026, 4) == pytest.approx(1500.0) + # And it does not leak into month 5. + assert cc_net(session, 2026, 5) == pytest.approx(0.0) + + def test_deferred_devolucion_also_moves(self, session): + session.add(cc_tx(datetime(2026, 3, 10), 9000.0)) # normal, month 3 + session.add( + cc_tx( + datetime(2026, 3, 10), + 2000.0, + tx_type=TransactionType.DEVOLUCION, + deferred=True, + ) + ) + session.commit() + assert cc_net(session, 2026, 3) == pytest.approx(9000.0) + assert cc_net(session, 2026, 4) == pytest.approx(-2000.0) + + +class TestAmountSemantics: + def test_devolucion_subtracts_from_net(self, session): + session.add(cc_tx(datetime(2026, 4, 1), 5000.0)) + session.add( + cc_tx( + datetime(2026, 4, 2), + 1200.0, + tx_type=TransactionType.DEVOLUCION, + ) + ) + session.commit() + actuals = compute_actuals_by_source(session, 2026, 4)["CREDIT_CARD"] + assert actuals["total_compra"] == pytest.approx(5000.0) + assert actuals["total_devolucion"] == pytest.approx(1200.0) + assert actuals["net"] == pytest.approx(3800.0) + assert actuals["count"] == 2 + + def test_usd_converted_at_multiplier(self, session): + # conftest pins USD→CRC at 500. + session.add(cc_tx(datetime(2026, 4, 1), 10.0, currency=Currency.USD)) + session.commit() + assert cc_net(session, 2026, 4) == pytest.approx(5000.0) + + def test_income_types_excluded_from_counts(self, session): + session.add( + cc_tx( + datetime(2026, 4, 1), + 999999.0, + tx_type=TransactionType.SALARY, + source=TransactionSource.TRANSFER, + ) + ) + session.commit() + actuals = compute_actuals_by_source(session, 2026, 4)["TRANSFER"] + assert actuals["count"] == 0 + # SALARY is not COMPRA/DEVOLUCION so it contributes nothing to net. + assert actuals["net"] == pytest.approx(0.0) diff --git a/backend/tests/test_import_parse.py b/backend/tests/test_import_parse.py new file mode 100644 index 0000000..cea02a6 --- /dev/null +++ b/backend/tests/test_import_parse.py @@ -0,0 +1,60 @@ +"""Characterization of the BAC paste-import line parser.""" + +from datetime import datetime + +from app.api.v1.endpoints.import_transactions import ( + make_reference_hash, + parse_bac_line, +) +from app.models.models import TransactionType + + +class TestParseBacLine: + def test_tab_separated_purchase(self): + line = "15/04/2026\tWALMART\\SAN JOSE\\CR\t25,000.50 CRC" + parsed = parse_bac_line(line) + assert parsed is not None + assert parsed["date"] == datetime(2026, 4, 15) + assert parsed["merchant"] == "WALMART" + assert parsed["city"] == "SAN JOSE" + assert parsed["amount"] == 25000.50 + assert parsed["currency"] == "CRC" + assert parsed["transaction_type"] == TransactionType.COMPRA + + def test_multispace_separated_usd(self): + line = "01/05/2026 AMAZON\\SEATTLE\\US 12.99 USD" + parsed = parse_bac_line(line) + assert parsed is not None + assert parsed["amount"] == 12.99 + assert parsed["currency"] == "USD" + + def test_negative_amount_is_refund(self): + parsed = parse_bac_line("15/04/2026\tSTORE\\X\\CR\t-5,000.00 CRC") + assert parsed is not None + assert parsed["transaction_type"] == TransactionType.DEVOLUCION + assert parsed["amount"] == 5000.0 # stored as absolute value + + def test_pago_recibido_is_refund(self): + parsed = parse_bac_line("15/04/2026\tPAGO RECIBIDO\\\\\t100.00 CRC") + assert parsed is not None + assert parsed["transaction_type"] == TransactionType.DEVOLUCION + + def test_unparseable_lines_return_none(self): + assert parse_bac_line("") is None + assert parse_bac_line("not a transaction") is None + assert parse_bac_line("99/99/2026\tX\\Y\\Z\t1.00 CRC") is None + assert parse_bac_line("15/04/2026\tX\\Y\\Z\t1.00 GBP") is None + + +class TestReferenceHash: + def test_deterministic_and_merchant_normalized(self): + a = make_reference_hash(datetime(2026, 4, 15), " walmart ", 100.0, "CRC") + b = make_reference_hash(datetime(2026, 4, 15), "WALMART", 100.0, "CRC") + assert a == b + assert len(a) == 16 + + def test_different_inputs_differ(self): + base = make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "CRC") + assert make_reference_hash(datetime(2026, 4, 16), "X", 100.0, "CRC") != base + assert make_reference_hash(datetime(2026, 4, 15), "X", 100.1, "CRC") != base + assert make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "USD") != base