diff --git a/CHANGELOG.md b/CHANGELOG.md index 38303fd..4c20dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/buckaroo/_version.py b/buckaroo/_version.py index 1cf6267..7d7c7c3 100644 --- a/buckaroo/_version.py +++ b/buckaroo/_version.py @@ -1 +1 @@ -VERSION = "0.1.0" +VERSION = "0.1.1" diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index dd5140d..704cb1f 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -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 @@ -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 @@ -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: @@ -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 @@ -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, @@ -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: diff --git a/buckaroo/builders/payments/giftcards_builder.py b/buckaroo/builders/payments/giftcards_builder.py index fdceba8..3c29cb5 100644 --- a/buckaroo/builders/payments/giftcards_builder.py +++ b/buckaroo/builders/payments/giftcards_builder.py @@ -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, @@ -26,19 +69,11 @@ 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": ""}, @@ -46,4 +81,17 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: "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 {} diff --git a/buckaroo/builders/payments/payperemail_builder.py b/buckaroo/builders/payments/payperemail_builder.py new file mode 100644 index 0000000..82ad8a0 --- /dev/null +++ b/buckaroo/builders/payments/payperemail_builder.py @@ -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 {} diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index c040d52..4a9a20c 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -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): @@ -75,6 +76,7 @@ class PaymentMethodFactory(BuilderFactory): "payconiq": PayconiqBuilder, "paypal": PaypalBuilder, "paybybank": PayByBankBuilder, + "payperemail": PayPerEmailBuilder, "przelewy24": Przelewy24Builder, "riverty": RivertyBuilder, "sepadirectdebit": SepaDirectDebitBuilder, diff --git a/buckaroo/http/client.py b/buckaroo/http/client.py index 3c2073e..9722fa2 100644 --- a/buckaroo/http/client.py +++ b/buckaroo/http/client.py @@ -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.""" @@ -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 @@ -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, diff --git a/buckaroo/models/payment_request.py b/buckaroo/models/payment_request.py index 017a95c..c63e0ac 100644 --- a/buckaroo/models/payment_request.py +++ b/buckaroo/models/payment_request.py @@ -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.""" @@ -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 diff --git a/tests/feature/payments/test_giftcards.py b/tests/feature/payments/test_giftcards.py index 736781d..ea77dab 100644 --- a/tests/feature/payments/test_giftcards.py +++ b/tests/feature/payments/test_giftcards.py @@ -12,7 +12,7 @@ def test_giftcards_pay_returns_pending_with_redirect(self, buckaroo, mock_strate mock_strategy, method="giftcards", invoice="INV-GC-001", - payload_overrides={"description": "Test giftcards"}, + payload_overrides={"description": "Test giftcards", "giftcard_name": "other"}, service_params={"Cardnumber": "1234567890123456", "PIN": "1234"}, ) @@ -36,6 +36,7 @@ def test_giftcards_pay_sends_cardnumber_and_pin_on_the_wire( "giftcards", Helpers.standard_payload( invoice="INV-GC-WIRE", + giftcard_name="other", service_parameters={"Cardnumber": "1234567890123456", "PIN": "1234"}, ), ).pay() @@ -44,3 +45,29 @@ def test_giftcards_pay_sends_cardnumber_and_pin_on_the_wire( params = {p["Name"]: p["Value"] for p in recorded_service_parameters(recording_mock)} assert params.get("Cardnumber") == "1234567890123456" assert params.get("Pin") == "1234" + + def test_giftcards_pay_redirect_mode_omits_card_parameters( + self, recording_buckaroo, recording_mock + ): + """Redirect mode: no giftcard_name and no card details — payload reaches + the gateway with no Cardnumber/PIN and an empty service-parameter list. + Buckaroo's hosted page collects card details there. + """ + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.pending_redirect_response("giftcards"), + ) + ) + recording_buckaroo.payments.create_payment( + "giftcards", + Helpers.standard_payload( + invoice="INV-GC-REDIRECT", + services_selectable_by_client="fashioncheque,intersolve,tcs", + ), + ).pay() + + assert recorded_action(recording_mock) == "Pay" + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(recording_mock)} + assert params == {} diff --git a/tests/unit/builders/payments/test_giftcards_builder.py b/tests/unit/builders/payments/test_giftcards_builder.py index 5ebcece..ea0c5af 100644 --- a/tests/unit/builders/payments/test_giftcards_builder.py +++ b/tests/unit/builders/payments/test_giftcards_builder.py @@ -7,12 +7,15 @@ from __future__ import annotations +import pytest + from buckaroo._buckaroo_client import BuckarooClient -from buckaroo.builders.payments.giftcards_builder import GiftcardsBuilder +from buckaroo.builders.payments.giftcards_builder import GiftcardsBuilder, INTERSOLVE_BRANDS from buckaroo.builders.payments.payment_builder import PaymentBuilder from tests.support.builders import populate_required_fields from tests.support.mock_buckaroo import MockBuckaroo from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http def test_construct_with_buckaroo_client_returns_payment_builder(client): @@ -23,13 +26,15 @@ def test_construct_with_buckaroo_client_returns_payment_builder(client): def test_class_does_not_declare_service_name_attribute(): """Unlike ``CreditcardBuilder``, ``GiftcardsBuilder`` does not set ``_serviceName`` on the class — service name is derived dynamically from - ``_payload['giftcard_name']`` with a ``'Giftcards'`` default. + ``_payload['giftcard_name']`` with a ``'giftcards'`` default. """ assert not hasattr(GiftcardsBuilder, "_serviceName") +# Buckaroo rejects the capitalized "Giftcards" with 491 'is not a valid service +# name'. The umbrella selectable-services flow uses lowercase ``giftcards``. def test_get_service_name_defaults_to_giftcards_when_payload_empty(client): - assert GiftcardsBuilder(client).get_service_name() == "Giftcards" + assert GiftcardsBuilder(client).get_service_name() == "giftcards" def test_get_service_name_reads_giftcard_name_from_payload(client): @@ -37,11 +42,36 @@ def test_get_service_name_reads_giftcard_name_from_payload(client): assert builder.get_service_name() == "fashioncheque" -def test_get_allowed_service_parameters_empty_payload_returns_default(client): +def test_get_allowed_service_parameters_empty_payload_returns_empty_for_redirect_mode(client): + """Redirect mode: when no ``giftcard_name`` is set, Buckaroo's hosted page + collects card details so the SDK must not require any parameters locally. + """ builder = GiftcardsBuilder(client) - spec = builder.get_allowed_service_parameters("Pay") - assert "Cardnumber" in spec - assert "PIN" in spec + assert builder.get_allowed_service_parameters("Pay") == {} + + +def test_pay_succeeds_in_redirect_mode_without_card_details(): + """Redirect mode constructs a payload with no Cardnumber/PIN but does set + ``services_selectable_by_client``. The validator must not raise. + """ + client = BuckarooClient("store_key", "secret_key", mode="test") + mock = MockBuckaroo() + client.http_client.http_strategy = mock + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "GC-REDIRECT-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + builder = populate_required_fields(GiftcardsBuilder(client), amount=10.50) + builder.services_selectable_by_client("fashioncheque,intersolve,tcs") + + response = builder.pay() + + assert response.key == "GC-REDIRECT-1" + mock.assert_all_consumed() def test_get_allowed_service_parameters_pay_fashioncheque_snapshot(client): @@ -68,6 +98,22 @@ def test_get_allowed_service_parameters_pay_intersolve_snapshot(client): } +@pytest.mark.parametrize( + "brand", ["intersolve", "vvvgiftcard", "webshopgiftcard", "boekenbon", "yourgift"] +) +def test_get_allowed_service_parameters_intersolve_backed_brands_use_intersolve_params( + client, brand +): + """VVV, webshop, boekenbon, yourgift all run on the Intersolve backend. + The gateway expects ``IntersolveCardnumber``/``IntersolvePIN`` for them — + not the generic ``Cardnumber``/``PIN`` pair (which the gateway rejects).""" + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": brand}) + assert builder.get_allowed_service_parameters("Pay") == { + "IntersolveCardnumber": {"type": str, "required": True, "description": ""}, + "IntersolvePIN": {"type": str, "required": True, "description": ""}, + } + + def test_get_allowed_service_parameters_pay_tcs_snapshot(client): builder = GiftcardsBuilder(client).from_dict({"giftcard_name": "tcs"}) assert builder.get_allowed_service_parameters("Pay") == { @@ -98,7 +144,75 @@ def test_get_allowed_service_parameters_is_case_insensitive_for_pay(client): def test_get_allowed_service_parameters_unsupported_action_returns_empty(client): """Unsupported actions short-circuit to ``{}`` before the ``giftcard_name`` branch, so the empty-payload bug does not fire here.""" - assert GiftcardsBuilder(client).get_allowed_service_parameters("Refund") == {} + assert GiftcardsBuilder(client).get_allowed_service_parameters("Authorize") == {} + + +@pytest.mark.parametrize( + "brand", ["intersolve", "vvvgiftcard", "webshopgiftcard", "boekenbon", "yourgift"] +) +def test_get_allowed_service_parameters_refund_intersolve_brands_allow_lastname_email( + client, brand +): + """Intersolve giftcard refunds carry LastName + Email service params per + Buckaroo docs (https://docs.buckaroo.io/docs/giftcards-integration + #partial-refunds). LastName is required; Email is allowed-but-optional so a + caller without one defers to Plaza's status 690 instead of a local raise.""" + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": brand}) + assert builder.get_allowed_service_parameters("Refund") == { + "LastName": {"type": str, "required": True, "description": ""}, + "Email": {"type": str, "required": False, "description": ""}, + } + + +def test_get_allowed_service_parameters_refund_non_intersolve_returns_empty(client): + """Refund on fashioncheque / tcs / unknown brands carries no extra + service params; only Intersolve-backed brands need LastName + Email.""" + for brand in ("fashioncheque", "tcs", "other", ""): + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": brand}) + assert builder.get_allowed_service_parameters("Refund") == {} + + +def test_refund_build_validates_when_intersolve_email_absent(client): + """Regression: an Intersolve refund carrying LastName but no Email must + build cleanly. Email is optional, so a missing one defers to Plaza's status + 690 instead of raising RequiredParameterMissingError during local validation. + """ + builder = populate_required_fields( + GiftcardsBuilder(client).from_dict({"giftcard_name": "vvvgiftcard"}), + amount=10.50, + ) + builder.add_parameter("LastName", "Customer") + + request = builder.build("Refund", validate=True) + + service = request.to_dict()["Services"]["ServiceList"][0] + names = {p["Name"] for p in service["Parameters"]} + assert "Lastname" in names + assert "Email" not in names + + +def test_refund_build_keeps_intersolve_email_when_supplied(client): + """A supplied Email must survive the refund filter — ``required: False`` + keeps the key allowed, it must not be stripped.""" + builder = populate_required_fields( + GiftcardsBuilder(client).from_dict({"giftcard_name": "vvvgiftcard"}), + amount=10.50, + ) + builder.add_parameter("LastName", "Customer") + builder.add_parameter("Email", "shopper@example.com") + + request = builder.build("Refund", validate=True) + + service = request.to_dict()["Services"]["ServiceList"][0] + names = {p["Name"] for p in service["Parameters"]} + assert {"Lastname", "Email"} <= names + + +def test_get_allowed_service_parameters_refund_is_case_insensitive(client): + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": "intersolve"}) + assert builder.get_allowed_service_parameters( + "refund" + ) == builder.get_allowed_service_parameters("Refund") def test_pay_dispatches_giftcards_service_through_mock_buckaroo(): @@ -120,3 +234,38 @@ def test_pay_dispatches_giftcards_service_through_mock_buckaroo(): assert response.key == "GC-1" mock.assert_all_consumed() + + +def test_intersolve_brands_constant_is_importable_and_correct(): + assert INTERSOLVE_BRANDS == frozenset( + {"intersolve", "vvvgiftcard", "webshopgiftcard", "boekenbon", "yourgift"} + ) + + +def test_pay_redirect_omits_services_and_sends_selectable_by_client_csv(): + """Redirect mode: wire body must contain ServicesSelectableByClient and + all core fields, but no Services entry — Buckaroo rejects giftcard service + names so brand selection is delegated to the hosted page.""" + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "GC-REDIRECT-2", "Status": {"Code": {"Code": 190}}}, + ) + ) + + builder = populate_required_fields(GiftcardsBuilder(client), amount=10.50) + builder.services_selectable_by_client("vvvgiftcard,boekenbon") + + response = builder.pay_redirect() + + sent = recorded_request(mock) + assert "Services" not in sent + assert sent["ServicesSelectableByClient"] == "vvvgiftcard,boekenbon" + assert sent["Currency"] == "EUR" + assert sent["AmountDebit"] == 10.50 + assert sent["Invoice"] == "INV-1" + assert sent["ReturnURL"] == "https://ret.example/ok" + assert response.key == "GC-REDIRECT-2" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_payment_builder.py b/tests/unit/builders/payments/test_payment_builder.py index db8dfa5..20f454e 100644 --- a/tests/unit/builders/payments/test_payment_builder.py +++ b/tests/unit/builders/payments/test_payment_builder.py @@ -213,6 +213,27 @@ def test_client_ip_setter_returns_self_and_appears_in_request(): assert request["ClientIP"] == {"Type": 1, "Address": "203.0.113.7"} +def test_services_selectable_by_client_setter_returns_self_and_appears_in_request(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + assert builder.services_selectable_by_client("a,b") is builder + request = builder.build(validate=False).to_dict() + assert request["ServicesSelectableByClient"] == "a,b" + + +def test_pay_sends_services_selectable_by_client_in_request_body(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "P-1"})) + + builder = populate_required_fields( + make_test_builder(client, service_name="ideal"), amount=10.50 + ) + builder.services_selectable_by_client("a,b").pay(validate=False) + + sent = recorded_request(mock) + assert sent["ServicesSelectableByClient"] == "a,b" + mock.assert_all_consumed() + + # --------------------------------------------------------------------------- # add_parameter diff --git a/tests/unit/builders/payments/test_payperemail_builder.py b/tests/unit/builders/payments/test_payperemail_builder.py new file mode 100644 index 0000000..fdf065a --- /dev/null +++ b/tests/unit/builders/payments/test_payperemail_builder.py @@ -0,0 +1,129 @@ +"""Unit coverage for :class:`PayPerEmailBuilder`. + +Pay Per Email drives the ``PaymentInvitation`` action: Buckaroo emails the +shopper a payment link instead of returning an inline redirect. The builder +carries no capability mixins and exposes its parameter spec only for the +``PaymentInvitation`` action (``Pay`` is empty). The per-action spec and an +end-to-end ``execute_action("PaymentInvitation")`` via :class:`RecordingMock` +are pinned inline so drift is loud. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.payperemail_builder import PayPerEmailBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import RecordingMock, recorded_action, recorded_service_parameters + + +@pytest.fixture +def builder(client: BuckarooClient) -> PayPerEmailBuilder: + return PayPerEmailBuilder(client) + + +def test_builder_instantiates_as_payment_builder(builder: PayPerEmailBuilder) -> None: + assert isinstance(builder, PayPerEmailBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_service_name_is_payperemail(builder: PayPerEmailBuilder) -> None: + assert builder.get_service_name() == "payperemail" + assert PayPerEmailBuilder._serviceName == "payperemail" + + +_PAYMENT_INVITATION_SPEC = { + "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", + }, +} + + +def test_get_allowed_service_parameters_payment_invitation_snapshot( + builder: PayPerEmailBuilder, +) -> None: + assert builder.get_allowed_service_parameters("PaymentInvitation") == _PAYMENT_INVITATION_SPEC + + +def test_get_allowed_service_parameters_payment_invitation_case_insensitive( + builder: PayPerEmailBuilder, +) -> None: + # Source lowercases the action before comparing. + assert builder.get_allowed_service_parameters( + "paymentinvitation" + ) == builder.get_allowed_service_parameters("PaymentInvitation") + + +@pytest.mark.parametrize("action", ["Pay", "Refund", "Authorize", "Capture", ""]) +def test_get_allowed_service_parameters_other_actions_return_empty( + builder: PayPerEmailBuilder, action: str +) -> None: + assert builder.get_allowed_service_parameters(action) == {} + + +def test_payment_invitation_end_to_end_uses_invitation_action( + builder: PayPerEmailBuilder, +) -> None: + """``execute_action("PaymentInvitation")`` posts the invitation action with + the customer params on the wire — not a plain Pay.""" + mock = RecordingMock() + builder._client.http_client.http_strategy = mock + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "ppe-key", "Status": {"Code": {"Code": 791}}}, + ) + ) + + response = ( + builder.currency("EUR") + .amount(42.00) + .description("PPE invite") + .invoice("INV-PPE-1") + .return_url("https://ret.example/ok") + .return_url_cancel("https://ret.example/cancel") + .return_url_error("https://ret.example/error") + .return_url_reject("https://ret.example/reject") + .from_dict( + { + "service_parameters": { + "CustomerEmail": "jane@example.com", + "CustomerFirstName": "Jane", + "CustomerLastName": "Doe", + "CustomerGender": "1", + } + } + ) + .execute_action("PaymentInvitation") + ) + + assert response.key == "ppe-key" + assert recorded_action(mock) == "PaymentInvitation" + sent = {p["Name"].lower(): p["Value"] for p in recorded_service_parameters(mock)} + assert sent["customeremail"] == "jane@example.com" + assert sent["customerfirstname"] == "Jane" + assert sent["customerlastname"] == "Doe" + assert sent["customergender"] == "1" diff --git a/tests/unit/builders/test_base_builder.py b/tests/unit/builders/test_base_builder.py index e82d599..91d39e7 100644 --- a/tests/unit/builders/test_base_builder.py +++ b/tests/unit/builders/test_base_builder.py @@ -134,6 +134,26 @@ def test_push_url_setters_return_self_and_appear_in_request(): assert request["PushURLFailure"] == "https://example.com/push-fail" +def test_services_selectable_by_client_setter_returns_self_and_appears_in_request(): + builder = populate_required_fields(_make_builder(), amount=10.50) + assert builder.services_selectable_by_client("ideal,bancontact") is builder + request = builder.build(validate=False).to_dict() + assert request["ServicesSelectableByClient"] == "ideal,bancontact" + + +def test_culture_setter_returns_self(): + builder = populate_required_fields(_make_builder(), amount=10.50) + assert builder.culture("nl-NL") is builder + + +def test_culture_never_emitted_in_request_body(): + # Culture rides in the HTTP header, never the body (the gateway ignores a + # body-level Culture). + builder = populate_required_fields(_make_builder(), amount=10.50) + request = builder.culture("nl-NL").build(validate=False).to_dict() + assert "Culture" not in request + + # --------------------------------------------------------------------------- # from_dict @@ -173,6 +193,14 @@ def test_from_dict_populates_all_supported_core_fields_and_returns_self(): assert request["ClientIP"] == {"Type": 0, "Address": "198.51.100.9"} +def test_from_dict_roundtrips_culture(): + # from_dict stores culture; it never lands in the request body. + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.from_dict({"culture": "nl-NL"}) + request = builder.build("Pay", validate=False).to_dict() + assert "Culture" not in request + + def test_from_dict_client_ip_dict_form_uses_address_and_type(): builder = populate_required_fields(_make_builder(), amount=10.50) builder.from_dict({"client_ip": {"address": "203.0.113.5", "type": 1}}) @@ -416,9 +444,11 @@ class _StubHttp: def __init__(self, response): self.response = response self.calls = [] + self.cultures = [] - def post(self, path, data): + def post(self, path, data, culture=None): self.calls.append((path, data)) + self.cultures.append(culture) return self.response @@ -442,6 +472,42 @@ def test_pay_posts_to_transaction_and_returns_payment_response(): assert response.to_dict()["Status"]["Code"]["Code"] == 190 +def test_pay_sends_services_selectable_by_client_in_request_body(): + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + builder.services_selectable_by_client("a,b").pay() + + _, sent = http.calls[0] + assert sent["ServicesSelectableByClient"] == "a,b" + + +def test_from_dict_roundtrips_services_selectable_by_client(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.from_dict({"services_selectable_by_client": "a,b"}) + + request = builder.build("Pay", validate=False).to_dict() + assert request["ServicesSelectableByClient"] == "a,b" + + +def test_pay_passes_culture_to_http_client_for_header(): + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + builder.culture("nl-NL").pay() + + assert http.cultures[0] == "nl-NL" + + +def test_pay_passes_no_culture_when_unset(): + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + builder.pay() + + assert http.cultures[0] is None + + def test_post_transaction_returns_empty_response_when_strategy_returns_none(): client, http = _client_returning(None) builder = populate_required_fields(_make_builder(client=client), amount=10.50) @@ -501,6 +567,37 @@ def test_refund_partial_uses_refund_amount_and_removes_debit(): assert "AmountDebit" not in sent +def test_pay_remainder_requires_original_transaction_key(): + builder = populate_required_fields(_make_builder(), amount=10.50) + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.pay_remainder() + + +def test_pay_remainder_uses_key_argument_and_sets_pay_remainder_action(): + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + builder.pay_remainder(original_transaction_key="GROUP-1") + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "GROUP-1" + assert sent["AmountDebit"] == 10.50 + assert "AmountCredit" not in sent + assert sent["Services"]["ServiceList"][0]["Action"] == "PayRemainder" + + +def test_pay_remainder_reads_key_from_payload(): + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + builder.from_dict({"original_transaction_key": "GROUP-2"}) + + builder.pay_remainder() + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "GROUP-2" + assert sent["Services"]["ServiceList"][0]["Action"] == "PayRemainder" + + def test_capture_requires_authorization_key(): builder = populate_required_fields(_make_builder(), amount=10.50) with pytest.raises(ValueError, match="Authorization key is required"): diff --git a/tests/unit/models/test_payment_request.py b/tests/unit/models/test_payment_request.py index bf47660..c5f0125 100644 --- a/tests/unit/models/test_payment_request.py +++ b/tests/unit/models/test_payment_request.py @@ -218,6 +218,18 @@ def test_to_dict_omits_client_ip_when_falsy(self): request.client_ip = None assert "ClientIP" not in request.to_dict() + def test_to_dict_includes_services_selectable_by_client_when_set(self): + request = _make_request(services_selectable_by_client="ideal,bancontact") + assert request.to_dict()["ServicesSelectableByClient"] == "ideal,bancontact" + + def test_to_dict_omits_services_selectable_by_client_when_none(self): + result = _make_request().to_dict() + assert "ServicesSelectableByClient" not in result + + def test_to_dict_omits_services_selectable_by_client_when_empty_string(self): + result = _make_request(services_selectable_by_client="").to_dict() + assert "ServicesSelectableByClient" not in result + class TestServiceUnsupportedParameters: def test_to_dict_ignores_parameters_of_unsupported_type(self): diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 164b0d3..fa16390 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -9,7 +9,7 @@ def test_version_attribute_matches_version_module(): from buckaroo import _version assert buckaroo.__version__ == _version.VERSION - assert buckaroo.__version__ == "0.1.0" + assert buckaroo.__version__ == "0.1.1" def test_public_api_reexports(): @@ -49,4 +49,4 @@ def test_setup_py_can_read_version_module(): version_file = Path(buckaroo.__file__).parent / "_version.py" namespace = runpy.run_path(str(version_file)) - assert namespace["VERSION"] == "0.1.0" + assert namespace["VERSION"] == "0.1.1"