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.
- connections_sdk/__init__.py +4 -0
- connections_sdk/client.py +106 -0
- connections_sdk/config.py +18 -0
- connections_sdk/exceptions.py +24 -0
- connections_sdk/models.py +210 -0
- connections_sdk/providers/adyen.py +405 -0
- connections_sdk/providers/checkout.py +440 -0
- connections_sdk/utils/__init__.py +4 -0
- connections_sdk/utils/model_utils.py +94 -0
- connections_sdk/utils/request_client.py +89 -0
- connections_sdk-0.1.0.dist-info/LICENSE +202 -0
- connections_sdk-0.1.0.dist-info/METADATA +91 -0
- connections_sdk-0.1.0.dist-info/RECORD +14 -0
- connections_sdk-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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,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
|