Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 288 additions & 0 deletions easyswitch/integrators/klarna.py
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):
Comment thread
Einswilli marked this conversation as resolved.
Outdated
"""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))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think api_username must be in the extra attr.
self.config here is an instance of ProviderConfig so it'd be better to have api_username and other aggregator specific config attrs in extra...


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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put webhook_secret in self.contiig.extra.

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": [
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use transaction.callback_url or self.config.callback_url.

"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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use json_data instead of json.

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,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need unnecessary .value here.

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:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancel_transaction must return a boolean.

"""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,
)
Loading