Skip to content
Merged
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
2 changes: 1 addition & 1 deletion extend/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.2.1"
__version__ = "1.2.2"
36 changes: 27 additions & 9 deletions extend/resources/transactions.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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,
}
Expand Down
31 changes: 31 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import uuid
from datetime import datetime, timedelta
from io import BytesIO
from typing import Dict, Optional

import httpx
import pytest
Expand Down Expand Up @@ -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:
Expand Down