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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file.

## [Released]

## [0.1.1]
- BTI-1091 Send the culture code as the `Culture` request header
- Add PayPerEmail builder for PaymentInvitation
- Add pay_remainder action to the base builder
- Add giftcard redirect mode and Intersolve refund params

## [0.1.0]
- BA-510 Initial setup
- BTI-9 Core SDK setup
Expand Down
2 changes: 1 addition & 1 deletion buckaroo/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "0.1.0"
VERSION = "0.1.1"
65 changes: 63 additions & 2 deletions buckaroo/builders/base_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ def __init__(self, client):
self._return_url_error: Optional[str] = None
self._return_url_reject: Optional[str] = None
self._continue_on_incomplete: str = "1"
self._culture: Optional[str] = None
self._push_url: Optional[str] = None
self._push_url_failure: Optional[str] = None
self._services_selectable_by_client: Optional[str] = None
self._client_ip: Optional[ClientIP] = None
self._service_parameters: List[Parameter] = []
self._payload: Dict[str, Any] = {} # Store original payload
Expand Down Expand Up @@ -72,6 +74,21 @@ def continue_on_incomplete(self, continue_incomplete: str) -> "BaseBuilder":
self._continue_on_incomplete = continue_incomplete
return self

def services_selectable_by_client(self, services: str) -> "BaseBuilder":
"""Set the CSV of services the client may pick on Buckaroo's hosted page."""
self._services_selectable_by_client = services
return self

def culture(self, culture: str) -> "BaseBuilder":
"""Set the culture (language) for the gateway request.

Sent as the ``Culture`` HTTP request header (e.g. ``nl-NL``); the
gateway uses it to localize templates and consumer messages. The
gateway ignores a ``Culture`` field placed in the request body.
"""
self._culture = culture
return self

def push_url(self, url: str) -> "BaseBuilder":
"""Set the Push (webhook) URL."""
self._push_url = url
Expand Down Expand Up @@ -215,6 +232,12 @@ def from_dict(self, data: Dict[str, Any]) -> "BaseBuilder":
if "continue_on_incomplete" in data:
self.continue_on_incomplete(data["continue_on_incomplete"])

if "services_selectable_by_client" in data:
self.services_selectable_by_client(data["services_selectable_by_client"])

if "culture" in data:
self.culture(data["culture"])

if "push_url" in data:
self.push_url(data["push_url"])
if "push_url_failure" in data:
Expand Down Expand Up @@ -344,6 +367,7 @@ def build(
push_url_failure=self._push_url_failure,
client_ip=self._client_ip,
services=service_list,
services_selectable_by_client=self._services_selectable_by_client,
)

return payment_request
Expand Down Expand Up @@ -416,6 +440,40 @@ def refund(self, validate: bool = True) -> PaymentResponse:

return self._post_transaction(request_data)

def pay_remainder(
self, original_transaction_key: Optional[str] = None, validate: bool = True
) -> PaymentResponse:
"""
Execute a pay-remainder transaction.

Pays the open remainder of a group transaction (e.g. after a partial
giftcard payment) via the PayRemainder action. The original transaction
key is the group transaction key that links this payment into the group.

Args:
original_transaction_key (str, optional): Group transaction key of the
partial payment. If None, read from the payload.
validate (bool): Whether to validate service parameters before building

Returns:
PaymentResponse: The pay-remainder response

Raises:
ValueError: If no original transaction key is available
"""
txn_key = original_transaction_key or self._payload.get("original_transaction_key")
if not txn_key:
raise ValueError(
"Original transaction key is required for pay remainder "
"(provide as parameter or in payload)"
)

payment_request = self.build("PayRemainder", validate=validate)
request_data = payment_request.to_dict()
request_data["OriginalTransactionKey"] = txn_key

return self._post_transaction(request_data)

