diff --git a/easyswitch/integrators/klarna.py b/easyswitch/integrators/klarna.py new file mode 100644 index 0000000..141bb14 --- /dev/null +++ b/easyswitch/integrators/klarna.py @@ -0,0 +1,294 @@ +""" +EasySwitch - Klarna Adapter +""" + +import hmac +import hashlib +import json +import base64 +from typing import ClassVar, List, Dict, Optional, Any +from datetime import datetime + +from easyswitch.adapters.base import IntegratorRegistry, BaseIntegrator +from easyswitch.types import ( + Currency, + PaymentResponse, + WebhookEvent, + TransactionDetail, + TransactionStatusResponse, + CustomerInfo, + TransactionStatus, +) +from easyswitch.exceptions import PaymentError + + +@IntegratorRegistry.register() +class KlarnaAdapter(BaseIntegrator): + """Klarna Payment Adapter 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 from extra config.""" + extra = getattr(self.config, "extra", {}) or {} + return bool(extra.get("api_username") and extra.get("api_key")) + + def get_credentials(self) -> Dict[str, str]: + """Return Klarna API credentials from extra config.""" + extra = getattr(self.config, "extra", {}) or {} + return { + "api_username": extra.get("api_username"), + "api_key": extra.get("api_key"), + } + + async def get_headers(self, **kwargs) -> Dict[str, str]: + """Return authorization headers for Klarna.""" + creds = self.get_credentials() + credentials = f"{creds['api_username']}:{creds['api_key']}" + 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 webhook_secret is configured.""" + signature = headers.get("klarna-signature") + secret = getattr(self.config, "extra", {}).get("webhook_secret") + if not signature or not secret: + return True # Optional verification + + 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") + + order_id = payload.get("order_id") + status = self.get_normalize_status(payload.get("status", "UNKNOWN")) + + return WebhookEvent( + event_type=payload.get("event_type", "payment.update"), + provider=self.provider_name(), + transaction_id=order_id, + status=status, + amount=float(payload.get("amount", 0)), + currency=payload.get("currency", "EUR"), + 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 or not customer.email: + raise PaymentError("Customer email is required for Klarna payment") + + callback_url = ( + transaction.callback_url + or getattr(self.config, "callback_url", None) + or getattr(self.config.extra, "callback_url", "https://example.com/callback") + ) + + 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": [ + { + "name": "EasySwitch Payment", + "quantity": 1, + "unit_price": int(transaction.amount * 100), + "total_amount": int(transaction.amount * 100), + } + ], + "merchant_urls": { + "confirmation": callback_url, + "notification": callback_url, + }, + } + + 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_data=payload, headers=headers) + data = getattr(response, "json", lambda: 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, + 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 = getattr(response, "json", lambda: 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_data=refund_data, + headers=headers, + ) + data = getattr(response, "json", lambda: 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, + 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) -> bool: + """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 in range(200, 300): + return True + + 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 = getattr(response, "json", lambda: 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, + ) diff --git a/tests/test_klarna.py b/tests/test_klarna.py new file mode 100644 index 0000000..4093224 --- /dev/null +++ b/tests/test_klarna.py @@ -0,0 +1,290 @@ +import base64 +import hmac +import hashlib +import json +from datetime import datetime +from unittest.mock import AsyncMock, patch +import pytest + +from easyswitch.integrators.klarna import KlarnaIntegrator +from easyswitch.types import ( + Currency, + CustomerInfo, + TransactionDetail, + TransactionStatus, +) +from easyswitch.exceptions import PaymentError + + +# ------------------------- +# Fixtures +# ------------------------- + +@pytest.fixture +def klarna_config(): + """Provides default Klarna config for sandbox testing.""" + return { + "api_username": "test_user", + "api_key": "test_key", + "environment": "sandbox", + "webhook_secret": "secret123", + } + + +@pytest.fixture +def klarna_integrator(klarna_config): + """Instantiate KlarnaIntegrator with test configuration.""" + return KlarnaIntegrator(klarna_config) + + +@pytest.fixture +def sample_transaction(): + """A sample transaction object for Klarna testing.""" + return TransactionDetail( + transaction_id="txn_123", + reference="order_456", + amount=100.0, + currency=Currency.EUR, + customer=CustomerInfo( + email="user@example.com", + first_name="Jane", + last_name="Doe", + country="SE", + ), + callback_url="https://example.com/callback", + ) + + +# ------------------------- +# Credential & Auth Tests +# ------------------------- + +def test_validate_credentials(klarna_integrator): + """Validate that credentials check works properly.""" + assert klarna_integrator.validate_credentials() is True + + klarna_integrator.config.api_username = None + assert klarna_integrator.validate_credentials() is False + + +def test_get_credentials(klarna_integrator): + """Test credential dictionary.""" + creds = klarna_integrator.get_credentials() + assert creds["api_username"] == "test_user" + assert creds["api_key"] == "test_key" + + +@pytest.mark.asyncio +async def test_get_headers(klarna_integrator): + """Ensure headers include proper Base64 encoded auth.""" + headers = await klarna_integrator.get_headers() + assert "Authorization" in headers + decoded = base64.b64decode(headers["Authorization"].split()[1]).decode() + assert decoded == "test_user:test_key" + assert headers["Content-Type"] == "application/json" + + +# ------------------------- +# Status Normalization Tests +# ------------------------- + +def test_get_normalize_status(klarna_integrator): + """Test mapping of Klarna payment statuses.""" + assert klarna_integrator.get_normalize_status("AUTHORIZED") == TransactionStatus.PENDING + assert klarna_integrator.get_normalize_status("CAPTURED") == TransactionStatus.SUCCESSFUL + assert klarna_integrator.get_normalize_status("REFUNDED") == TransactionStatus.REFUNDED + assert klarna_integrator.get_normalize_status("FAILED") == TransactionStatus.FAILED + assert klarna_integrator.get_normalize_status("XYZ") == TransactionStatus.UNKNOWN + + +# ------------------------- +# Webhook Tests +# ------------------------- + +def test_validate_webhook_valid(klarna_integrator): + """Validate correct webhook signature.""" + payload = {"event": "payment.update", "amount": 100} + raw_body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() + signature = hmac.new( + klarna_integrator.config.webhook_secret.encode(), + raw_body, + hashlib.sha256 + ).hexdigest() + headers = {"klarna-signature": signature} + assert klarna_integrator.validate_webhook(raw_body, headers) is True + + +def test_validate_webhook_invalid(klarna_integrator): + """Fail validation for wrong signature.""" + payload = {"event": "payment.update"} + raw_body = json.dumps(payload).encode() + headers = {"klarna-signature": "invalid"} + assert klarna_integrator.validate_webhook(raw_body, headers) is False + + +def test_parse_webhook_valid(klarna_integrator): + """Parse valid Klarna webhook payload.""" + payload = { + "event_type": "payment.update", + "order_id": "ord_001", + "status": "CAPTURED", + "amount": 5000, + "currency": "EUR", + } + raw_body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() + sig = hmac.new( + klarna_integrator.config.webhook_secret.encode(), + raw_body, + hashlib.sha256 + ).hexdigest() + + headers = {"klarna-signature": sig} + event = klarna_integrator.parse_webhook(payload, headers) + assert event.transaction_id == "ord_001" + assert event.status == TransactionStatus.SUCCESSFUL + assert event.amount == 5000 + assert event.provider == "klarna" + + +def test_parse_webhook_invalid_signature(klarna_integrator): + """Ensure invalid signature raises PaymentError.""" + payload = {"order_id": "ord_002", "status": "FAILED"} + headers = {"klarna-signature": "wrong"} + with pytest.raises(PaymentError): + klarna_integrator.parse_webhook(payload, headers) + + +# ------------------------- +# Transaction Formatting +# ------------------------- + +def test_format_transaction_valid(klarna_integrator, sample_transaction): + """Ensure valid transaction formatting for Klarna API.""" + formatted = klarna_integrator.format_transaction(sample_transaction) + assert formatted["purchase_country"] == "SE" + assert formatted["order_amount"] == 10000 # 100 * 100 + assert "merchant_urls" in formatted + assert formatted["merchant_urls"]["confirmation"].startswith("https://") + + +def test_format_transaction_missing_email(klarna_integrator, sample_transaction): + """Email is required for Klarna payments.""" + sample_transaction.customer.email = None + with pytest.raises(PaymentError): + klarna_integrator.format_transaction(sample_transaction) + + +# ------------------------- +# Payment & Status Flow Tests +# ------------------------- + +@pytest.mark.asyncio +async def test_send_payment_success(klarna_integrator, sample_transaction): + """Mock successful Klarna payment creation.""" + mock_response = AsyncMock(status=201) + mock_response.json.return_value = { + "session_id": "sess_123", + "redirect_url": "https://klarna.com/pay", + "client_token": "token_123", + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(klarna_integrator, "get_client", return_value=mock_client): + result = await klarna_integrator.send_payment(sample_transaction) + + assert result.transaction_id == "sess_123" + assert result.payment_link == "https://klarna.com/pay" + assert result.transaction_token == "token_123" + assert result.status == TransactionStatus.PENDING.value + + +@pytest.mark.asyncio +async def test_send_payment_failure(klarna_integrator, sample_transaction): + """Handle Klarna payment API error.""" + mock_response = AsyncMock(status=400) + mock_response.json.return_value = {"error": "Invalid request"} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(klarna_integrator, "get_client", return_value=mock_client): + with pytest.raises(PaymentError): + await klarna_integrator.send_payment(sample_transaction) + + +@pytest.mark.asyncio +async def test_check_status_success(klarna_integrator): + """Mock successful Klarna status check.""" + mock_response = AsyncMock(status=200) + mock_response.json.return_value = {"status": "CAPTURED", "order_amount": 5000, "purchase_currency": "EUR"} + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + with patch.object(klarna_integrator, "get_client", return_value=mock_client): + result = await klarna_integrator.check_status("ord_001") + + assert result.status == TransactionStatus.SUCCESSFUL + assert result.amount == 50.0 + assert result.transaction_id == "ord_001" + + +@pytest.mark.asyncio +async def test_refund_success(klarna_integrator): + """Mock successful Klarna refund.""" + mock_response = AsyncMock(status=200) + mock_response.json.return_value = {"refunded_amount": 5000, "currency": "EUR"} + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(klarna_integrator, "get_client", return_value=mock_client): + result = await klarna_integrator.refund("ord_001", amount=50.0) + + assert result.status == TransactionStatus.REFUNDED.value + assert result.amount == 50.0 + assert result.transaction_id == "ord_001" + + +@pytest.mark.asyncio +async def test_cancel_transaction_success(klarna_integrator): + """Mock successful Klarna transaction cancellation.""" + mock_response = AsyncMock(status=200) + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(klarna_integrator, "get_client", return_value=mock_client): + await klarna_integrator.cancel_transaction("ord_001") # Should not raise + + +@pytest.mark.asyncio +async def test_get_transaction_detail_success(klarna_integrator): + """Mock retrieving Klarna transaction details.""" + mock_response = AsyncMock(status=200) + mock_response.json.return_value = { + "status": "CAPTURED", + "order_amount": 5000, + "purchase_currency": "EUR", + "billing_address": { + "email": "user@example.com", + "phone": "+46700000000", + "given_name": "Jane", + "family_name": "Doe", + }, + "order_id": "ord_001", + } + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + with patch.object(klarna_integrator, "get_client", return_value=mock_client): + result = await klarna_integrator.get_transaction_detail("ord_001") + + assert result.transaction_id == "ord_001" + assert result.amount == 50.0 + assert result.currency == "EUR" + assert result.customer.email == "user@example.com" + assert result.status == TransactionStatus.SUCCESSFUL