Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
149 changes: 110 additions & 39 deletions buckaroo/builders/payments/klarna_builder.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
from __future__ import annotations
from typing import Dict, Any, Optional
from typing import Dict, Any

from buckaroo.models.payment_response import PaymentResponse
from .payment_builder import PaymentBuilder


class KlarnaBuilder(PaymentBuilder):
"""Builder for Klarna MOR (Merchant of Record) payments."""
"""Builder for Klarna MOR (Merchant of Record) payments.

Klarna no longer returns a reservation number. Every follow-up action
(CancelReservation, UpdateReservation, ExtendReservation, and Pay)
references the prior Reserve through the Buckaroo ``DataRequestKey`` carried
as a service parameter. The reservation actions post to ``/json/DataRequest``;
Pay and Refund are transaction requests. Shipping details are attached to the
Pay request (``shippingMethod`` / ``company`` / ``trackingNumber``); the
gateway has no standalone AddShippingInfo action for this service.
"""

def required_fields(self, action: str = "Pay") -> Dict[str, Any]:
"""Narrow the base required-field set per action.

Reserve only needs currency + invoice; Pay needs currency + amount; the
DataRequest follow-up actions need none of the base transaction fields.
Refund and any other action fall through to the base requirements.
"""
action = action.lower()

if action == "reserve":
return {
"currency": self._currency,
"invoice": self._invoice,
}

if action == "pay":
return {
"currency": self._currency,
"amount_debit": self._amount_debit,
"invoice": self._invoice,
}

if action in (
"cancelreservation",
"updatereservation",
"extendreservation",
):
return {}

return super().required_fields(action)

def get_service_name(self) -> str:
"""Get the service name for Klarna payments."""
Expand All @@ -23,30 +63,50 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]:
"required": True,
"description": "Key of the prior Klarna Reserve",
},
"article": {
"type": list,
"required": False,
"description": "Articles to pay for on a partial delivery",
},
"shippingMethod": {
"type": str,
"required": False,
"description": "Shipping method",
},
"company": {
"type": str,
"required": False,
"description": "Shipping company name",
},
"trackingNumber": {
"type": str,
"required": False,
"description": "Shipping tracking number",
},
}

if action == "reserve":
return {
"billingCustomer": {
"type": list,
"required": True,
"description": "Billing customer information",
},
"shippingCustomer": {
"type": list,
"required": True,
"description": "Shipping customer information",
},
"article": {
"type": list,
"required": True,
"description": "Klarna articles",
},
"operatingCountry": {
"type": str,
"required": False,
"required": True,
"description": "Operating country code",
},
"billingCustomer": {
"type": list,
"required": False,
"description": "Billing customer information",
},
"shippingCustomer": {
"type": list,
"required": False,
"description": "Shipping customer information",
},
"pno": {
"type": str,
"required": False,
Expand All @@ -64,37 +124,48 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]:
},
}

if action == "cancelreservation":
return {}
if action in ("cancelreservation", "extendreservation"):
return {
"dataRequestKey": {
"type": str,
"required": True,
"description": "Buckaroo data request key of the prior Reserve",
},
}

if action == "updatereservation":
return {
"dataRequestKey": {
"type": str,
"required": True,
"description": "Buckaroo data request key of the prior Reserve",
},
"article": {
"type": list,
"required": False,
"description": "Updated Klarna articles",
},
"shippingCustomer": {
"type": list,
"required": False,
"description": "Updated shipping customer information",
},
}

return {}

