-
Notifications
You must be signed in to change notification settings - Fork 18
Add Klarna Adapter #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,288 @@ | ||
| """ | ||
| EasySwitch - Klarna Integrator | ||
| """ | ||
|
|
||
| import hmac | ||
| import hashlib | ||
| import json | ||
| from typing import ClassVar, List, Dict, Optional, Any | ||
| from datetime import datetime, timedelta | ||
|
|
||
| from easyswitch.adapters.base import IntegratorRegistry, BaseIntegrator | ||
| from easyswitch.types import ( | ||
| Currency, | ||
| PaymentResponse, | ||
| WebhookEvent, | ||
| TransactionDetail, | ||
| TransactionStatusResponse, | ||
| CustomerInfo, | ||
| TransactionStatus, | ||
| ) | ||
| from easyswitch.exceptions import PaymentError, UnsupportedOperationError | ||
|
|
||
|
|
||
| @IntegratorRegistry.register() | ||
| class KlarnaIntegrator(BaseIntegrator): | ||
| """Klarna Payment Integrator for EasySwitch SDK.""" | ||
|
|
||
| SANDBOX_URL: str = "https://api.playground.klarna.com" | ||
| PRODUCTION_URL: str = "https://api.klarna.com" | ||
|
|
||
| SUPPORTED_CURRENCIES: ClassVar[List[Currency]] = [ | ||
| Currency.EUR, | ||
| Currency.USD, | ||
| Currency.GBP, | ||
| Currency.SEK, | ||
| Currency.NOK, | ||
| Currency.DKK, | ||
| ] | ||
|
|
||
| MIN_AMOUNT: ClassVar[Dict[Currency, float]] = { | ||
| Currency.EUR: 1.0, | ||
| Currency.USD: 1.0, | ||
| Currency.GBP: 1.0, | ||
| Currency.SEK: 10.0, | ||
| Currency.NOK: 10.0, | ||
| Currency.DKK: 10.0, | ||
| } | ||
|
|
||
| MAX_AMOUNT: ClassVar[Dict[Currency, float]] = { | ||
| Currency.EUR: 100000.0, | ||
| Currency.USD: 100000.0, | ||
| Currency.GBP: 100000.0, | ||
| Currency.SEK: 1000000.0, | ||
| Currency.NOK: 1000000.0, | ||
| Currency.DKK: 1000000.0, | ||
| } | ||
|
|
||
| def validate_credentials(self) -> bool: | ||
| """Validate Klarna API credentials.""" | ||
| return bool(self.config.api_key and getattr(self.config, "api_username", None)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think |
||
|
|
||
| def get_credentials(self): | ||
| """Return Klarna API credentials.""" | ||
| return { | ||
| "api_username": getattr(self.config, "api_username", None), | ||
| "api_key": self.config.api_key, | ||
| } | ||
|
|
||
| async def get_headers(self, **kwargs) -> Dict[str, str]: | ||
| """Return authorization headers for Klarna.""" | ||
| credentials = f"{self.config.api_username}:{self.config.api_key}" | ||
| import base64 | ||
| encoded = base64.b64encode(credentials.encode()).decode() | ||
|
|
||
| return { | ||
| "Content-Type": "application/json", | ||
| "Authorization": f"Basic {encoded}", | ||
| } | ||
|
|
||
| def get_normalize_status(self, status: str) -> TransactionStatus: | ||
| """Normalize Klarna transaction statuses.""" | ||
| mapping = { | ||
| "AUTHORIZED": TransactionStatus.PENDING, | ||
| "CAPTURED": TransactionStatus.SUCCESSFUL, | ||
| "CANCELLED": TransactionStatus.CANCELLED, | ||
| "REFUNDED": TransactionStatus.REFUNDED, | ||
| "FAILED": TransactionStatus.FAILED, | ||
| } | ||
| return mapping.get(status.upper(), TransactionStatus.UNKNOWN) | ||
|
|
||
| def validate_webhook(self, raw_body: bytes, headers: Dict[str, str]) -> bool: | ||
| """Validate Klarna webhook signature (if provided).""" | ||
| # Klarna allows optional signature validation via HMAC | ||
| signature = headers.get("klarna-signature") | ||
| secret = getattr(self.config, "webhook_secret", None) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Put |
||
| if not signature or not secret: | ||
| return True # Skip if not configured | ||
|
|
||
| computed_sig = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() | ||
| return hmac.compare_digest(signature, computed_sig) | ||
|
|
||
| def parse_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> WebhookEvent: | ||
| """Parse Klarna webhook events.""" | ||
| raw_body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") | ||
|
|
||
| if not self.validate_webhook(raw_body, headers): | ||
| raise PaymentError("Invalid Klarna webhook signature") | ||
|
|
||
| event_type = payload.get("event_type", "payment.update") | ||
| order_id = payload.get("order_id") | ||
| status = self.get_normalize_status(payload.get("status", "UNKNOWN")) | ||
| amount = float(payload.get("amount", 0)) | ||
| currency = payload.get("currency", "EUR") | ||
|
|
||
| return WebhookEvent( | ||
| event_type=event_type, | ||
| provider=self.provider_name(), | ||
| transaction_id=order_id, | ||
| status=status, | ||
| amount=amount, | ||
| currency=currency, | ||
| created_at=datetime.utcnow(), | ||
| raw_data=payload, | ||
| ) | ||
|
|
||
| def format_transaction(self, transaction: TransactionDetail) -> Dict[str, Any]: | ||
| """Convert standardized TransactionDetail into Klarna API payload.""" | ||
| self.validate_transaction(transaction) | ||
|
|
||
| customer = transaction.customer | ||
| if not customer.email: | ||
| raise PaymentError("Email is required for Klarna payment") | ||
|
|
||
| return { | ||
| "purchase_country": getattr(customer, "country", "SE"), | ||
| "purchase_currency": transaction.currency, | ||
| "locale": "en-SE", | ||
| "order_amount": int(transaction.amount * 100), | ||
| "order_tax_amount": 0, | ||
| "order_lines": [ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Plase can you explain this section to me??? :-) |
||
| { | ||
| "name": "EasySwitch Payment", | ||
| "quantity": 1, | ||
| "unit_price": int(transaction.amount * 100), | ||
| "total_amount": int(transaction.amount * 100), | ||
| } | ||
| ], | ||
| "merchant_urls": { | ||
| "confirmation": transaction.callback_url or "https://example.com/confirm", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use |
||
| "notification": transaction.callback_url or "https://example.com/webhook", | ||
| }, | ||
| } | ||
|
|
||
| async def send_payment(self, transaction: TransactionDetail) -> PaymentResponse: | ||
| """Initiate a Klarna payment session.""" | ||
| payload = self.format_transaction(transaction) | ||
| headers = await self.get_headers() | ||
|
|
||
| async with self.get_client() as client: | ||
| response = await client.post("/payments/v1/sessions", json=payload, headers=headers) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use |
||
| data = response.json() if hasattr(response, "json") else response.data | ||
|
|
||
| if response.status in range(200, 300): | ||
| return PaymentResponse( | ||
| transaction_id=data.get("session_id"), | ||
| reference=transaction.reference, | ||
| provider=self.provider_name(), | ||
| status=TransactionStatus.PENDING.value, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need unnecessary |
||
| amount=transaction.amount, | ||
| currency=transaction.currency, | ||
| payment_link=data.get("redirect_url"), | ||
| transaction_token=data.get("client_token"), | ||
| metadata=data, | ||
| raw_response=data, | ||
| ) | ||
|
|
||
| raise PaymentError( | ||
| message=f"Klarna payment initiation failed with {response.status}", | ||
| status_code=response.status, | ||
| raw_response=data, | ||
| ) | ||
|
|
||
| async def check_status(self, order_id: str) -> TransactionStatusResponse: | ||
| """Check Klarna order status.""" | ||
| headers = await self.get_headers() | ||
| async with self.get_client() as client: | ||
| response = await client.get(f"/payments/v1/orders/{order_id}", headers=headers) | ||
| data = response.json() if hasattr(response, "json") else response.data | ||
|
|
||
| if response.status in range(200, 300): | ||
| status = self.get_normalize_status(data.get("status", "UNKNOWN")) | ||
| return TransactionStatusResponse( | ||
| transaction_id=order_id, | ||
| provider=self.provider_name(), | ||
| status=status, | ||
| amount=float(data.get("order_amount", 0)) / 100, | ||
| data=data, | ||
| ) | ||
|
|
||
| raise PaymentError( | ||
| message=f"Klarna status check failed: {order_id}", | ||
| status_code=response.status, | ||
| raw_response=data, | ||
| ) | ||
|
|
||
| async def refund(self, order_id: str, amount: Optional[float] = None) -> PaymentResponse: | ||
| """Issue refund through Klarna.""" | ||
| headers = await self.get_headers() | ||
| refund_data = { | ||
| "refunded_amount": int((amount or 0) * 100), | ||
| "description": "Refund via EasySwitch", | ||
| } | ||
|
|
||
| async with self.get_client() as client: | ||
| response = await client.post( | ||
| f"/payments/v1/orders/{order_id}/refunds", | ||
| json=refund_data, | ||
| headers=headers, | ||
| ) | ||
| data = response.json() if hasattr(response, "json") else response.data | ||
|
|
||
| if response.status in range(200, 300): | ||
| return PaymentResponse( | ||
| transaction_id=order_id, | ||
| reference=f"refund-{order_id}", | ||
| provider=self.provider_name(), | ||
| status=TransactionStatus.REFUNDED.value, | ||
| amount=amount or float(data.get("refunded_amount", 0)) / 100, | ||
| currency=data.get("currency", "EUR"), | ||
| metadata=data, | ||
| raw_response=data, | ||
| ) | ||
|
|
||
| raise PaymentError( | ||
| message=f"Klarna refund failed with {response.status}", | ||
| status_code=response.status, | ||
| raw_response=data, | ||
| ) | ||
|
|
||
| async def cancel_transaction(self, transaction_id: str) -> None: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| """Cancel a Klarna order.""" | ||
| headers = await self.get_headers() | ||
| async with self.get_client() as client: | ||
| response = await client.post( | ||
| f"/payments/v1/orders/{transaction_id}/cancel", headers=headers | ||
| ) | ||
| if response.status not in range(200, 300): | ||
| raise PaymentError( | ||
| message=f"Cancellation failed ({response.status})", | ||
| status_code=response.status, | ||
| ) | ||
|
|
||
| async def get_transaction_detail(self, transaction_id: str) -> TransactionDetail: | ||
| """Retrieve Klarna order details.""" | ||
| headers = await self.get_headers() | ||
| async with self.get_client() as client: | ||
| response = await client.get(f"/payments/v1/orders/{transaction_id}", headers=headers) | ||
| data = response.json() if hasattr(response, "json") else response.data | ||
|
|
||
| if response.status in range(200, 300): | ||
| customer_info = data.get("billing_address", {}) | ||
| customer = CustomerInfo( | ||
| email=customer_info.get("email"), | ||
| phone_number=customer_info.get("phone"), | ||
| first_name=customer_info.get("given_name"), | ||
| last_name=customer_info.get("family_name"), | ||
| ) | ||
|
|
||
| status = self.get_normalize_status(data.get("status", "UNKNOWN")) | ||
|
|
||
| return TransactionDetail( | ||
| transaction_id=transaction_id, | ||
| provider=self.provider_name(), | ||
| amount=float(data.get("order_amount", 0)) / 100, | ||
| currency=data.get("purchase_currency", "EUR"), | ||
| status=status, | ||
| reference=data.get("order_id") or transaction_id, | ||
| created_at=datetime.utcnow(), | ||
| customer=customer, | ||
| metadata=data, | ||
| raw_data=data, | ||
| ) | ||
|
|
||
| raise PaymentError( | ||
| message=f"Failed to retrieve Klarna transaction {transaction_id}", | ||
| status_code=response.status, | ||
| raw_response=data, | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.