Skip to content

Commit 69220de

Browse files
authored
Merge pull request #6 from paywithextend/add-expense-management-endpoints
2 parents 1455b68 + 57977cf commit 69220de

File tree

9 files changed

+642
-1
lines changed

9 files changed

+642
-1
lines changed

extend/client.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,5 +124,29 @@ async def put(self, url: str, data: Dict) -> Any:
124124
response.raise_for_status()
125125
return response.json()
126126

127+
async def patch(self, url: str, data: Dict) -> Any:
128+
"""Make a PATCH request to the Extend API.
129+
130+
Args:
131+
url (str): The API endpoint path (e.g., "/virtualcards/{card_id}")
132+
data (Dict): The JSON payload to send in the request body
133+
134+
Returns:
135+
The JSON response from the API
136+
137+
Raises:
138+
httpx.HTTPError: If the request fails
139+
ValueError: If the response is not valid JSON
140+
"""
141+
async with httpx.AsyncClient() as client:
142+
response = await client.patch(
143+
self.build_full_url(url),
144+
headers=self.headers,
145+
json=data,
146+
timeout=httpx.Timeout(30)
147+
)
148+
response.raise_for_status()
149+
return response.json()
150+
127151
def build_full_url(self, url: Optional[str]):
128152
return f"https://{API_HOST}{url or ''}"

extend/extend.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from extend.resources.virtual_cards import VirtualCards
22
from .client import APIClient
33
from .resources.credit_cards import CreditCards
4+
from .resources.expense_data import ExpenseData
45
from .resources.transactions import Transactions
56

67

@@ -29,3 +30,4 @@ def __init__(self, api_key: str, api_secret: str):
2930
self.credit_cards = CreditCards(self._api_client)
3031
self.virtual_cards = VirtualCards(self._api_client)
3132
self.transactions = Transactions(self._api_client)
33+
self.expense_data = ExpenseData(self._api_client)

