Skip to content

Commit fe6aaad

Browse files
authored
Merge pull request #18 from paywithextend/development
Release 1.1.0
2 parents 90202cc + af52552 commit fe6aaad

File tree

7 files changed

+185
-8
lines changed

7 files changed

+185
-8
lines changed

extend/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from extend.models import VirtualCard, Transaction, RecurrenceConfig
66
from .extend import ExtendClient
77

8-
__version__ = "0.1.0"
8+
__version__ = "1.1.0"
99

1010
__all__ = [
1111
"ExtendClient",

extend/extend.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .resources.credit_cards import CreditCards
44
from .resources.expense_data import ExpenseData
55
from .resources.receipt_attachments import ReceiptAttachments
6+
from .resources.receipt_capture import ReceiptCapture
67
from .resources.transactions import Transactions
78

89

@@ -33,3 +34,4 @@ def __init__(self, api_key: str, api_secret: str):
3334
self.transactions = Transactions(self._api_client)
3435
self.expense_data = ExpenseData(self._api_client)
3536
self.receipt_attachments = ReceiptAttachments(self._api_client)
37+
self.receipt_capture = ReceiptCapture(self._api_client)

extend/resources/receipt_attachments.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Dict, IO
1+
from typing import Dict, IO, Optional
22

33
from extend.client import APIClient
44
from .resource import Resource
@@ -14,15 +14,15 @@ def __init__(self, api_client: APIClient):
1414

1515
async def create_receipt_attachment(
1616
self,
17-
transaction_id: str,
1817
file: IO,
18+
transaction_id: Optional[str] = None,
1919
) -> Dict:
2020
"""Create a receipt attachment for a transaction by uploading a file using multipart form data.
2121
2222
Args:
23-
transaction_id (str): The unique identifier of the transaction to attach the receipt to
2423
file (IO): A file-like object opened in binary mode that contains the data
2524
to be uploaded
25+
transaction_id (Optional[str]): The optional unique identifier of the transaction to attach the receipt to
2626
2727
Returns:
2828
Dict: A dictionary representing the receipt attachment, including:
@@ -39,5 +39,5 @@ async def create_receipt_attachment(
3939

4040
return await self._request(
4141
method="post_multipart",
42-
data={"transactionId": transaction_id},
42+
data={"transactionId": transaction_id} if transaction_id else None,
4343
files={"file": file})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from typing import Dict, List, Any
2+
3+
from extend.client import APIClient
4+
from .resource import Resource
5+
6+
7+
class ReceiptCapture(Resource):
8+
@property
9+
def _base_url(self) -> str:
10+
return "/receiptcapture"
11+
12+
def __init__(self, api_client: APIClient):
13+
super().__init__(api_client)
14+
15+
async def automatch_receipts(
16+
self,
17+
receipt_attachment_ids: List[str],
18+
) -> Dict[str, Any]:
19+
"""
20+
Initiates an asynchronous bulk receipt automatch job.
21+
22+
This method triggers an asynchronous job on the server that processes the provided receipt
23+
attachment IDs. The operation is non-blocking: it immediately returns a job ID and preliminary
24+
details, while the matching process is performed in the background.
25+
26+
The server returns a response conforming to the BulkReceiptAutomatchResponse structure, which includes:
27+
- id (str): The bulk automatch job ID.
28+
- tasks (List[Dict]): A list of tasks representing individual automatch operations. Each task includes:
29+
- id (str): Task ID.
30+
- status (str): Task status.
31+
- receiptAttachmentId (str): Receipt attachment ID.
32+
- transactionId (Optional[str]): Matched transaction ID (if available).
33+
- attachmentsCount (Optional[int]): Number of attachments on the matched transaction.
34+
35+
Args:
36+
receipt_attachment_ids (List[str]): A list of receipt attachment IDs to be automatched.
37+
38+
Returns:
39+
Dict[str, Any]: A dictionary representing the Bulk Receipt Automatch Response, including the job ID.
40+
41+
Raises:
42+
httpx.HTTPError: If the request fails.
43+
"""
44+
payload = {"receiptAttachmentIds": receipt_attachment_ids}
45+
46+
return await self._request(
47+
method="post",
48+
path="/automatch",
49+
params=payload,
50+
)
51+
52+
async def get_automatch_status(
53+
self,
54+
job_id: str,
55+
) -> Dict[str, Any]:
56+
"""
57+
Retrieves the status of a bulk receipt capture automatch job.
58+
59+
This method calls a GET endpoint with the provided job ID to fetch the current status of the
60+
asynchronous automatch job. The response conforms to the BulkReceiptAutomatchResponse structure and includes:
61+
- id (str): The job ID.
62+
- tasks (List[Dict]): A list of automatch task views containing details such as task ID, status,
63+
receipt attachment ID, the matched transaction ID (if available), and the number of attachments
64+
on the transaction.
65+
66+
Args:
67+
job_id (str): The ID of the automatch job whose status is to be retrieved.
68+
69+
Returns:
70+
Dict[str, Any]: A dictionary representing the current Bulk Receipt Automatch Response.
71+
72+
Raises:
73+
httpx.HTTPError: If the request fails.
74+
"""
75+
return await self._request(
76+
method="get",
77+
path=f"/{job_id}/status",
78+
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "paywithextend"
7-
version = "1.0.0"
7+
version = "1.1.0"
88
description = "Python client for the Extend API"
99
readme = "README.md"
1010
authors = [{ name = "Extend Engineering", email = "[email protected]" }]

tests/test_client.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import os
22
from datetime import datetime, timedelta
3-
from typing import Any
3+
from typing import Any, Dict, List
44

55
import httpx
66
import pytest
@@ -593,3 +593,59 @@ async def test_update_expense_category_label(extend, mocker):
593593

594594
assert response["name"] == "Updated Label"
595595
assert response["active"] is True
596+
597+
598+
@pytest.mark.asyncio
599+
async def test_automatch_receipts(extend, mocker):
600+
"""
601+
Test that the automatch_receipts endpoint returns the expected job and task details.
602+
"""
603+
mock_response: Dict[str, Any] = {
604+
"id": "job_123",
605+
"tasks": [
606+
{
607+
"id": "task_1",
608+
"status": "PENDING",
609+
"receiptAttachmentId": "ra_123",
610+
"transactionId": None,
611+
"attachmentsCount": 0
612+
}
613+
]
614+
}
615+
616+
mocker.patch.object(extend._api_client, 'post', return_value=mock_response)
617+
receipt_attachment_ids: List[str] = ["ra_123"]
618+
619+
response = await extend.receipt_capture.automatch_receipts(
620+
receipt_attachment_ids=receipt_attachment_ids
621+
)
622+
623+
assert response["id"] == "job_123"
624+
assert len(response["tasks"]) == 1
625+
assert response["tasks"][0]["receiptAttachmentId"] == "ra_123"
626+
627+
628+
@pytest.mark.asyncio
629+
async def test_get_automatch_status(extend, mocker):
630+
"""
631+
Test that the get_automatch_status endpoint returns the current job status and task details.
632+
"""
633+
mock_response: Dict[str, Any] = {
634+
"id": "job_123",
635+
"tasks": [
636+
{
637+
"id": "task_1",
638+
"status": "COMPLETED",
639+
"receiptAttachmentId": "ra_123",
640+
"transactionId": "txn_123",
641+
"attachmentsCount": 1
642+
}
643+
]
644+
}
645+
646+
mocker.patch.object(extend._api_client, 'get', return_value=mock_response)
647+
response = await extend.receipt_capture.get_automatch_status("job_123")
648+
649+
assert response["id"] == "job_123"
650+
assert len(response["tasks"]) == 1
651+
assert response["tasks"][0]["transactionId"] == "txn_123"

tests/test_integration.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,8 @@ async def test_update_transaction_expense_data_with_specific_category_and_label(
435435
transactions_response = await extend.transactions.get_transactions(per_page=1)
436436
assert "report" in transactions_response, "Response should include 'report'"
437437
assert "transactions" in transactions_response["report"], "Response should include 'transactions'"
438-
assert transactions_response["report"]["transactions"], "No transactions available for testing expense data update"
438+
assert transactions_response["report"][
439+
"transactions"], "No transactions available for testing expense data update"
439440
transaction = transactions_response["report"]["transactions"][0]
440441
transaction_id = transaction["id"]
441442

@@ -494,6 +495,46 @@ async def test_create_receipt_attachment(self, extend):
494495
assert response["contentType"] == "image/png", "Content type should be 'image/png'"
495496

496497

498+
@pytest.mark.integration
499+
class TestReceiptCaptureEndpoints:
500+
"""Integration tests for the new receipt capture endpoints"""
501+
502+
@pytest.mark.asyncio
503+
async def test_automatch_receipts_and_get_status(self, extend):
504+
"""
505+
Integration test that:
506+
1. Creates a dummy receipt attachment for an existing transaction.
507+
2. Initiates an automatch job using the new endpoint.
508+
3. Retrieves and verifies the automatch job status.
509+
"""
510+
# Create a dummy PNG file in memory (a minimal PNG header plus extra bytes)
511+
png_header = b'\x89PNG\r\n\x1a\n'
512+
dummy_content = png_header + b'\x00' * 100
513+
file_obj = BytesIO(dummy_content)
514+
file_obj.name = f"test_receipt_{uuid.uuid4()}.png"
515+
516+
# Create a receipt attachment using the receipt_attachments endpoint
517+
attachment_response = await extend.receipt_attachments.create_receipt_attachment(
518+
file=file_obj
519+
)
520+
assert "id" in attachment_response, "Receipt attachment should have an id"
521+
receipt_attachment_id = attachment_response["id"]
522+
523+
# Initiate an automatch job using the new receipt capture endpoint
524+
automatch_response = await extend.receipt_capture.automatch_receipts(
525+
receipt_attachment_ids=[receipt_attachment_id]
526+
)
527+
assert "id" in automatch_response, "Automatch response should include a job id"
528+
assert "tasks" in automatch_response, "Automatch response should include tasks"
529+
job_id = automatch_response["id"]
530+
531+
# Retrieve the automatch job status using the new endpoint
532+
status_response = await extend.receipt_capture.get_automatch_status(job_id)
533+
assert "id" in status_response, "Status response should include a job id"
534+
assert status_response["id"] == job_id, "Job id should match the one returned during automatch"
535+
assert "tasks" in status_response, "Status response should include tasks"
536+
537+
497538
def test_environment_variables():
498539
"""Test that required environment variables are set"""
499540
assert os.getenv("EXTEND_API_KEY"), "EXTEND_API_KEY environment variable is required"

0 commit comments

Comments
 (0)