def capture(
self,
original_transaction_key: Optional[str] = None,
Expand Down Expand Up @@ -557,8 +615,11 @@ def _post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse:

def _post_transaction(self, request_data: Dict[str, Any]) -> PaymentResponse:
"""Helper method to post transaction and handle response."""
# Send to Buckaroo API
response = self._client.http_client.post("/json/transaction", request_data)
# Send to Buckaroo API. Culture (when set) rides as a request header,
# not a body field — the gateway only honors it in the header. Only
# passed when present so it stays a no-op for every other request.
extra = {"culture": self._culture} if self._culture else {}
response = self._client.http_client.post("/json/transaction", request_data, **extra)

# Check if response is valid and convert to dict
if response is None:
Expand Down
74 changes: 61 additions & 13 deletions buckaroo/builders/payments/giftcards_builder.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,62 @@
from typing import Dict, Any
from .payment_builder import PaymentBuilder
from ...models.payment_response import PaymentResponse


# VVV, webshop, boekenbon, yourgift all run on the Intersolve backend
# and the gateway requires IntersolveCardnumber/IntersolvePIN for them.
INTERSOLVE_BRANDS = frozenset(
{
"intersolve",
"vvvgiftcard",
"webshopgiftcard",
"boekenbon",
"yourgift",
}
)


class GiftcardsBuilder(PaymentBuilder):
"""Builder for Giftcards payments."""

def get_service_name(self) -> str:
"""Get the service name for Giftcards payments."""
return self._payload.get("giftcard_name", "Giftcards")
"""Get the service name for Giftcards payments.

Buckaroo rejects capitalized "Giftcards" with 491 "is not a valid
service name"; the umbrella selectable-services flow needs the
lowercase plural ``giftcards``.
"""
return self._payload.get("giftcard_name") or "giftcards"

def pay_redirect(self) -> PaymentResponse:
"""Redirect-mode giftcard pay: no Service entry is sent.

Buckaroo's hosted page handles brand selection via
ServicesSelectableByClient. Sending any service name in the
ServiceList causes a 491 "No valid subscription found" error because
the merchant account is subscribed to individual brand codes, not a
generic "giftcard" service.
"""
request_data = self.build(action="Pay", validate=False).to_dict()
request_data.pop("Services", None)
return self._post_transaction(request_data)

def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]:
"""Get the allowed service parameters for Giftcards payments based on action."""
action_lower = action.lower()
giftcard_name = (self._payload.get("giftcard_name") or "").lower()

if action.lower() in ["pay"]:
if self._payload.get("giftcard_name", "").lower() == "fashioncheque":
if action_lower == "pay":
if not giftcard_name:
# Redirect mode: no brand picked locally, Buckaroo's hosted page
# collects card details so we must not require any params here.
return {}
if giftcard_name in INTERSOLVE_BRANDS:
return {
"IntersolveCardnumber": {"type": str, "required": True, "description": ""},
"IntersolvePIN": {"type": str, "required": True, "description": ""},
}
if giftcard_name == "fashioncheque":
return {
"FashionChequeCardNumber": {
"type": str,
Expand All @@ -26,24 +69,29 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]:
"description": "Save payment token for future use",
},
}

if self._payload.get("giftcard_name", "").lower() == "intersolve":
return {
"IntersolveCardnumber": {"type": str, "required": True, "description": ""},
"IntersolvePIN": {"type": str, "required": True, "description": ""},
}

if self._payload.get("giftcard_name", "").lower() == "tcs":
if giftcard_name == "tcs":
return {
"TCSCardnumber": {"type": str, "required": True, "description": ""},
"TCSValidationCode": {"type": str, "required": True, "description": ""},
}

return {
"Cardnumber": {"type": str, "required": True, "description": ""},
"PIN": {"type": str, "required": True, "description": ""},
"LastName": {"type": str, "required": False, "description": ""},
"Email": {"type": str, "required": False, "description": ""},
}

if action_lower == "refund":
# Intersolve giftcard refunds carry LastName + Email (docs.buckaroo.io/
# docs/giftcards-integration#partial-refunds); Plaza returns status 690
# without them. Email is allowed-but-optional: callers can't always
# supply one, so a missing Email defers to Plaza's 690 rather than
# failing local validation. Non-Intersolve brands need no extra params.
if giftcard_name in INTERSOLVE_BRANDS:
return {
"LastName": {"type": str, "required": True, "description": ""},
"Email": {"type": str, "required": False, "description": ""},
}
return {}