extend/resources/credit_cards.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,21 @@ async def get_credit_cards(
4949
params = {k: v for k, v in params.items() if v is not None}
5050

5151
return await self._request(method="get", params=params)
52+
53+
async def get_credit_card_detail(
54+
self,
55+
card_id: str
56+
) -> Dict:
57+
"""Get detailed information about a specific credit card.
58+
59+
Args:
60+
card_id (str): The unique identifier of the credit card
61+
62+
Returns:
63+
Dict: A dictionary containing the credit card details:
64+
65+
Raises:
66+
httpx.HTTPError: If the request fails
67+
"""
68+
69+
return await self._request(method="get", path=f"/{card_id}")

extend/resources/expense_data.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
from typing import Optional, Dict
2+
3+
from extend.client import APIClient
4+
from .resource import Resource
5+
6+
7+
class ExpenseData(Resource):
8+
@property
9+
def _base_url(self) -> str:
10+
return "/expensedata"
11+
12+
def __init__(self, api_client: APIClient):
13+
super().__init__(api_client)
14+
15+
async def get_expense_categories(
16+
self,
17+
active: Optional[bool] = None,
18+
required: Optional[bool] = None,
19+
search: Optional[str] = None,
20+
sort_field: Optional[str] = None,
21+
sort_direction: Optional[str] = None,
22+
) -> Dict:
23+
"""Get a list of expense categories.
24+
25+
Args:
26+
active (Optional[bool]): Show only active categories
27+
required (Optional[bool]): Show only required categories
28+
search (Optional[str]): Full-text search query for filtering categories
29+
sort_field (Optional[str]): Field to sort by
30+
sort_direction (Optional[str]): Direction of sorting ("asc" or "desc")
31+
32+
Returns:
33+
Dict: A dictionary containing:
34+
- expenseCategories: List of Expense Category objects
35+
36+
Raises:
37+
httpx.HTTPError: If the request fails
38+
"""
39+
40+
params = {
41+
"active": active,
42+
"required": required,
43+
"search": search,
44+
"sortField": sort_field,
45+
"sortDirection": sort_direction,
46+
}
47+
48+
return await self._request(method="get", path=f"/categories", params=params)
49+
50+
async def get_expense_category(self, category_id: str) -> Dict:
51+
"""Get detailed information about a specific expense category.
52+
53+
Args:
54+
category_id (str): The unique identifier of the expense category
55+
56+
Returns:
57+
Dict: A dictionary containing the expense category details
58+
59+
Raises:
60+
httpx.HTTPError: If the request fails or transaction not found
61+
"""
62+
return await self._request(method="get", path=f"/categories/{category_id}")
63+
64+
async def get_expense_category_labels(
65+
self,
66+
category_id: str,
67+
page: Optional[int] = None,
68+
per_page: Optional[int] = None,
69+
active: Optional[bool] = None,
70+
search: Optional[str] = None,
71+
sort_field: Optional[str] = None,
72+
sort_direction: Optional[str] = None,
73+
) -> Dict:
74+
"""Get a paginated list of expense categories.
75+
76+
Args:
77+
category_id (str): The unique identifier of the expense category
78+
page (Optional[int]): The page number for pagination (1-based)
79+
per_page (Optional[int]): Number of items per page
80+
active (Optional[bool]): Show only active labels
81+
search (Optional[str]): Full-text search query
82+
sort_field (Optional[str]): Field to sort by (e.g., activeLabelNameAsc, name)
83+
sort_direction (Optional[str]): Sort direction (asc, desc) for sortable fields
84+
85+
Returns:
86+
Dict: A dictionary containing:
87+
- expenseLabels: List of Expense Category Label objects
88+
- pagination: Dictionary containing the following pagination stats:
89+
- page: Current page number
90+
- pageItemCount: Number of items per page
91+
- totalItems: Total number expense category labels across all pages
92+
- numberOfPages: Total number of pages
93+
94+
Raises:
95+
httpx.HTTPError: If the request fails
96+
"""
97+
98+
params = {
99+
"page": page,
100+
"count": per_page,
101+
"active": active,
102+
"search": search,
103+
"sortField": sort_field,
104+
"sortDirection": sort_direction,
105+
}
106+
107+
return await self._request(method="get", path=f"/categories/{category_id}/labels", params=params)
108+
109+
async def create_expense_category(
110+
self,
111+
name: str,
112+
code: str,
113+
required: bool,
114+
active: Optional[bool] = None,
115+
free_text_allowed: Optional[bool] = None,
116+
integrator_enabled: Optional[bool] = None,
117+
integrator_field_number: Optional[int] = None,
118+
) -> Dict:
119+
"""Create an expense category.
120+
121+
Args:
122+
name (str): User-facing name for this expense category
123+
code (str): Code for the expense category
124+
required (bool): Whether this field is required for all users
125+
active (Optional[bool]): Whether this category is active and available for input
126+
free_text_allowed (Optional[bool]): Whether free text input is allowed
127+
integrator_enabled (Optional[bool]): Whether this category is integrator enabled
128+
integrator_field_number (Optional[int]): Field number used by the integrator
129+
130+
Returns:
131+
Dict: A dictionary containing the newly created expense category
132+
133+
Raises:
134+
httpx.HTTPError: If the request fails or the transaction is not found
135+
"""
136+
137+
payload = {
138+
"name": name,
139+
"code": code,
140+
"required": required,
141+
"active": active,
142+
"freeTextAllowed": free_text_allowed,
143+
"integratorEnabled": integrator_enabled,
144+
"integratorFieldNumber": integrator_field_number,
145+
}
146+
147+
return await self._request(
148+
method="post",
149+
params=payload
150+
)
151+
152+
async def create_expense_category_label(
153+
self,
154+
category_id: str,
155+
name: str,
156+
code: str,
157+
active: bool = True
158+
) -> Dict:
159+
"""Create an expense category.
160+
161+
Args:
162+
category_id (str): The unique identifier of the expense category
163+
name (str): User-facing name for this expense category label
164+
code (str): Code for the expense category label
165+
active (bool): Whether the label is active and available for input
166+
167+
Returns:
168+
Dict: A dictionary containing the newly created expense category label
169+
170+
Raises:
171+
httpx.HTTPError: If the request fails or the transaction is not found
172+
"""
173+
payload = {
174+
"name": name,
175+
"code": code,
176+
"active": active,
177+
}
178+
179+
return await self._request(
180+
method="post",
181+
path=f"/{category_id}",
182+
params=payload
183+
)
184+
185+
async def update_expense_category(
186+
self,
187+
category_id: str,
188+
name: Optional[str] = None,
189+
active: Optional[bool] = None,
190+
required: Optional[bool] = None,
191+
free_text_allowed: Optional[bool] = None,
192+
integrator_enabled: Optional[bool] = None,
193+
integrator_field_number: Optional[int] = None,
194+
) -> Dict:
195+
"""Update the an expense category.
196+
197+
Args:
198+
category_id (str): The unique identifier of the expense category
199+
name (Optional[str]): User-facing name for this expense category
200+
active (Optional[bool]): Whether the category is active
201+
required (Optional[bool]): Whether this field is required for all users
202+
free_text_allowed (Optional[bool]): Whether free text input is allowed
203+
integrator_enabled (Optional[bool]): Whether this category is integrator enabled
204+
integrator_field_number (Optional[int]): Field number used by the integrator
205+
206+
Returns:
207+
Dict: A dictionary containing the updated expense category details
208+
209+
Raises:
210+
httpx.HTTPError: If the request fails or the transaction is not found
211+
"""
212+
213+
payload = {
214+
"name": name,
215+
"active": active,
216+
"required": required,
217+
"freeTextAllowed": free_text_allowed,
218+
"integratorEnabled": integrator_enabled,
219+
"integratorFieldNumber": integrator_field_number,
220+
}
221+
222+
return await self._request(
223+
method="patch",
224+
path=f"/{category_id}",
225+
params=payload
226+
)
227+
228+
async def update_expense_category_label(
229+
self,
230+
category_id: str,
231+
label_id: str,
232+
name: Optional[str] = None,
233+
active: Optional[bool] = None,
234+
) -> Dict:
235+
"""Update an expense category label.
236+
237+
Args:
238+
category_id (str): The unique identifier of the expense category
239+
label_id (str): The unique identifier of the expense category label to update
240+
name (Optional[str]): User-facing name for the expense label
241+
active (Optional[bool]): Whether the label is active and available for input
242+
243+
Returns:
244+
Dict: A dictionary containing the updated expense category details
245+
246+
Raises:
247+
httpx.HTTPError: If the request fails or the transaction is not found
248+
"""
249+
250+
payload = {
251+
"name": name,
252+
"active": active,
253+
}
254+
255+
return await self._request(
256+
method="patch",
257+
path=f"/{category_id}/labels/{label_id}",
258+
params=payload
259+
)

extend/resources/resource.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,17 @@ async def _request(
2222
path: str = None,
2323
params: Optional[Dict] = None
2424
) -> Any:
25+
if params is not None:
26+
params = {k: v for k, v in params.items() if v is not None}
2527
match method:
2628
case "get":
2729
return await self._api_client.get(self.build_full_path(path), params)
2830
case "post":
2931
return await self._api_client.post(self.build_full_path(path), params)
3032
case "put":
3133
return await self._api_client.put(self.build_full_path(path), params)
34+
case "patch":
35+
return await self._api_client.patch(self.build_full_path(path), params)
3236
case _:
3337
raise ValueError(f"Unsupported HTTP method: {method}")
3438

extend/resources/transactions.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,39 @@ async def get_transaction(self, transaction_id: str) -> Dict:
7575
httpx.HTTPError: If the request fails or transaction not found
7676
"""
7777
return await self._request(method="get", path=f"/{transaction_id}")
78+
79+
async def update_transaction_expense_data(self, transaction_id: str, data: Dict) -> Dict:
80+
"""Update the expense data for a specific transaction.
81+
82+
Args:
83+
transaction_id (str): The unique identifier of the transaction
84+
data (Dict): A dictionary representing the expense data to update, should match
85+
the schema:
86+
{
87+
"supplier": {
88+
"name": "Some Supplier",
89+
"id": "supplier-id"
90+
},
91+
"expenseCategories": [
92+
{
93+
"categoryCode": "COMPCODE",
94+
"labelCode": "ABC123"
95+
}
96+
],
97+
"customer": {
98+
"name": "Some Customer",
99+
"id": "customer-id"
100+
}
101+
}
102+
103+
Returns:
104+
Dict: A dictionary containing the updated transaction details
105+
106+
Raises:
107+
httpx.HTTPError: If the request fails or the transaction is not found
108+
"""
109+
return await self._request(
110+
method="patch",
111+
path=f"/{transaction_id}/expensedata",
112+
params=data
113+
)

extend/resources/virtual_cards.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,8 @@ async def create_virtual_card(
137137
async def update_virtual_card(
138138
self,
139139
card_id: str,
140+
display_name: str,
140141
balance_cents: int,
141-
display_name: Optional[str] = None,
142142
notes: Optional[str] = None,
143143
valid_from: Optional[str] = None,
144144
valid_to: Optional[str] = None

0 commit comments

Comments
 (0)