def reserve(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse:

payment_request = self.build("Reserve", validate=validate)
request_data = payment_request.to_dict()

return self._post_data_request(request_data)

def cancelReservation(
self: "PaymentBuilder",
original_transaction_key: Optional[str] = None,
validate: bool = True,
) -> PaymentResponse:
"""Cancel a previously-reserved Klarna transaction.

Mirrors :meth:`AuthorizeCaptureCapable.cancelAuthorize` but with the
``CancelReservation`` action.
"""
txn_key = original_transaction_key or self._payload.get("original_transaction_key")
if not txn_key:
raise ValueError(
"Original transaction key is required for cancelReservation "
"(provide 'original_transaction_key' in payload)"
)
return self._post_data_request(payment_request.to_dict())

def cancelReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse:
payment_request = self.build("CancelReservation", validate=validate)
request_data = payment_request.to_dict()
request_data["OriginalTransactionKey"] = txn_key
return self._post_data_request(payment_request.to_dict())

def updateReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse:
payment_request = self.build("UpdateReservation", validate=validate)
return self._post_data_request(payment_request.to_dict())

return self._post_transaction(request_data)
def extendReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse:
payment_request = self.build("ExtendReservation", validate=validate)
return self._post_data_request(payment_request.to_dict())
79 changes: 79 additions & 0 deletions tests/feature/payments/test_klarna.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,82 @@ def test_klarna_pay_as_capture_attaches_data_request_key(self, buckaroo, mock_st
services = body["Services"]["ServiceList"]
names = [p["Name"] for p in services[0]["Parameters"]]
assert "Datarequestkey" in names

def test_klarna_cancel_reservation_posts_data_request_key(self, buckaroo, mock_strategy):
"""Follow-up actions carry the Buckaroo DataRequestKey (no reservation
number) and post to /json/DataRequest."""
response_body = Helpers.pending_redirect_response("klarna", action="CancelReservation")
mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body))
response = buckaroo.payments.create_payment(
"klarna",
Helpers.standard_payload(
invoice="INV-KLARNA-CAN",
service_parameters={"dataRequestKey": "RES-DRK-2"},
),
).cancelReservation()

assert response.key == response_body["Key"]
body = json.loads(mock_strategy.calls[-1]["data"])
service = body["Services"]["ServiceList"][0]
assert service["Action"] == "CancelReservation"
assert "OriginalTransactionKey" not in body
names = [p["Name"] for p in service["Parameters"]]
assert "Datarequestkey" in names

def test_klarna_update_reservation_dispatches_to_data_request(self, buckaroo, mock_strategy):
response_body = Helpers.pending_redirect_response("klarna", action="UpdateReservation")
mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body))
response = buckaroo.payments.create_payment(
"klarna",
Helpers.standard_payload(
invoice="INV-KLARNA-UPD",
service_parameters={
"dataRequestKey": "RES-DRK-3",
"article": [{"description": "Widget", "quantity": "1", "price": "10.00"}],
},
),
).updateReservation()

assert response.key == response_body["Key"]
assert (
json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0]["Action"]
== "UpdateReservation"
)

def test_klarna_extend_reservation_dispatches_to_data_request(self, buckaroo, mock_strategy):
response_body = Helpers.pending_redirect_response("klarna", action="ExtendReservation")
mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body))
response = buckaroo.payments.create_payment(
"klarna",
Helpers.standard_payload(
invoice="INV-KLARNA-EXT",
service_parameters={"dataRequestKey": "RES-DRK-4"},
),
).extendReservation()

assert response.key == response_body["Key"]
assert (
json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0]["Action"]
== "ExtendReservation"
)

def test_klarna_pay_carries_shipping_details_on_transaction(self, buckaroo, mock_strategy):
"""Shipping details ride on the Pay transaction (no AddShippingInfo action)."""
capture_body = Helpers.success_response(
overrides={"Key": "PAY-K-SHP", "AmountDebit": 22.45, "ServiceCode": "klarna"}
)
mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", capture_body))
builder = buckaroo.payments.create_payment(
"klarna",
Helpers.standard_payload(invoice="INV-KLARNA-SHP", amount=22.45),
)
builder.add_parameter("dataRequestKey", "RES-DRK-5")
builder.add_parameter("trackingNumber", "AAAA1234567890")
response = builder.pay()

assert response.key == "PAY-K-SHP"
service = json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0]
assert service["Action"] == "Pay"
names = [p["Name"] for p in service["Parameters"]]
assert "Datarequestkey" in names
assert "Trackingnumber" in names
Loading
Loading