return {}
64 changes: 64 additions & 0 deletions buckaroo/builders/payments/payperemail_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from typing import Dict, Any
from .payment_builder import PaymentBuilder


class PayPerEmailBuilder(PaymentBuilder):
"""Builder for Pay Per Email payments.

Pay Per Email drives the ``PaymentInvitation`` action: Buckaroo emails the
shopper a payment link rather than returning an inline redirect. Customer
identity (email, name, gender) is supplied as service parameters.
"""

_serviceName = "payperemail"

def get_service_name(self) -> str:
"""Get the service name for Pay Per Email payments."""
return "payperemail"

def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]:
"""Get the allowed service parameters for Pay Per Email based on action.

Only ``PaymentInvitation`` carries parameters; every other action
(Pay, Refund, ...) has none.
"""
if action.lower() == "paymentinvitation":
return {
"CustomerGender": {
"type": (str, int),
"required": True,
"description": "Customer gender (1=Male, 2=Female, 0=Unknown, 9=N/A)",
},
"CustomerEmail": {
"type": str,
"required": True,
"description": "Customer email address",
},
"CustomerFirstName": {
"type": str,
"required": True,
"description": "Customer first name",
},
"CustomerLastName": {
"type": str,
"required": True,
"description": "Customer last name",
},
"ExpirationDate": {
"type": str,
"required": False,
"description": "Invitation expiration date",
},
"PaymentMethodsAllowed": {
"type": str,
"required": False,
"description": "Allowed payment methods (CSV)",
},
"MerchantSendsEmail": {
"type": (str, bool),
"required": False,
"description": "Whether the merchant sends the email instead of Buckaroo",
},
}

return {}
2 changes: 2 additions & 0 deletions buckaroo/factories/payment_method_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from buckaroo.builders.payments.mbway_builder import MBWayBuilder
from buckaroo.builders.payments.paypal_builder import PaypalBuilder
from buckaroo.builders.payments.paybybank_builder import PayByBankBuilder
from buckaroo.builders.payments.payperemail_builder import PayPerEmailBuilder


class PaymentMethodFactory(BuilderFactory):
Expand Down Expand Up @@ -75,6 +76,7 @@ class PaymentMethodFactory(BuilderFactory):
"payconiq": PayconiqBuilder,
"paypal": PaypalBuilder,
"paybybank": PayByBankBuilder,
"payperemail": PayPerEmailBuilder,
"przelewy24": Przelewy24Builder,
"riverty": RivertyBuilder,
"sepadirectdebit": SepaDirectDebitBuilder,
Expand Down
9 changes: 8 additions & 1 deletion buckaroo/http/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,10 @@ def post(
endpoint: str,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
culture: Optional[str] = None,
) -> "BuckarooResponse":
"""Send a POST request to the Buckaroo API."""
return self._make_request("POST", endpoint, data, params)
return self._make_request("POST", endpoint, data, params, culture=culture)

def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> "BuckarooResponse":
"""Send a GET request to the Buckaroo API."""
Expand All @@ -124,6 +125,7 @@ def _make_request(
endpoint: str,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
culture: Optional[str] = None,
) -> "BuckarooResponse":
"""Make an HTTP request to the Buckaroo API."""
# Build full URL
Expand All @@ -144,6 +146,11 @@ def _make_request(
# Generate authentication headers
auth_headers = self._generate_hmac_signature(method, url, content)

# Culture is sent as a request header (not part of the signed body) so
# the gateway can localize templates/consumer messages.
if culture:
auth_headers["Culture"] = culture

try:
http_response = self.http_strategy.request(
method=method,
Expand Down
4 changes: 4 additions & 0 deletions buckaroo/models/payment_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class PaymentRequest:
push_url_failure: Optional[str] = None
client_ip: Optional[ClientIP] = None
services: Optional[ServiceList] = None
services_selectable_by_client: Optional[str] = None

def __post_init__(self):
"""Set default values after initialization."""
Expand Down Expand Up @@ -138,4 +139,7 @@ def to_dict(self) -> Dict[str, Any]:
if self.services:
request_dict["Services"] = self.services.to_dict()

if self.services_selectable_by_client:
request_dict["ServicesSelectableByClient"] = self.services_selectable_by_client

return request_dict
Loading
Loading