diff --git a/extend/__version__.py b/extend/__version__.py index a955fda..bc86c94 100644 --- a/extend/__version__.py +++ b/extend/__version__.py @@ -1 +1 @@ -__version__ = "1.2.1" +__version__ = "1.2.2" diff --git a/extend/resources/transactions.py b/extend/resources/transactions.py index 9d96223..2f74fb2 100644 --- a/extend/resources/transactions.py +++ b/extend/resources/transactions.py @@ -1,4 +1,4 @@ -from typing import Optional, Dict +from typing import Optional, Dict, Sequence, List from extend.client import APIClient from .resource import Resource @@ -19,13 +19,14 @@ async def get_transactions( per_page: Optional[int] = None, from_date: Optional[str] = None, to_date: Optional[str] = None, - status: Optional[str] = None, + status: Optional[Sequence[str]] = None, virtual_card_id: Optional[str] = None, min_amount_cents: Optional[int] = None, max_amount_cents: Optional[int] = None, receipt_missing: Optional[bool] = None, search_term: Optional[str] = None, sort_field: Optional[str] = None, + missing_expense_categories: Optional[bool] = None, ) -> Dict: """Get a list of transactions with optional filtering and pagination. @@ -34,7 +35,7 @@ async def get_transactions( per_page (Optional[int]): Number of items per page from_date (Optional[str]): Start date in YYYY-MM-DD format to_date (Optional[str]): End date in YYYY-MM-DD format - status (Optional[str]): Filter transactions by status (e.g., "PENDING", "CLEARED", "DECLINED", "NO_MATCH", "AVS_PASS", "AVS_FAIL", "AUTH_REVERSAL") + status (Optional[Sequence[str]]): Filter transactions by one or more statuses (e.g., "PENDING", "CLEARED", "DECLINED", "NO_MATCH", "AVS_PASS", "AVS_FAIL", "AUTH_REVERSAL"). virtual_card_id (str): Filter by specific virtual card min_amount_cents (int): Minimum clearing amount in cents max_amount_cents (int): Maximum clearing amount in cents @@ -43,6 +44,7 @@ async def get_transactions( sort_field (Optional[str]): Field to sort by, with optional direction Use "recipientName", "merchantName", "amount", "date" for ASC Use "-recipientName", "-merchantName", "-amount", "-date" for DESC + missing_expense_categories (Optional[bool]): Filter transactions that are missing required expense categories Returns: Dict: A dictionary containing: @@ -57,19 +59,35 @@ async def get_transactions( httpx.HTTPError: If the request fails """ - if status and not TransactionStatus.is_valid(status.upper()): - raise ValueError(f"{status} is not a valid status") + normalized_statuses: Optional[List[str]] = None + if status: + status_values = [status] if isinstance(status, str) else list(status) + normalized_statuses = [] + invalid_statuses = [] + for status_value in status_values: + normalized_value = status_value.upper() + if TransactionStatus.is_valid(normalized_value): + normalized_statuses.append(normalized_value) + else: + invalid_statuses.append(status_value) + if invalid_statuses: + invalid_list = ", ".join(invalid_statuses) + raise ValueError(f"{invalid_list} is not a valid status") + if not normalized_statuses: + normalized_statuses = None params = { "page": page, - "count": per_page, - "fromDate": from_date, - "toDate": to_date, - "statuses": status.upper() if status else None, + "perPage": per_page, + "since": from_date, + "until": to_date, + "status": normalized_statuses, "virtualCardId": virtual_card_id, "minClearingBillingCents": min_amount_cents, "maxClearingBillingCents": max_amount_cents, "receiptMissing": receipt_missing, + "receiptStatus": ["Missing"] if receipt_missing else None, + "expenseCategoryStatuses": ["Missing"] if missing_expense_categories else None, "search": search_term, "sort": sort_field, } diff --git a/tests/test_client.py b/tests/test_client.py index b20d1a8..c70ee87 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -259,6 +259,37 @@ async def test_get_transactions(extend, mocker, mock_transaction): assert len(response["report"]["transactions"]) == 2 +@pytest.mark.asyncio +async def test_get_transactions_receipt_missing_param(extend, mocker, mock_transaction): + mock_response: Any = { + "report": {"transactions": [mock_transaction]} + } + + mock_get = mocker.patch.object(extend._api_client, 'get', return_value=mock_response) + + await extend.transactions.get_transactions(receipt_missing=True) + + assert mock_get.call_count == 1 + _, params = mock_get.call_args[0] + assert params["receiptMissing"] is True + assert params["receiptStatus"] == "Missing" + + +@pytest.mark.asyncio +async def test_get_transactions_missing_expense_categories_param(extend, mocker, mock_transaction): + mock_response: Any = { + "report": {"transactions": [mock_transaction]} + } + + mock_get = mocker.patch.object(extend._api_client, 'get', return_value=mock_response) + + await extend.transactions.get_transactions(missing_expense_categories=True) + + assert mock_get.call_count == 1 + _, params = mock_get.call_args[0] + assert params["expenseCategoryStatuses"] == ["Missing"] + + # Additional recurrence validation tests def test_validate_recurrence_data_monthly(extend): # Test valid monthly recurrence diff --git a/tests/test_integration.py b/tests/test_integration.py index 657a049..54d9ce3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,6 +2,7 @@ import uuid from datetime import datetime, timedelta from io import BytesIO +from typing import Dict, Optional import httpx import pytest @@ -256,6 +257,109 @@ async def test_list_transactions_with_sorting(self, extend): f"Different sort directions for {field_name} should produce different results" ) + @pytest.mark.asyncio + async def test_list_transactions_with_status_filter(self, extend): + """Ensure status filtering supports single and multiple values.""" + + baseline_response = await extend.transactions.get_transactions(per_page=20) + baseline_transactions = get_transactions_from_response(baseline_response) + + if not baseline_transactions: + pytest.skip("No transactions available to validate status filtering") + + unique_statuses = {tx["status"] for tx in baseline_transactions if tx.get("status")} + + if not unique_statuses: + pytest.skip("Transactions are missing status values for validation") + + single_status = next(iter(unique_statuses)) + + # List using a single status value in a sequence + single_response = await extend.transactions.get_transactions(status=[single_status]) + single_transactions = get_transactions_from_response(single_response) + if single_transactions: + for transaction in single_transactions: + assert transaction.get("status") == single_status + + # List using multiple status values when available + if len(unique_statuses) > 1: + multi_statuses = list(unique_statuses)[:2] + multi_response = await extend.transactions.get_transactions(status=multi_statuses) + multi_transactions = get_transactions_from_response(multi_response) + allowed_statuses = set(multi_statuses) + if multi_transactions: + for transaction in multi_transactions: + assert transaction.get("status") in allowed_statuses + + @pytest.mark.asyncio + async def test_list_transactions_with_date_filters(self, extend): + """Validate since/until filters constrain transaction authedAt timestamps.""" + + baseline_response = await extend.transactions.get_transactions(per_page=50, sort_field='-date') + baseline_transactions = get_transactions_from_response(baseline_response) + + if not baseline_transactions: + pytest.skip("No transactions available to validate date filters") + + def _parse_timestamp(raw: str) -> datetime: + try: + return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S.%f%z") + except ValueError: + return datetime.strptime(raw, "%Y-%m-%d") + + def _extract_transaction_datetime(tx: Dict) -> Optional[datetime]: + authed_at = tx.get("authedAt") + if isinstance(authed_at, str): + try: + return _parse_timestamp(authed_at) + except ValueError: + return None + return None + + parsed_dates = [dt for tx in baseline_transactions if (dt := _extract_transaction_datetime(tx)) is not None] + + if not parsed_dates: + pytest.skip("Transactions do not include parsable authedAt values for validation") + + parsed_dates.sort() + unique_dates = sorted({dt.date() for dt in parsed_dates}) + + since_date = unique_dates[0] + if len(unique_dates) > 1: + since_date = unique_dates[1] + + until_date = unique_dates[-1] + if len(unique_dates) > 1: + until_date = unique_dates[-2] + + since_response = await extend.transactions.get_transactions(from_date=since_date.isoformat(), per_page=50) + since_transactions = get_transactions_from_response(since_response) + if since_transactions: + for transaction in since_transactions: + parsed = _extract_transaction_datetime(transaction) + assert parsed is not None + assert parsed.date() >= since_date + + until_response = await extend.transactions.get_transactions(to_date=until_date.isoformat(), per_page=50) + until_transactions = get_transactions_from_response(until_response) + if until_transactions: + for transaction in until_transactions: + parsed = _extract_transaction_datetime(transaction) + assert parsed is not None + assert parsed.date() <= until_date + + range_response = await extend.transactions.get_transactions( + from_date=since_date.isoformat(), + to_date=until_date.isoformat(), + per_page=50 + ) + range_transactions = get_transactions_from_response(range_response) + if range_transactions: + for transaction in range_transactions: + parsed = _extract_transaction_datetime(transaction) + assert parsed is not None + assert since_date <= parsed.date() <= until_date + @pytest.mark.integration class TestRecurringCards: