connections-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,440 @@
1
+ from typing import Dict, Any, Tuple, Optional, Union, cast
2
+ from datetime import datetime, timezone
3
+ from deepmerge import always_merger
4
+ import requests
5
+ import os
6
+ import json
7
+ from json.decoder import JSONDecodeError
8
+
9
+ from ..models import (
10
+ TransactionRequest,
11
+ Amount,
12
+ Source,
13
+ SourceType,
14
+ Customer,
15
+ Address,
16
+ StatementDescription,
17
+ ThreeDS,
18
+ RecurringType,
19
+ TransactionStatusCode,
20
+ ErrorType,
21
+ ErrorCategory,
22
+ RefundRequest,
23
+ RefundResponse,
24
+ TransactionStatus,
25
+ ErrorResponse,
26
+ ErrorCode,
27
+ TransactionResponse,
28
+ TransactionSource,
29
+ ProvisionedSource
30
+ )
31
+ from connections_sdk.exceptions import TransactionError
32
+ from ..utils.model_utils import create_transaction_request, validate_required_fields
33
+ from ..utils.request_client import RequestClient
34
+
35
+
36
+ RECURRING_TYPE_MAPPING = {
37
+ RecurringType.ONE_TIME: "Regular",
38
+ RecurringType.CARD_ON_FILE: "CardOnFile",
39
+ RecurringType.SUBSCRIPTION: "Recurring",
40
+ RecurringType.UNSCHEDULED: "Unscheduled"
41
+ }
42
+
43
+ # Map Checkout.com status to our status codes
44
+ STATUS_CODE_MAPPING = {
45
+ "Authorized": TransactionStatusCode.AUTHORIZED,
46
+ "Pending": TransactionStatusCode.PENDING,
47
+ "Card Verified": TransactionStatusCode.CARD_VERIFIED,
48
+ "Declined": TransactionStatusCode.DECLINED,
49
+ "Retry Scheduled": TransactionStatusCode.RETRY_SCHEDULED
50
+ }
51
+
52
+ # Mapping of Checkout.com error codes to our error types
53
+ ERROR_CODE_MAPPING = {
54
+ "card_authorization_failed": ErrorType.REFUSED,
55
+ "card_disabled": ErrorType.BLOCKED_CARD,
56
+ "card_expired": ErrorType.EXPIRED_CARD,
57
+ "card_expiry_month_invalid": ErrorType.INVALID_CARD,
58
+ "card_expiry_month_required": ErrorType.INVALID_CARD,
59
+ "card_expiry_year_invalid": ErrorType.INVALID_CARD,
60
+ "card_expiry_year_required": ErrorType.INVALID_CARD,
61
+ "expiry_date_format_invalid": ErrorType.INVALID_CARD,
62
+ "card_not_found": ErrorType.INVALID_CARD,
63
+ "card_number_invalid": ErrorType.INVALID_CARD,
64
+ "card_number_required": ErrorType.INVALID_CARD,
65
+ "issuer_network_unavailable": ErrorType.OTHER,
66
+ "card_not_eligible_domestic_money_transfer": ErrorType.NOT_SUPPORTED,
67
+ "card_not_eligible_cross_border_money_transfer": ErrorType.NOT_SUPPORTED,
68
+ "card_not_eligible_domestic_non_money_transfer": ErrorType.NOT_SUPPORTED,
69
+ "card_not_eligible_cross_border_non_money_transfer": ErrorType.NOT_SUPPORTED,
70
+ "card_not_eligible_domestic_online_gambling": ErrorType.NOT_SUPPORTED,
71
+ "card_not_eligible_cross_border_online_gambling": ErrorType.NOT_SUPPORTED,
72
+ "3ds_malfunction": ErrorType.AUTHENTICATION_FAILURE,
73
+ "3ds_not_enabled_for_card": ErrorType.AUTHENTICATION_FAILURE,
74
+ "3ds_not_supported": ErrorType.AUTHENTICATION_FAILURE,
75
+ "3ds_not_configured": ErrorType.AUTHENTICATION_FAILURE,
76
+ "3ds_payment_required": ErrorType.AUTHENTICATION_FAILURE,
77
+ "3ds_version_invalid": ErrorType.AUTHENTICATION_FAILURE,
78
+ "3ds_version_not_supported": ErrorType.AUTHENTICATION_FAILURE,
79
+ "amount_exceeds_balance": ErrorType.INSUFFICENT_FUNDS,
80
+ "amount_limit_exceeded": ErrorType.INSUFFICENT_FUNDS,
81
+ "payment_expired": ErrorType.PAYMENT_CANCELLED,
82
+ "cvv_invalid": ErrorType.CVC_INVALID,
83
+ "processing_error": ErrorType.REFUSED,
84
+ "velocity_amount_limit_exceeded": ErrorType.INSUFFICENT_FUNDS,
85
+ "velocity_count_limit_exceeded": ErrorType.INSUFFICENT_FUNDS,
86
+ "address_invalid": ErrorType.AVS_DECLINE,
87
+ "city_invalid": ErrorType.AVS_DECLINE,
88
+ "country_address_invalid": ErrorType.AVS_DECLINE,
89
+ "country_invalid": ErrorType.AVS_DECLINE,
90
+ "country_phone_code_invalid": ErrorType.AVS_DECLINE,
91
+ "country_phone_code_length_invalid": ErrorType.AVS_DECLINE,
92
+ "phone_number_invalid": ErrorType.AVS_DECLINE,
93
+ "phone_number_length_invalid": ErrorType.AVS_DECLINE,
94
+ "zip_invalid": ErrorType.AVS_DECLINE,
95
+ "action_failure_limit_exceeded": ErrorType.PROCESSOR_BLOCKED,
96
+ "token_expired": ErrorType.OTHER,
97
+ "token_in_use": ErrorType.OTHER,
98
+ "token_invalid": ErrorType.OTHER,
99
+ "token_used": ErrorType.OTHER,
100
+ "capture_value_greater_than_authorized": ErrorType.OTHER,
101
+ "capture_value_greater_than_remaining_authorized": ErrorType.OTHER,
102
+ "card_holder_invalid": ErrorType.OTHER,
103
+ "previous_payment_id_invalid": ErrorType.OTHER,
104
+ "processing_channel_id_required": ErrorType.CONFIGURATION_ERROR,
105
+ "success_url_required": ErrorType.CONFIGURATION_ERROR,
106
+ "source_token_invalid": ErrorType.INVALID_SOURCE_TOKEN,
107
+ "aft_processor_not_matched": ErrorType.OTHER,
108
+ "amount_invalid": ErrorType.OTHER,
109
+ "api_calls_quota_exceeded": ErrorType.OTHER,
110
+ "billing_descriptor_city_invalid": ErrorType.OTHER,
111
+ "billing_descriptor_city_required": ErrorType.OTHER,
112
+ "billing_descriptor_name_invalid": ErrorType.OTHER,
113
+ "billing_descriptor_name_required": ErrorType.OTHER,
114
+ "business_invalid": ErrorType.OTHER,
115
+ "business_settings_missing": ErrorType.OTHER,
116
+ "channel_details_invalid": ErrorType.OTHER,
117
+ "channel_url_missing": ErrorType.OTHER,
118
+ "charge_details_invalid": ErrorType.OTHER,
119
+ "currency_invalid": ErrorType.OTHER,
120
+ "currency_required": ErrorType.OTHER,
121
+ "customer_already_exists": ErrorType.OTHER,
122
+ "customer_email_invalid": ErrorType.OTHER,
123
+ "customer_id_invalid": ErrorType.OTHER,
124
+ "customer_not_found": ErrorType.OTHER,
125
+ "customer_number_invalid": ErrorType.OTHER,
126
+ "customer_plan_edit_failed": ErrorType.OTHER,
127
+ "customer_plan_id_invalid": ErrorType.OTHER,
128
+ "email_in_use": ErrorType.OTHER,
129
+ "email_invalid": ErrorType.OTHER,
130
+ "email_required": ErrorType.OTHER,
131
+ "endpoint_invalid": ErrorType.OTHER,
132
+ "fail_url_invalid": ErrorType.OTHER,
133
+ "ip_address_invalid": ErrorType.OTHER,
134
+ "metadata_key_invalid": ErrorType.OTHER,
135
+ "no_authorization_enabled_processors_available": ErrorType.OTHER,
136
+ "parameter_invalid": ErrorType.OTHER,
137
+ "payment_invalid": ErrorType.OTHER,
138
+ "payment_method_not_supported": ErrorType.OTHER,
139
+ "payment_source_required": ErrorType.OTHER,
140
+ "payment_type_invalid": ErrorType.OTHER,
141
+ "processing_key_required": ErrorType.OTHER,
142
+ "processing_value_required": ErrorType.OTHER,
143
+ "recurring_plan_exists": ErrorType.OTHER,
144
+ "recurring_plan_not_exist": ErrorType.OTHER,
145
+ "recurring_plan_removal_failed": ErrorType.OTHER,
146
+ "request_invalid": ErrorType.OTHER,
147
+ "request_json_invalid": ErrorType.OTHER,
148
+ "risk_enabled_required": ErrorType.OTHER,
149
+ "server_api_not_allowed": ErrorType.OTHER,
150
+ "source_email_invalid": ErrorType.OTHER,
151
+ "source_email_required": ErrorType.OTHER,
152
+ "source_id_invalid": ErrorType.OTHER,
153
+ "source_id_or_email_required": ErrorType.OTHER,
154
+ "source_id_required": ErrorType.OTHER,
155
+ "source_id_unknown": ErrorType.OTHER,
156
+ "source_invalid": ErrorType.OTHER,
157
+ "source_or_destination_required": ErrorType.OTHER,
158
+ "source_token_invalid": ErrorType.OTHER,
159
+ "source_token_required": ErrorType.OTHER,
160
+ "source_token_type_required": ErrorType.OTHER,
161
+ "source_token_type_invalid": ErrorType.OTHER,
162
+ "source_type_required": ErrorType.OTHER,
163
+ "sub_entities_count_invalid": ErrorType.OTHER,
164
+ "success_url_invalid": ErrorType.OTHER,
165
+ "token_required": ErrorType.OTHER,
166
+ "token_type_required": ErrorType.OTHER,
167
+ "void_amount_invalid": ErrorType.OTHER,
168
+ "refund_amount_exceeds_balance": ErrorType.REFUND_AMOUNT_EXCEEDS_BALANCE,
169
+ "refund_authorization_declined": ErrorType.REFUND_DECLINED
170
+ }
171
+
172
+
173
+ class CheckoutClient:
174
+ def __init__(self, private_key: str, processing_channel: str, is_test: bool, bt_api_key: str):
175
+ self.api_key = private_key
176
+ self.processing_channel = processing_channel
177
+ self.base_url = "https://api.sandbox.checkout.com" if is_test else "https://api.checkout.com"
178
+ self.request_client = RequestClient(bt_api_key)
179
+
180
+ def _get_status_code(self, checkout_status: Optional[str]) -> TransactionStatusCode:
181
+ """Map Checkout.com status to our status code."""
182
+ if not checkout_status:
183
+ return TransactionStatusCode.DECLINED
184
+ return STATUS_CODE_MAPPING.get(checkout_status, TransactionStatusCode.DECLINED)
185
+
186
+ def _transform_to_checkout_payload(self, request: TransactionRequest) -> Dict[str, Any]:
187
+ """Transform SDK request to Checkout.com payload format."""
188
+
189
+ payload: Dict[str, Any] = {
190
+ "amount": request.amount.value,
191
+ "currency": request.amount.currency,
192
+ "merchant_initiated": request.merchant_initiated,
193
+ "processing_channel_id": self.processing_channel,
194
+ "reference": request.reference
195
+ }
196
+
197
+ if request.metadata:
198
+ payload["metadata"] = request.metadata
199
+
200
+ if request.type:
201
+ payload["payment_type"] = RECURRING_TYPE_MAPPING.get(request.type)
202
+
203
+ if request. previous_network_transaction_id:
204
+ payload["previous_payment_id"] = request. previous_network_transaction_id
205
+ # Process source based on type
206
+ if request.source.type == SourceType.PROCESSOR_TOKEN:
207
+ payload["source"] = {
208
+ "type": "id",
209
+ "id": request.source.id
210
+ }
211
+ elif request.source.type in [SourceType.BASIS_THEORY_TOKEN, SourceType.BASIS_THEORY_TOKEN_INTENT]:
212
+ # Add card data with Basis Theory expressions
213
+ token_prefix = "token_intent" if request.source.type == SourceType.BASIS_THEORY_TOKEN_INTENT else "token"
214
+ source_data: Dict[str, Any] = {
215
+ "type": "card",
216
+ "number": f"{{{{ {token_prefix}: {request.source.id} | json: '$.data.number'}}}}",
217
+ "expiry_month": f"{{{{ {token_prefix}: {request.source.id} | json: '$.data.expiration_month'}}}}",
218
+ "expiry_year": f"{{{{ {token_prefix}: {request.source.id} | json: '$.data.expiration_year'}}}}",
219
+ "cvv": f"{{{{ {token_prefix}: {request.source.id} | json: '$.data.cvc'}}}}",
220
+ "store_for_future_use": request.source.store_with_provider
221
+ }
222
+ payload["source"] = source_data
223
+
224
+ # Add customer information if provided
225
+ if request.customer:
226
+ customer_data: Dict[str, Any] = {}
227
+ if request.customer.first_name or request.customer.last_name:
228
+ name_parts = []
229
+ if request.customer.first_name:
230
+ name_parts.append(request.customer.first_name)
231
+ if request.customer.last_name:
232
+ name_parts.append(request.customer.last_name)
233
+ customer_data["name"] = " ".join(name_parts)
234
+
235
+ if request.customer.email:
236
+ customer_data["email"] = request.customer.email
237
+
238
+ payload["customer"] = customer_data
239
+
240
+ # Add billing address if provided
241
+ if request.customer.address and "source" in payload:
242
+ billing_address: Dict[str, str] = {}
243
+ if request.customer.address.address_line1:
244
+ billing_address["address_line1"] = request.customer.address.address_line1
245
+ if request.customer.address.address_line2:
246
+ billing_address["address_line2"] = request.customer.address.address_line2
247
+ if request.customer.address.city:
248
+ billing_address["city"] = request.customer.address.city
249
+ if request.customer.address.state:
250
+ billing_address["state"] = request.customer.address.state
251
+ if request.customer.address.zip:
252
+ billing_address["zip"] = request.customer.address.zip
253
+ if request.customer.address.country:
254
+ billing_address["country"] = request.customer.address.country
255
+
256
+ source = cast(Dict[str, Any], payload["source"])
257
+ source["billing_address"] = billing_address
258
+
259
+ # Add statement descriptor if provided
260
+ if request.statement_description and "source" in payload:
261
+ source = cast(Dict[str, Any], payload["source"])
262
+ billing_descriptor: Dict[str, str] = {}
263
+ if request.statement_description.name:
264
+ billing_descriptor["name"] = request.statement_description.name
265
+ if request.statement_description.city:
266
+ billing_descriptor["city"] = request.statement_description.city
267
+ source["billing_descriptor"] = billing_descriptor
268
+
269
+ # Add 3DS information if provided
270
+ if request.three_ds:
271
+ three_ds_data: Dict[str, str] = {}
272
+ if request.three_ds.eci:
273
+ three_ds_data["eci"] = request.three_ds.eci
274
+ if request.three_ds.authentication_value:
275
+ three_ds_data["cryptogram"] = request.three_ds.authentication_value
276
+ if request.three_ds.xid:
277
+ three_ds_data["xid"] = request.three_ds.xid
278
+ if request.three_ds.version:
279
+ three_ds_data["version"] = request.three_ds.version
280
+ payload["3ds"] = three_ds_data
281
+
282
+ # Override/merge any provider properties if specified
283
+ if request.override_provider_properties:
284
+ payload = always_merger.merge(payload, request.override_provider_properties)
285
+
286
+ print(f"Payload: {json.dumps(payload, indent=2)}")
287
+
288
+ return payload
289
+
290
+ def _transform_checkout_response(self, response_data: Dict[str, Any], request: TransactionRequest) -> TransactionResponse:
291
+ """Transform Checkout.com response to our standardized format."""
292
+ return TransactionResponse(
293
+ id=str(response_data.get("id")),
294
+ reference=str(response_data.get("reference")),
295
+ amount=Amount(
296
+ value=int(str(response_data.get("amount"))),
297
+ currency=str(response_data.get("currency"))
298
+ ),
299
+ status=TransactionStatus(
300
+ code=self._get_status_code(response_data.get("status")),
301
+ provider_code=str(response_data.get("status"))
302
+ ),
303
+ source=TransactionSource(
304
+ type=request.source.type,
305
+ id=request.source.id,
306
+ provisioned=ProvisionedSource(
307
+ id=str(response_data.get("source", {}).get("id"))
308
+ ) if response_data.get("source", {}).get("id") else None
309
+ ),
310
+ full_provider_response=response_data,
311
+ created_at=datetime.fromisoformat(response_data["processed_on"].split(".")[0] + "+00:00") if response_data.get("processed_on") else datetime.now(timezone.utc),
312
+ network_transaction_id=str(response_data.get("processing", {}).get("acquirer_transaction_id"))
313
+ )
314
+
315
+ def _get_error_code(self, error: ErrorType) -> Dict[str, Any]:
316
+ return {
317
+ "category": error.category,
318
+ "code": error.code
319
+ }
320
+
321
+ def _get_error_code_object(self, error: ErrorType) -> ErrorCode:
322
+ return ErrorCode(
323
+ category=error.category,
324
+ code=error.code
325
+ )
326
+
327
+ def _transform_error_response_object(self, response, error_data=None) -> ErrorResponse:
328
+ """Transform error response from Checkout.com to SDK format."""
329
+ error_codes = []
330
+
331
+ if response.status_code == 401:
332
+ error_codes.append(self._get_error_code_object(ErrorType.INVALID_API_KEY))
333
+ elif response.status_code == 403:
334
+ error_codes.append(self._get_error_code_object(ErrorType.UNAUTHORIZED))
335
+ elif error_data is not None:
336
+ for error_code in error_data.get('error_codes', []):
337
+ mapped_error = ERROR_CODE_MAPPING.get(error_code, ErrorType.OTHER)
338
+ error_codes.append(self._get_error_code_object(mapped_error))
339
+
340
+ if not error_codes:
341
+ error_codes.append(self._get_error_code_object(ErrorType.OTHER))
342
+ else:
343
+ error_codes.append(self._get_error_code_object(ErrorType.OTHER))
344
+
345
+ return ErrorResponse(
346
+ error_codes=error_codes,
347
+ provider_errors=error_data.get('error_codes', []) if error_data else [],
348
+ full_provider_response=error_data
349
+ )
350
+
351
+
352
+ async def create_transaction(self, request_data: TransactionRequest) -> TransactionResponse:
353
+ """Process a payment transaction through Checkout.com's API directly or via Basis Theory's proxy."""
354
+ validate_required_fields(request_data)
355
+ # Transform request to Checkout.com format
356
+ payload = self._transform_to_checkout_payload(request_data)
357
+
358
+ # Set up common headers
359
+ headers = {
360
+ "Authorization": f"Bearer {self.api_key}",
361
+ "Content-Type": "application/json"
362
+ }
363
+
364
+ try:
365
+ # Make request to Checkout.com
366
+ response = self.request_client.request(
367
+ url=f"{self.base_url}/payments",
368
+ method="POST",
369
+ headers=headers,
370
+ data=payload,
371
+ use_bt_proxy=request_data.source.type != SourceType.PROCESSOR_TOKEN
372
+ )
373
+ except requests.exceptions.HTTPError as e:
374
+ # Check if this is a BT error
375
+ if hasattr(e, 'bt_error_response'):
376
+ return e.bt_error_response
377
+
378
+ try:
379
+ error_data = e.response.json()
380
+ except:
381
+ error_data = None
382
+
383
+ raise TransactionError(self._transform_error_response_object(e.response, error_data))
384
+
385
+ # Transform response to SDK format
386
+ return self._transform_checkout_response(response.json(), request_data)
387
+
388
+ async def refund_transaction(self, refund_request: RefundRequest) -> RefundResponse:
389
+ """
390
+ Refund a payment transaction through Checkout.com's API.
391
+
392
+ Args:
393
+ refund_request (RefundRequest)
394
+ Returns:
395
+ Union[RefundResponse, ErrorResponse]: The refund response or error response
396
+ """
397
+ # Set up headers
398
+ headers = {
399
+ "Authorization": f"Bearer {self.api_key}",
400
+ "Content-Type": "application/json"
401
+ }
402
+
403
+ # Prepare the refund payload
404
+ payload = {
405
+ "reference": refund_request.reference,
406
+ "amount": refund_request.amount.value,
407
+ "currency": refund_request.amount.currency
408
+ }
409
+
410
+ try:
411
+ # Make request to Checkout.com
412
+ response = self.request_client.request(
413
+ url=f"{self.base_url}/payments/{refund_request.original_transaction_id}/refunds",
414
+ method="POST",
415
+ headers=headers,
416
+ data=payload,
417
+ use_bt_proxy=False # Refunds don't need BT proxy
418
+ )
419
+
420
+ response_data = response.json()
421
+
422
+ # Transform the response to a standardized format
423
+ return RefundResponse(
424
+ id=response_data.get('action_id'),
425
+ reference=response_data.get('reference'),
426
+ amount=Amount(value=response_data.get('amount'), currency=response_data.get('currency')),
427
+ status=TransactionStatus(code=TransactionStatusCode.RECEIVED, provider_code=""),
428
+ full_provider_response=response_data,
429
+ created_at=datetime.now(timezone.utc),
430
+ refunded_transaction_id=refund_request.original_transaction_id
431
+ )
432
+
433
+ except requests.exceptions.HTTPError as e:
434
+ try:
435
+ error_data = e.response.json()
436
+ except:
437
+ error_data = None
438
+
439
+ raise TransactionError(self._transform_error_response_object(e.response, error_data))
440
+
@@ -0,0 +1,4 @@
1
+ from .request_client import RequestClient
2
+ from .model_utils import create_transaction_request
3
+
4
+ __all__ = ['RequestClient', 'create_transaction_request']
@@ -0,0 +1,94 @@
1
+ from typing import Dict, Any, Optional
2
+ from ..models import (
3
+ TransactionRequest,
4
+ Amount,
5
+ Source,
6
+ SourceType,
7
+ Customer,
8
+ Address,
9
+ StatementDescription,
10
+ ThreeDS,
11
+ RecurringType
12
+ )
13
+ from ..exceptions import ValidationError
14
+
15
+
16
+ def validate_required_fields(data: TransactionRequest) -> None:
17
+ """
18
+ Validate required fields in a transaction request.
19
+
20
+ Args:
21
+ data: TransactionRequest containing transaction request data
22
+
23
+ Raises:
24
+ ValidationError: If required fields are missing
25
+ """
26
+ if not data.amount or not data.amount.value:
27
+ raise ValidationError("amount.value is required")
28
+ if not data.source or not data.source.type or not data.source.id:
29
+ raise ValidationError("source.type and source.id are required")
30
+
31
+
32
+ def create_transaction_request(data: Dict[str, Any]) -> TransactionRequest:
33
+ """
34
+ Convert a dictionary into a TransactionRequest model.
35
+
36
+ Args:
37
+ data: Dictionary containing transaction request data
38
+
39
+ Returns:
40
+ TransactionRequest: A fully populated TransactionRequest object
41
+
42
+ Raises:
43
+ ValidationError: If required fields are missing
44
+ """
45
+ return TransactionRequest(
46
+ amount=Amount(
47
+ value=data.get('amount', {}).get('value', 0),
48
+ currency=data.get('amount', {}).get('currency', 'USD')
49
+ ),
50
+ source=Source(
51
+ type=SourceType(data.get('source', {}).get('type', '')),
52
+ id=data.get('source', {}).get('id', ''),
53
+ store_with_provider=data.get('source', {}).get('store_with_provider', False),
54
+ holder_name=data.get('source', {}).get('holder_name', '')
55
+ ),
56
+ reference=data.get('reference'),
57
+ merchant_initiated=data.get('merchant_initiated', False),
58
+ type=RecurringType(data.get('type', '')) if 'type' in data else None,
59
+ customer=_create_customer(data.get('customer')) if 'customer' in data else None,
60
+ statement_description=StatementDescription(**data.get('statement_description', {}))
61
+ if 'statement_description' in data else None,
62
+ three_ds=_create_three_ds(data.get('3ds')) if '3ds' in data else None,
63
+ override_provider_properties=data.get('override_provider_properties')
64
+ )
65
+
66
+
67
+ def _create_customer(data: Optional[Dict[str, Any]]) -> Optional[Customer]:
68
+ """Create a Customer model from dictionary data."""
69
+ if not data:
70
+ return None
71
+
72
+ return Customer(
73
+ reference=data.get('reference'),
74
+ first_name=data.get('first_name'),
75
+ last_name=data.get('last_name'),
76
+ email=data.get('email'),
77
+ address=_create_address(data.get('address'))
78
+ )
79
+
80
+
81
+ def _create_address(data: Optional[Dict[str, Any]]) -> Optional[Address]:
82
+ """Create an Address model from dictionary data."""
83
+ if not data:
84
+ return None
85
+
86
+ return Address(**data)
87
+
88
+
89
+ def _create_three_ds(data: Optional[Dict[str, Any]]) -> Optional[ThreeDS]:
90
+ """Create a ThreeDS model from dictionary data."""
91
+ if not data:
92
+ return None
93
+
94
+ return ThreeDS(**{k.lower(): v for k, v in data.items()})
@@ -0,0 +1,89 @@
1
+ from typing import Dict, Any, Optional, Union, List, cast
2
+ import requests
3
+ from requests.models import Response
4
+ from ..models import ErrorType, ErrorCode, ErrorResponse
5
+ from connections_sdk.exceptions import BasisTheoryError
6
+
7
+ class RequestClient:
8
+ def __init__(self, bt_api_key: str) -> None:
9
+ self.bt_api_key = bt_api_key
10
+
11
+ def _is_bt_error(self, response: Response) -> bool:
12
+ """Check if the error is from BasisTheory by comparing status codes."""
13
+ bt_status = response.headers.get('BT-PROXY-DESTINATION-STATUS')
14
+ return bt_status is None or str(response.status_code) != bt_status
15
+
16
+ def _transform_bt_error(self, response: Response) -> ErrorResponse:
17
+ """Transform BasisTheory error response to standardized format."""
18
+ error_type = ErrorType.BT_UNEXPECTED # Default error type
19
+
20
+ if response.status_code == 401:
21
+ error_type = ErrorType.BT_UNAUTHENTICATED
22
+ elif response.status_code == 403:
23
+ error_type = ErrorType.BT_UNAUTHORIZED
24
+ elif response.status_code < 500:
25
+ error_type = ErrorType.BT_REQUEST_ERROR
26
+
27
+ try:
28
+ response_data = response.json()
29
+ except:
30
+ response_data = {"message": response.text or "Unknown error"}
31
+
32
+ provider_errors: List[str] = []
33
+ proxy_error = response_data.get("proxy_error", {})
34
+ errors = proxy_error.get("errors", {}) if isinstance(proxy_error, dict) else {}
35
+ for key, value in errors.items():
36
+ if isinstance(key, str):
37
+ provider_errors.append(value)
38
+
39
+ return ErrorResponse(
40
+ error_codes=[
41
+ ErrorCode(
42
+ category=error_type.category,
43
+ code=error_type.code
44
+ )
45
+ ],
46
+ provider_errors=provider_errors,
47
+ full_provider_response=response_data
48
+ )
49
+
50
+ def request(
51
+ self,
52
+ url: str,
53
+ method: str = "GET",
54
+ headers: Optional[Dict[str, str]] = None,
55
+ data: Optional[Dict[str, Any]] = None,
56
+ use_bt_proxy: bool = False
57
+ ) -> Response:
58
+ """Make an HTTP request, optionally through the BasisTheory proxy."""
59
+ request_headers = headers.copy() if headers else {}
60
+
61
+ if use_bt_proxy:
62
+ # Add BT API key and proxy headers
63
+ request_headers["BT-API-KEY"] = self.bt_api_key
64
+ # Add proxy header only if not already present
65
+ if "BT-PROXY-URL" not in request_headers:
66
+ request_headers["BT-PROXY-URL"] = url
67
+ # Use the BT proxy endpoint
68
+ request_url = "https://api.basistheory.com/proxy"
69
+ else:
70
+ request_url = url
71
+
72
+ # Make the request
73
+ response = requests.request(
74
+ method=method,
75
+ url=request_url,
76
+ headers=request_headers,
77
+ json=data
78
+ )
79
+
80
+ # Check for BT errors first
81
+ if use_bt_proxy and not response.ok and self._is_bt_error(response):
82
+ error_response = self._transform_bt_error(response)
83
+
84
+ raise BasisTheoryError(error_response, response.status_code)
85
+
86
+ # Raise for other HTTP errors
87
+ response.raise_for_status()
88
+
89
+ return response