From fc941aad9613efd1de594e59b917f21fb233bccf Mon Sep 17 00:00:00 2001 From: Sakshi146-eng Date: Tue, 21 Oct 2025 20:36:43 +0530 Subject: [PATCH 1/3] Add Klarna Adapter --- easyswitch/integrators/klarna.py | 288 +++++++++++++++++++++++++++++++ tests/test_klarna.py | 279 ++++++++++++++++++++++++++++++ 2 files changed, 567 insertions(+) create mode 100644 easyswitch/integrators/klarna.py create mode 100644 tests/test_klarna.py diff --git a/easyswitch/integrators/klarna.py b/easyswitch/integrators/klarna.py new file mode 100644 index 0000000..b2f929c --- /dev/null +++ b/easyswitch/integrators/klarna.py @@ -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)) + + 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) + 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": [ + { + "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", + "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) + 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, + 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: + """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, + ) diff --git a/tests/test_klarna.py b/tests/test_klarna.py new file mode 100644 index 0000000..1ccd694 --- /dev/null +++ b/tests/test_klarna.py @@ -0,0 +1,279 @@ +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(): + return { + "api_username": "test_user", + "api_key": "test_key", + "environment": "sandbox", + "webhook_secret": "secret123", + } + + +@pytest.fixture +def klarna_integrator(klarna_config): + return KlarnaIntegrator(klarna_config) + + +@pytest.fixture +def sample_transaction(): + 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", + ) + + +# ------------------------- +# Unit Tests +# ------------------------- + +def test_validate_credentials(klarna_integrator): + """Validate that credentials check works properly.""" + assert klarna_integrator.validate_credentials() is True + + # Missing username should fail + 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" + + +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 + + +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) + + +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) + + +@pytest.mark.asyncio +async def test_send_payment_success(klarna_integrator, sample_transaction): + """Mock successful Klarna payment creation.""" + mock_response = AsyncMock() + mock_response.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() + mock_response.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() + mock_response.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 # 5000 / 100 + assert result.transaction_id == "ord_001" + + +@pytest.mark.asyncio +async def test_refund_success(klarna_integrator): + """Mock successful Klarna refund.""" + mock_response = AsyncMock() + mock_response.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() + mock_response.status = 200 + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(klarna_integrator, "get_client", return_value=mock_client): + # Should not raise + await klarna_integrator.cancel_transaction("ord_001") + + +@pytest.mark.asyncio +async def test_get_transaction_detail_success(klarna_integrator): + """Mock retrieving Klarna transaction details.""" + mock_response = AsyncMock() + mock_response.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 From fc8a44eab728c1ae2ad2aabbe13005fabca6d66a Mon Sep 17 00:00:00 2001 From: Sakshi Shetty Date: Wed, 22 Oct 2025 02:58:17 +0530 Subject: [PATCH 2/3] Rename KlarnaIntegrator to KlarnaAdapter and refactor the code with all the changes mentioned --- easyswitch/integrators/klarna.py | 92 +++++++++++++++++--------------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/easyswitch/integrators/klarna.py b/easyswitch/integrators/klarna.py index b2f929c..141bb14 100644 --- a/easyswitch/integrators/klarna.py +++ b/easyswitch/integrators/klarna.py @@ -1,12 +1,13 @@ """ -EasySwitch - Klarna Integrator +EasySwitch - Klarna Adapter """ import hmac import hashlib import json +import base64 from typing import ClassVar, List, Dict, Optional, Any -from datetime import datetime, timedelta +from datetime import datetime from easyswitch.adapters.base import IntegratorRegistry, BaseIntegrator from easyswitch.types import ( @@ -18,12 +19,12 @@ CustomerInfo, TransactionStatus, ) -from easyswitch.exceptions import PaymentError, UnsupportedOperationError +from easyswitch.exceptions import PaymentError @IntegratorRegistry.register() -class KlarnaIntegrator(BaseIntegrator): - """Klarna Payment Integrator for EasySwitch SDK.""" +class KlarnaAdapter(BaseIntegrator): + """Klarna Payment Adapter for EasySwitch SDK.""" SANDBOX_URL: str = "https://api.playground.klarna.com" PRODUCTION_URL: str = "https://api.klarna.com" @@ -56,20 +57,22 @@ class KlarnaIntegrator(BaseIntegrator): } def validate_credentials(self) -> bool: - """Validate Klarna API credentials.""" - return bool(self.config.api_key and getattr(self.config, "api_username", None)) + """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): - """Return Klarna API credentials.""" + def get_credentials(self) -> Dict[str, str]: + """Return Klarna API credentials from extra config.""" + extra = getattr(self.config, "extra", {}) or {} return { - "api_username": getattr(self.config, "api_username", None), - "api_key": self.config.api_key, + "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.""" - credentials = f"{self.config.api_username}:{self.config.api_key}" - import base64 + creds = self.get_credentials() + credentials = f"{creds['api_username']}:{creds['api_key']}" encoded = base64.b64encode(credentials.encode()).decode() return { @@ -89,12 +92,11 @@ def get_normalize_status(self, status: str) -> TransactionStatus: 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 + """Validate Klarna webhook signature if webhook_secret is configured.""" signature = headers.get("klarna-signature") - secret = getattr(self.config, "webhook_secret", None) + secret = getattr(self.config, "extra", {}).get("webhook_secret") if not signature or not secret: - return True # Skip if not configured + return True # Optional verification computed_sig = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(signature, computed_sig) @@ -106,19 +108,16 @@ def parse_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> Web 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, + event_type=payload.get("event_type", "payment.update"), provider=self.provider_name(), transaction_id=order_id, status=status, - amount=amount, - currency=currency, + amount=float(payload.get("amount", 0)), + currency=payload.get("currency", "EUR"), created_at=datetime.utcnow(), raw_data=payload, ) @@ -126,10 +125,16 @@ def parse_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> Web 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") + + 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"), @@ -146,8 +151,8 @@ def format_transaction(self, transaction: TransactionDetail) -> Dict[str, Any]: } ], "merchant_urls": { - "confirmation": transaction.callback_url or "https://example.com/confirm", - "notification": transaction.callback_url or "https://example.com/webhook", + "confirmation": callback_url, + "notification": callback_url, }, } @@ -157,15 +162,15 @@ async def send_payment(self, transaction: TransactionDetail) -> PaymentResponse: headers = await self.get_headers() async with self.get_client() as client: - response = await client.post("/payments/v1/sessions", json=payload, headers=headers) - data = response.json() if hasattr(response, "json") else response.data + 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.value, + status=TransactionStatus.PENDING, amount=transaction.amount, currency=transaction.currency, payment_link=data.get("redirect_url"), @@ -185,7 +190,7 @@ async def check_status(self, order_id: str) -> TransactionStatusResponse: 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 + data = getattr(response, "json", lambda: response.data)() if response.status in range(200, 300): status = self.get_normalize_status(data.get("status", "UNKNOWN")) @@ -214,17 +219,17 @@ async def refund(self, order_id: str, amount: Optional[float] = None) -> Payment async with self.get_client() as client: response = await client.post( f"/payments/v1/orders/{order_id}/refunds", - json=refund_data, + json_data=refund_data, headers=headers, ) - data = response.json() if hasattr(response, "json") else response.data + 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.value, + status=TransactionStatus.REFUNDED, amount=amount or float(data.get("refunded_amount", 0)) / 100, currency=data.get("currency", "EUR"), metadata=data, @@ -237,25 +242,27 @@ async def refund(self, order_id: str, amount: Optional[float] = None) -> Payment raw_response=data, ) - async def cancel_transaction(self, transaction_id: str) -> None: + 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 not in range(200, 300): - raise PaymentError( - message=f"Cancellation failed ({response.status})", - status_code=response.status, - ) + 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 = response.json() if hasattr(response, "json") else response.data + data = getattr(response, "json", lambda: response.data)() if response.status in range(200, 300): customer_info = data.get("billing_address", {}) @@ -265,7 +272,6 @@ async def get_transaction_detail(self, transaction_id: str) -> TransactionDetail 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( From 4cad5df85efd25da96504c265ed9106d5e9e10c3 Mon Sep 17 00:00:00 2001 From: Sakshi Shetty Date: Wed, 22 Oct 2025 03:05:05 +0530 Subject: [PATCH 3/3] Refactored tests --- tests/test_klarna.py | 47 +++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/tests/test_klarna.py b/tests/test_klarna.py index 1ccd694..4093224 100644 --- a/tests/test_klarna.py +++ b/tests/test_klarna.py @@ -22,6 +22,7 @@ @pytest.fixture def klarna_config(): + """Provides default Klarna config for sandbox testing.""" return { "api_username": "test_user", "api_key": "test_key", @@ -32,11 +33,13 @@ def klarna_config(): @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", @@ -53,14 +56,13 @@ def sample_transaction(): # ------------------------- -# Unit Tests +# Credential & Auth Tests # ------------------------- def test_validate_credentials(klarna_integrator): """Validate that credentials check works properly.""" assert klarna_integrator.validate_credentials() is True - # Missing username should fail klarna_integrator.config.api_username = None assert klarna_integrator.validate_credentials() is False @@ -82,6 +84,10 @@ async def test_get_headers(klarna_integrator): 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 @@ -91,6 +97,10 @@ def test_get_normalize_status(klarna_integrator): 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} @@ -144,6 +154,10 @@ def test_parse_webhook_invalid_signature(klarna_integrator): 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) @@ -160,11 +174,14 @@ def test_format_transaction_missing_email(klarna_integrator, sample_transaction) 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() - mock_response.status = 201 + mock_response = AsyncMock(status=201) mock_response.json.return_value = { "session_id": "sess_123", "redirect_url": "https://klarna.com/pay", @@ -186,8 +203,7 @@ async def test_send_payment_success(klarna_integrator, sample_transaction): @pytest.mark.asyncio async def test_send_payment_failure(klarna_integrator, sample_transaction): """Handle Klarna payment API error.""" - mock_response = AsyncMock() - mock_response.status = 400 + mock_response = AsyncMock(status=400) mock_response.json.return_value = {"error": "Invalid request"} mock_client = AsyncMock() @@ -201,8 +217,7 @@ async def test_send_payment_failure(klarna_integrator, sample_transaction): @pytest.mark.asyncio async def test_check_status_success(klarna_integrator): """Mock successful Klarna status check.""" - mock_response = AsyncMock() - mock_response.status = 200 + mock_response = AsyncMock(status=200) mock_response.json.return_value = {"status": "CAPTURED", "order_amount": 5000, "purchase_currency": "EUR"} mock_client = AsyncMock() @@ -212,15 +227,14 @@ async def test_check_status_success(klarna_integrator): result = await klarna_integrator.check_status("ord_001") assert result.status == TransactionStatus.SUCCESSFUL - assert result.amount == 50.0 # 5000 / 100 + 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() - mock_response.status = 200 + mock_response = AsyncMock(status=200) mock_response.json.return_value = {"refunded_amount": 5000, "currency": "EUR"} mock_client = AsyncMock() @@ -237,22 +251,19 @@ async def test_refund_success(klarna_integrator): @pytest.mark.asyncio async def test_cancel_transaction_success(klarna_integrator): """Mock successful Klarna transaction cancellation.""" - mock_response = AsyncMock() - mock_response.status = 200 + 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): - # Should not raise - await klarna_integrator.cancel_transaction("ord_001") + 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() - mock_response.status = 200 + mock_response = AsyncMock(status=200) mock_response.json.return_value = { "status": "CAPTURED", "order_amount": 5000, @@ -263,7 +274,7 @@ async def test_get_transaction_detail_success(klarna_integrator): "given_name": "Jane", "family_name": "Doe", }, - "order_id": "ord_001" + "order_id": "ord_001", } mock_client = AsyncMock()