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,405 @@
1
+ from typing import Dict, Any, Tuple, Optional, Union, cast
2
+ from datetime import datetime, timezone
3
+ import requests
4
+ from deepmerge import always_merger
5
+ from ..models import (
6
+ TransactionRequest,
7
+ Amount,
8
+ Source,
9
+ SourceType,
10
+ Customer,
11
+ Address,
12
+ StatementDescription,
13
+ ThreeDS,
14
+ RecurringType,
15
+ TransactionStatusCode,
16
+ ErrorType,
17
+ ErrorCategory,
18
+ RefundRequest,
19
+ RefundResponse,
20
+ TransactionStatus,
21
+ ErrorResponse,
22
+ ErrorCode,
23
+ TransactionResponse,
24
+ TransactionSource,
25
+ ProvisionedSource
26
+ )
27
+ from ..utils.model_utils import create_transaction_request, validate_required_fields
28
+ from ..utils.request_client import RequestClient
29
+ from ..exceptions import TransactionError
30
+
31
+
32
+ RECURRING_TYPE_MAPPING = {
33
+ RecurringType.ONE_TIME: None,
34
+ RecurringType.CARD_ON_FILE: "CardOnFile",
35
+ RecurringType.SUBSCRIPTION: "Subscription",
36
+ RecurringType.UNSCHEDULED: "UnscheduledCardOnFile"
37
+ }
38
+
39
+
40
+ # Map Adyen resultCode to our status codes
41
+ STATUS_CODE_MAPPING = {
42
+ "Authorised": TransactionStatusCode.AUTHORIZED, # Adyen: Authorised - Payment was successfully authorized
43
+ "Pending": TransactionStatusCode.PENDING, # Adyen: Pending - Payment is pending, waiting for completion
44
+ "Error": TransactionStatusCode.DECLINED, # Adyen: Error - Technical error occurred
45
+ "Refused": TransactionStatusCode.DECLINED, # Adyen: Refused - Payment was refused
46
+ "Cancelled": TransactionStatusCode.CANCELLED, # Adyen: Cancelled - Payment was cancelled
47
+ "ChallengeShopper": TransactionStatusCode.CHALLENGE_SHOPPER, # Adyen: ChallengeShopper - 3DS2 challenge required
48
+ "Received": TransactionStatusCode.RECEIVED, # Adyen: Received - Payment was received
49
+ "PartiallyAuthorised": TransactionStatusCode.PARTIALLY_AUTHORIZED # Adyen: PartiallyAuthorised - Only part of the amount was authorized
50
+ }
51
+
52
+
53
+ # Mapping of Adyen refusal reason codes to our error types
54
+ ERROR_CODE_MAPPING = {
55
+ "2": ErrorType.REFUSED, # Refused
56
+ "3": ErrorType.REFERRAL, # Referral
57
+ "4": ErrorType.ACQUIRER_ERROR, # Acquirer Error
58
+ "5": ErrorType.BLOCKED_CARD, # Blocked Card
59
+ "6": ErrorType.EXPIRED_CARD, # Expired Card
60
+ "7": ErrorType.INVALID_AMOUNT, # Invalid Amount
61
+ "8": ErrorType.INVALID_CARD, # Invalid Card Number
62
+ "9": ErrorType.OTHER, # Issuer Unavailable
63
+ "10": ErrorType.NOT_SUPPORTED, # Not supported
64
+ "11": ErrorType.AUTHENTICATION_FAILURE, # 3D Not Authenticated
65
+ "12": ErrorType.INSUFFICENT_FUNDS, # Not enough balance
66
+ "14": ErrorType.FRAUD, # Acquirer Fraud
67
+ "15": ErrorType.PAYMENT_CANCELLED, # Cancelled
68
+ "16": ErrorType.PAYMENT_CANCELLED_BY_CONSUMER, # Shopper Cancelled
69
+ "17": ErrorType.INVALID_PIN, # Invalid Pin
70
+ "18": ErrorType.PIN_TRIES_EXCEEDED, # Pin tries exceeded
71
+ "19": ErrorType.OTHER, # Pin validation not possible
72
+ "20": ErrorType.FRAUD, # FRAUD
73
+ "21": ErrorType.OTHER, # Not Submitted
74
+ "22": ErrorType.FRAUD, # FRAUD-CANCELLED
75
+ "23": ErrorType.NOT_SUPPORTED, # Transaction Not Permitted
76
+ "24": ErrorType.CVC_INVALID, # CVC Declined
77
+ "25": ErrorType.RESTRICTED_CARD, # Restricted Card
78
+ "26": ErrorType.STOP_PAYMENT, # Revocation Of Auth
79
+ "27": ErrorType.REFUSED, # Declined Non Generic
80
+ "28": ErrorType.INSUFFICENT_FUNDS, # Withdrawal amount exceeded
81
+ "29": ErrorType.INSUFFICENT_FUNDS, # Withdrawal count exceeded
82
+ "31": ErrorType.FRAUD, # Issuer Suspected Fraud
83
+ "32": ErrorType.AVS_DECLINE, # AVS Declined
84
+ "33": ErrorType.PIN_REQUIRED, # Card requires online pin
85
+ "34": ErrorType.BANK_ERROR, # No checking account available on Card
86
+ "35": ErrorType.BANK_ERROR, # No savings account available on Card
87
+ "36": ErrorType.PIN_REQUIRED, # Mobile pin required
88
+ "37": ErrorType.CONTACTLESS_FALLBACK, # Contactless fallback
89
+ "38": ErrorType.AUTHENTICATION_REQUIRED, # Authentication required
90
+ "39": ErrorType.AUTHENTICATION_FAILURE, # RReq not received from DS
91
+ "40": ErrorType.OTHER, # Current AID is in Penalty Box
92
+ "41": ErrorType.PIN_REQUIRED, # CVM Required Restart Payment
93
+ "42": ErrorType.AUTHENTICATION_FAILURE, # 3DS Authentication Error
94
+ "43": ErrorType.PIN_REQUIRED, # Online PIN required
95
+ "44": ErrorType.OTHER, # Try another interface
96
+ "45": ErrorType.OTHER, # Chip downgrade mode
97
+ "46": ErrorType.PROCESSOR_BLOCKED, # Transaction blocked by Adyen to prevent excessive retry fees
98
+ }
99
+
100
+
101
+ class AdyenClient:
102
+ def __init__(self, api_key: str, merchant_account: str, is_test: bool, bt_api_key: str, production_prefix: str):
103
+ self.api_key = api_key
104
+ self.merchant_account = merchant_account
105
+ self.base_url = "https://checkout-test.adyen.com/v71" if is_test else f"https://{production_prefix}-checkout-live.adyenpayments.com/checkout/v71"
106
+ self.request_client = RequestClient(bt_api_key)
107
+
108
+ def _get_status_code(self, adyen_result_code: Optional[str]) -> TransactionStatusCode:
109
+ """Map Adyen result code to our status code."""
110
+ if not adyen_result_code:
111
+ return TransactionStatusCode.DECLINED
112
+ return STATUS_CODE_MAPPING.get(adyen_result_code, TransactionStatusCode.DECLINED)
113
+
114
+ def _transform_to_adyen_payload(self, request: TransactionRequest) -> Dict[str, Any]:
115
+ """Transform SDK request to Adyen payload format."""
116
+ payload: Dict[str, Any] = {
117
+ "amount": {
118
+ "value": request.amount.value,
119
+ "currency": request.amount.currency
120
+ },
121
+ "merchantAccount": self.merchant_account,
122
+ "shopperInteraction": "ContAuth" if request.merchant_initiated else "Ecommerce",
123
+ "storePaymentMethod": request.source.store_with_provider,
124
+ }
125
+
126
+ if request.metadata:
127
+ payload["metadata"] = request.metadata
128
+
129
+ # Add reference if provided
130
+ if request.reference:
131
+ payload["reference"] = request.reference
132
+
133
+ # Add recurring type if provided
134
+ if request.type:
135
+ recurring_type = RECURRING_TYPE_MAPPING.get(request.type)
136
+ if recurring_type:
137
+ payload["recurringProcessingModel"] = recurring_type
138
+
139
+ # Process source based on type
140
+ payment_method: Dict[str, Any] = {"type": "scheme"}
141
+
142
+ if request.source.type == SourceType.PROCESSOR_TOKEN:
143
+ payment_method["storedPaymentMethodId"] = request.source.id
144
+ elif request.source.type in [SourceType.BASIS_THEORY_TOKEN, SourceType.BASIS_THEORY_TOKEN_INTENT]:
145
+ # Add card data with Basis Theory expressions
146
+ token_prefix = "token_intent" if request.source.type == SourceType.BASIS_THEORY_TOKEN_INTENT else "token"
147
+ payment_method.update({
148
+ "number": f"{{{{ {token_prefix}: {request.source.id} | json: '$.data.number'}}}}",
149
+ "expiryMonth": f"{{{{ {token_prefix}: {request.source.id} | json: '$.data.expiration_month'}}}}",
150
+ "expiryYear": f"{{{{ {token_prefix}: {request.source.id} | json: '$.data.expiration_year'}}}}",
151
+ "cvc": f"{{{{ {token_prefix}: {request.source.id} | json: '$.data.cvc'}}}}"
152
+ })
153
+ if request.source.holder_name:
154
+ payment_method["holderName"] = request.source.holder_name
155
+
156
+ if request.previous_network_transaction_id:
157
+ payment_method["networkPaymentReference"] = request. previous_network_transaction_id
158
+
159
+ payload["paymentMethod"] = payment_method
160
+
161
+ # Add customer information
162
+ if request.customer:
163
+ if request.customer.reference:
164
+ payload["shopperReference"] = request.customer.reference
165
+
166
+ # Map name fields
167
+ if request.customer.first_name or request.customer.last_name:
168
+ shopper_name: Dict[str, str] = {}
169
+ if request.customer.first_name:
170
+ shopper_name["firstName"] = request.customer.first_name
171
+ if request.customer.last_name:
172
+ shopper_name["lastName"] = request.customer.last_name
173
+ payload["shopperName"] = shopper_name
174
+
175
+ # Map email directly
176
+ if request.customer.email:
177
+ payload["shopperEmail"] = request.customer.email
178
+
179
+ # Map address fields
180
+ if request.customer.address:
181
+ address = request.customer.address
182
+ if any([address.address_line1, address.city, address.state, address.zip, address.country]):
183
+ billing_address: Dict[str, str] = {}
184
+
185
+ # Map address_line1 to street
186
+ if address.address_line1:
187
+ billing_address["street"] = address.address_line1
188
+
189
+ if address.city:
190
+ billing_address["city"] = address.city
191
+
192
+ if address.state:
193
+ billing_address["stateOrProvince"] = address.state
194
+
195
+ if address.zip:
196
+ billing_address["postalCode"] = address.zip
197
+
198
+ if address.country:
199
+ billing_address["country"] = address.country
200
+
201
+ payload["billingAddress"] = billing_address
202
+
203
+ # Map statement description (only name, city is not mapped as per CSV)
204
+ if request.statement_description and request.statement_description.name:
205
+ payload["shopperStatement"] = request.statement_description.name
206
+
207
+ # Map 3DS information
208
+ if request.three_ds:
209
+ three_ds_data: Dict[str, str] = {}
210
+
211
+ if request.three_ds.eci:
212
+ three_ds_data["eci"] = request.three_ds.eci
213
+
214
+ if request.three_ds.authentication_value:
215
+ three_ds_data["authenticationValue"] = request.three_ds.authentication_value
216
+
217
+ if request.three_ds.xid:
218
+ three_ds_data["xid"] = request.three_ds.xid
219
+
220
+ if request.three_ds.version:
221
+ three_ds_data["threeDSVersion"] = request.three_ds.version
222
+
223
+ if three_ds_data:
224
+ payload["additionalData"] = {"threeDSecure": three_ds_data}
225
+
226
+ # Override/merge any provider properties if specified
227
+ if request.override_provider_properties:
228
+ payload = always_merger.merge(payload, request.override_provider_properties)
229
+
230
+ return payload
231
+
232
+ def _transform_adyen_response(self, response_data: Dict[str, Any], request: TransactionRequest) -> TransactionResponse:
233
+ """Transform Adyen response to our standardized format."""
234
+ transaction_response = TransactionResponse(
235
+ id=str(response_data.get("pspReference")),
236
+ reference=str(response_data.get("merchantReference")),
237
+ amount=Amount(
238
+ value=int(response_data.get("amount", {}).get("value")),
239
+ currency=str(response_data.get("amount", {}).get("currency"))
240
+ ),
241
+ status=TransactionStatus(
242
+ code=self._get_status_code(response_data.get("resultCode")),
243
+ provider_code=str(response_data.get("resultCode"))
244
+ ),
245
+ source=TransactionSource(
246
+ type=request.source.type,
247
+ id=request.source.id,
248
+ ),
249
+ network_transaction_id=str(response_data.get("additionalData", {}).get("networkTxReference")),
250
+ full_provider_response=response_data,
251
+ created_at=datetime.now(timezone.utc)
252
+ )
253
+
254
+ # checking both as recurringDetailReference is deprecated, although it still appears without storedPaymentMethodId
255
+ stored_payment_id = response_data.get("paymentMethod", {}).get("storedPaymentMethodId")
256
+ recurring_ref = response_data.get("additionalData", {}).get("recurring.recurringDetailReference")
257
+
258
+ if stored_payment_id or recurring_ref:
259
+ transaction_response.source.provisioned = ProvisionedSource(id=stored_payment_id or recurring_ref)
260
+
261
+
262
+ return transaction_response
263
+
264
+ def _transform_error_response(self, response: requests.Response, response_data: Dict[str, Any]) -> ErrorResponse:
265
+ """Transform error responses to our standardized format.
266
+
267
+ Args:
268
+ response: The HTTP response object
269
+ response_data: The parsed JSON response data
270
+
271
+ Returns:
272
+ Dict[str, Any]: Standardized error response
273
+ """
274
+ # Map HTTP status codes to error types
275
+ if response.status_code == 401:
276
+ error_type = ErrorType.INVALID_API_KEY
277
+ elif response.status_code == 403:
278
+ error_type = ErrorType.UNAUTHORIZED
279
+ # Handle Adyen-specific error codes for declined transactions
280
+ elif response_data.get("resultCode") in ["Refused", "Error", "Cancelled"]:
281
+ refusal_code = response_data.get("refusalReasonCode", "")
282
+ error_type = ERROR_CODE_MAPPING.get(refusal_code, ErrorType.OTHER)
283
+ else:
284
+ error_type = ErrorType.OTHER
285
+
286
+ return ErrorResponse(
287
+ error_codes=[
288
+ ErrorCode(
289
+ category=error_type.category,
290
+ code=error_type.code
291
+ )
292
+ ],
293
+ provider_errors=[response_data.get("refusalReason") or response_data.get("message", "")],
294
+ full_provider_response=response_data
295
+ )
296
+
297
+
298
+ async def create_transaction(self, request_data: TransactionRequest) -> TransactionResponse:
299
+ """Process a payment transaction through Adyen's API directly or via Basis Theory's proxy."""
300
+ validate_required_fields(request_data)
301
+
302
+ # Transform to Adyen's format
303
+ payload = self._transform_to_adyen_payload(request_data)
304
+
305
+ # Set up common headers
306
+ headers = {
307
+ "X-API-Key": self.api_key,
308
+ "Content-Type": "application/json"
309
+ }
310
+
311
+ # Make the request (using proxy for BT tokens, direct for processor tokens)
312
+ try:
313
+ response = self.request_client.request(
314
+ url=f"{self.base_url}/payments",
315
+ method="POST",
316
+ headers=headers,
317
+ data=payload,
318
+ use_bt_proxy=request_data.source.type != SourceType.PROCESSOR_TOKEN
319
+ )
320
+
321
+ response_data = response.json()
322
+
323
+ # Check if it's an error response (non-200 status code or Adyen error)
324
+ if not response.ok or response_data.get("resultCode") in ["Refused", "Error", "Cancelled"]:
325
+ raise TransactionError(self._transform_error_response(response, response_data))
326
+
327
+ # Transform the successful response to our format
328
+ return self._transform_adyen_response(response_data, request_data)
329
+
330
+ except requests.exceptions.HTTPError as e:
331
+ try:
332
+ error_data = e.response.json()
333
+ except:
334
+ error_data = None
335
+
336
+ raise TransactionError(self._transform_error_response(e.response, error_data))
337
+
338
+
339
+ async def refund_transaction(self, refund_request: RefundRequest) -> RefundResponse:
340
+ """
341
+ Refund a payment transaction through Adyen's API.
342
+
343
+ Args:
344
+ refund_request (RefundRequest): The refund request details
345
+
346
+ Returns:
347
+ RefundResponse: The refund response
348
+ """
349
+ # Set up headers
350
+ headers = {
351
+ "X-API-Key": self.api_key,
352
+ "Content-Type": "application/json"
353
+ }
354
+
355
+ # Prepare the refund payload
356
+ payload = {
357
+ "merchantAccount": self.merchant_account,
358
+ "reference": refund_request.reference,
359
+ "amount": {
360
+ "value": refund_request.amount.value,
361
+ "currency": refund_request.amount.currency
362
+ }
363
+ }
364
+
365
+ # Add refund reason if provided
366
+ if refund_request.reason:
367
+ payload["merchantRefundReason"] = refund_request.reason
368
+
369
+ try:
370
+ # Make request to Adyen
371
+ response = self.request_client.request(
372
+ url=f"{self.base_url}/payments/{refund_request.original_transaction_id}/refunds",
373
+ method="POST",
374
+ headers=headers,
375
+ data=payload,
376
+ use_bt_proxy=False # Refunds don't need BT proxy
377
+ )
378
+
379
+ response_data = response.json()
380
+
381
+ # Transform the response to a standardized format
382
+ return RefundResponse(
383
+ id=response_data.get('pspReference'),
384
+ reference=response_data.get('reference'),
385
+ amount=Amount(
386
+ value=response_data.get('amount', {}).get('value'),
387
+ currency=response_data.get('amount', {}).get('currency')
388
+ ),
389
+ status=TransactionStatus(
390
+ code=TransactionStatusCode.RECEIVED,
391
+ provider_code=response_data.get('status')
392
+ ),
393
+ refunded_transaction_id=response_data.get('paymentPspReference'),
394
+ full_provider_response=response_data,
395
+ created_at=datetime.now(timezone.utc)
396
+ )
397
+
398
+ except requests.exceptions.HTTPError as e:
399
+ try:
400
+ error_data = e.response.json()
401
+ except:
402
+ error_data = None
403
+
404
+ raise TransactionError(self._transform_error_response(e.response, error_data))
405
+