diff --git a/README.md b/README.md index 1398b99..a238e30 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - [Example](#example) - [iDIN](#idin) - [Instant Refunds](#instant-refunds) +- [eMandate](#emandate) - [Contribute](#contribute) - [Versioning](#versioning) - [Additional information](#additional-information) @@ -132,6 +133,61 @@ print("key:", response.key) See [`examples/instant_refund.py`](examples/instant_refund.py) for a runnable demo covering both iDEAL and Payconiq. +### eMandate + +eMandate is a DataRequest-based solution for managing SEPA direct debit mandates, reached through +`app.solutions` rather than `app.payments`. It comes in two variants that share the same five +actions — only the service name differs: + +- `emandate` — retail (B2C) +- `emandateb2b` — business (B2B) + +```python +from buckaroo.app import Buckaroo + +app = Buckaroo.from_env() + +# List available issuers (GetIssuerList — no parameters) +response = app.solutions.create_solution("emandate").issuer_list() + +# Create a mandate (CreateMandate — debtorReference is required) +response = app.solutions.create_solution( + "emandate", + { + "service_parameters": { + "debtorReference": "DEBTOR-001", + "debtorBankId": "ABNANL2A", + "sequenceType": "1", + "purchaseId": "PUR-001", + "language": "nl", + } + }, +).create_mandate() +mandate_id = response.get_service_parameter("MandateId") + +# Look up a mandate's status (GetStatus — mandateId is required) +response = app.solutions.create_solution( + "emandate", {"service_parameters": {"mandateId": mandate_id}} +).status() + +# Modify a mandate (ModifyMandate — mandateId is required) +response = app.solutions.create_solution( + "emandate", + {"service_parameters": {"mandateId": mandate_id, "maxAmount": "1000.00"}}, +).modify_mandate() + +# Cancel a mandate (CancelMandate — mandateId is required) +response = app.solutions.create_solution( + "emandate", + {"service_parameters": {"mandateId": mandate_id, "purchaseId": "PUR-001"}}, +).cancel_mandate() +``` + +The B2B variant exposes the same five methods — swap `"emandate"` for `"emandateb2b"`. + +See [`examples/emandate.py`](examples/emandate.py) for a runnable demo of all five actions +against both the B2C and B2B services. + ### Contribute We really appreciate it when developers contribute to improve the Buckaroo plugins. diff --git a/buckaroo/builders/solutions/emandate_builder.py b/buckaroo/builders/solutions/emandate_builder.py new file mode 100644 index 0000000..fe199ae --- /dev/null +++ b/buckaroo/builders/solutions/emandate_builder.py @@ -0,0 +1,176 @@ +from typing import Dict, Any +from .solution_builder import SolutionBuilder + + +class EmandateBuilder(SolutionBuilder): + """Builder for eMandate solutions (DataRequest-based mandate management).""" + + def get_service_name(self) -> str: + """Get the service name for eMandate.""" + return "emandate" + + def get_allowed_service_parameters(self, action: str = "GetIssuerList") -> Dict[str, Any]: + """Get the allowed service parameters for eMandate based on action.""" + + if action.lower() in ["getissuerlist"]: + return {} + + if action.lower() in ["createmandate"]: + return { + "debtorBankId": { + "type": str, + "required": False, + "description": "BIC of the debtor's bank", + }, + "debtorReference": { + "type": str, + "required": True, + "description": "Debtor's reference for the mandate", + }, + "sequenceType": { + "type": str, + "required": False, + "description": "Sequence type of the mandate (0 = one-off, 1 = recurring)", + }, + "purchaseId": { + "type": str, + "required": False, + "description": "Unique purchase identifier", + }, + "language": { + "type": str, + "required": False, + "description": "Language for the mandate flow", + }, + "emandateReason": { + "type": str, + "required": False, + "description": "Reason for the mandate", + }, + "maxAmount": { + "type": str, + "required": False, + "description": "Maximum amount allowed under the mandate", + }, + } + + if action.lower() in ["getstatus"]: + return { + "mandateId": { + "type": str, + "required": True, + "description": "Identifier of the mandate to look up", + }, + } + + if action.lower() in ["modifymandate"]: + return { + "mandateId": { + "type": str, + "required": True, + "description": "Identifier of the mandate to modify", + }, + "maxAmount": { + "type": str, + "required": False, + "description": "Maximum amount allowed under the mandate", + }, + "language": { + "type": str, + "required": False, + "description": "Language for the mandate flow", + }, + "emandateReason": { + "type": str, + "required": False, + "description": "Reason for the mandate", + }, + } + + if action.lower() in ["cancelmandate"]: + return { + "mandateId": { + "type": str, + "required": True, + "description": "Identifier of the mandate to cancel", + }, + "purchaseId": { + "type": str, + "required": False, + "description": "Unique purchase identifier", + }, + } + + return {} + + def issuer_list(self, validate: bool = True) -> Any: + """Retrieve the list of available eMandate issuers via GetIssuerList.""" + payload = self.build("GetIssuerList", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def create_mandate(self, validate: bool = True) -> Any: + """Create a mandate via CreateMandate. + + Requires ``debtorReference`` to be set (via ``add_parameter`` or the + ``service_parameters`` payload key); the other Mandate parameters are + optional. Raises :class:`RequiredParameterMissingError` when + ``debtorReference`` is missing and ``validate`` is True. + """ + payload = self.build("CreateMandate", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def status(self, validate: bool = True) -> Any: + """Retrieve the status of a mandate via GetStatus. + + Requires ``mandateId`` to be set (via ``add_parameter`` or the + ``service_parameters`` payload key). Raises + :class:`RequiredParameterMissingError` when ``mandateId`` is missing + and ``validate`` is True. + """ + payload = self.build("GetStatus", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def modify_mandate(self, validate: bool = True) -> Any: + """Modify a mandate via ModifyMandate. + + Requires ``mandateId`` to be set (via ``add_parameter`` or the + ``service_parameters`` payload key); ``maxAmount``, ``language`` and + ``emandateReason`` are optional. Raises + :class:`RequiredParameterMissingError` when ``mandateId`` is missing + and ``validate`` is True. + """ + payload = self.build("ModifyMandate", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def cancel_mandate(self, validate: bool = True) -> Any: + """Cancel a mandate via CancelMandate. + + Requires ``mandateId`` to be set (via ``add_parameter`` or the + ``service_parameters`` payload key); ``purchaseId`` is optional. + Raises :class:`RequiredParameterMissingError` when ``mandateId`` is + missing and ``validate`` is True. + """ + payload = self.build("CancelMandate", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + +class EmandateB2BBuilder(EmandateBuilder): + """Builder for the Business (B2B) eMandate variant. + + Inherits every action and parameter spec from :class:`EmandateBuilder` + unchanged; only the service name differs. + """ + + def get_service_name(self) -> str: + """Get the service name for the B2B eMandate variant.""" + return "emandateb2b" diff --git a/buckaroo/factories/solution_method_factory.py b/buckaroo/factories/solution_method_factory.py index afd0303..8001a13 100644 --- a/buckaroo/factories/solution_method_factory.py +++ b/buckaroo/factories/solution_method_factory.py @@ -3,6 +3,7 @@ from .builder_factory import BuilderFactory from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder +from buckaroo.builders.solutions.emandate_builder import EmandateB2BBuilder, EmandateBuilder from buckaroo.builders.solutions.default_builder import DefaultBuilder from buckaroo.builders.solutions.solution_builder import SolutionBuilder @@ -13,6 +14,8 @@ class SolutionMethodFactory(BuilderFactory): # Registry of available solution methods _solution_methods: Dict[str, Type[SolutionBuilder]] = { "subscription": SubscriptionBuilder, + "emandate": EmandateBuilder, + "emandateb2b": EmandateB2BBuilder, } @classmethod diff --git a/examples/emandate.py b/examples/emandate.py new file mode 100644 index 0000000..8fae829 --- /dev/null +++ b/examples/emandate.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Demo of the eMandate solution (B2C ``emandate`` + B2B ``emandateb2b``). + +eMandate is a DataRequest-based solution for managing SEPA direct debit +mandates: list issuers, create a mandate, check its status, modify it, and +cancel it. The retail (B2C, service ``emandate``) and business (B2B, service +``emandateb2b``) variants share the same five actions; only the service name +differs. Each demo below runs against both variants. Gated on +``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + +SERVICES = ("emandate", "emandateb2b") + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def demo_issuer_list() -> None: + """List available eMandate issuers via GetIssuerList (no parameters).""" + print("\n1. Issuer list") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution(service).issuer_list() + print(f" [{service}] status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def demo_create_mandate() -> None: + """Create a mandate via CreateMandate. Only ``debtorReference`` is required.""" + print("\n2. Create mandate") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution( + service, + { + "service_parameters": { + "debtorReference": "DEBTOR-DEMO-001", + "debtorBankId": "ABNANL2A", + "sequenceType": "1", + "purchaseId": "EMANDATE-DEMO-001", + "language": "nl", + } + }, + ).create_mandate() + mandate_id = response.get_service_parameter("MandateId") + print(f" [{service}] status.code={response.status.code.code} mandateId={mandate_id}") + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def demo_status() -> None: + """Look up a mandate's status via GetStatus. ``mandateId`` is required.""" + print("\n3. Mandate status") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution( + service, + {"service_parameters": {"mandateId": "MND-DEMO-001"}}, + ).status() + mandate_status = response.get_service_parameter("Status") + print( + f" [{service}] status.code={response.status.code.code} mandateStatus={mandate_status}" + ) + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def demo_modify_mandate() -> None: + """Modify a mandate via ModifyMandate. ``mandateId`` is required.""" + print("\n4. Modify mandate") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution( + service, + { + "service_parameters": { + "mandateId": "MND-DEMO-001", + "maxAmount": "1000.00", + } + }, + ).modify_mandate() + print(f" [{service}] status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def demo_cancel_mandate() -> None: + """Cancel a mandate via CancelMandate. ``mandateId`` is required.""" + print("\n5. Cancel mandate") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution( + service, + { + "service_parameters": { + "mandateId": "MND-DEMO-001", + "purchaseId": "EMANDATE-DEMO-001", + } + }, + ).cancel_mandate() + print(f" [{service}] status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — EMANDATE DEMO") + print("=" * 60) + + demo_issuer_list() + demo_create_mandate() + demo_status() + demo_modify_mandate() + demo_cancel_mandate() + + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tests/feature/solutions/test_emandate.py b/tests/feature/solutions/test_emandate.py new file mode 100644 index 0000000..e43750b --- /dev/null +++ b/tests/feature/solutions/test_emandate.py @@ -0,0 +1,246 @@ +import pytest + +from buckaroo.exceptions._parameter_validation_error import RequiredParameterMissingError +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers +from tests.support.recording_mock import ( + recorded_action, + recorded_request, + recorded_service_parameters, +) + + +class TestEmandateFeature: + """Feature tests for the eMandate solution.""" + + def test_issuer_list_returns_issuers(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "GetIssuerList", + "Parameters": [ + {"Name": "Issuer", "Value": "ABNANL2A"}, + {"Name": "IssuerName", "Value": "ABN AMRO"}, + ], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution("emandate").issuer_list() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert response.get_service_parameter("Issuer") == "ABNANL2A" + assert response.get_service_parameter("IssuerName") == "ABN AMRO" + assert recorded_action(mock_strategy) == "GetIssuerList" + + def test_emandate_is_available(self, buckaroo): + assert buckaroo.solutions.is_method_supported("emandate") + + def test_create_mandate_posts_service_parameters_and_returns_mandate_id( + self, buckaroo, mock_strategy + ): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "CreateMandate", + "Parameters": [{"Name": "MandateId", "Value": "MND-999"}], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandate", + { + "service_parameters": { + "debtorReference": "DEBTOR-999", + "sequenceType": "1", + "purchaseId": "PUR-1", + } + }, + ).create_mandate() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert response.get_service_parameter("MandateId") == "MND-999" + assert recorded_action(mock_strategy) == "CreateMandate" + + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Debtorreference"] == "DEBTOR-999" + assert params["Sequencetype"] == "1" + assert params["Purchaseid"] == "PUR-1" + + def test_create_mandate_without_debtor_reference_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("emandate") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_mandate() + + assert exc.value.parameter_name == "debtorReference" + + def test_status_posts_mandate_id_and_returns_status(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "GetStatus", + "Parameters": [{"Name": "Status", "Value": "Active"}], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandate", + {"service_parameters": {"mandateId": "MND-777"}}, + ).status() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert response.get_service_parameter("Status") == "Active" + assert recorded_action(mock_strategy) == "GetStatus" + + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Mandateid"] == "MND-777" + + def test_status_without_mandate_id_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("emandate") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.status() + + assert exc.value.parameter_name == "mandateId" + + def test_modify_mandate_posts_service_parameters_and_returns_mandate_id( + self, buckaroo, mock_strategy + ): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "ModifyMandate", + "Parameters": [{"Name": "MandateId", "Value": "MND-555"}], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandate", + { + "service_parameters": { + "mandateId": "MND-555", + "maxAmount": "1000.00", + } + }, + ).modify_mandate() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert response.get_service_parameter("MandateId") == "MND-555" + assert recorded_action(mock_strategy) == "ModifyMandate" + + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Mandateid"] == "MND-555" + assert params["Maxamount"] == "1000.00" + + def test_modify_mandate_without_mandate_id_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("emandate") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.modify_mandate() + + assert exc.value.parameter_name == "mandateId" + + def test_cancel_mandate_posts_service_parameters_and_returns_response( + self, buckaroo, mock_strategy + ): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "CancelMandate", + "Parameters": [], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandate", + { + "service_parameters": { + "mandateId": "MND-321", + "purchaseId": "PUR-2", + } + }, + ).cancel_mandate() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "CancelMandate" + + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Mandateid"] == "MND-321" + assert params["Purchaseid"] == "PUR-2" + + def test_cancel_mandate_without_mandate_id_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("emandate") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.cancel_mandate() + + assert exc.value.parameter_name == "mandateId" + + +class TestEmandateB2BFeature: + """Feature tests for the Business (B2B) eMandate variant.""" + + def test_b2b_is_available(self, buckaroo): + assert buckaroo.solutions.is_method_supported("emandateb2b") + + def test_create_mandate_posts_under_emandateb2b_service_name(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandateb2b", + "Action": "CreateMandate", + "Parameters": [{"Name": "MandateId", "Value": "MND-B2B-1"}], + } + ], + "ServiceCode": "emandateb2b", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandateb2b", + {"service_parameters": {"debtorReference": "DEBTOR-B2B-1"}}, + ).create_mandate() + + assert response.status.code.code == 190 + assert response.get_service_parameter("MandateId") == "MND-B2B-1" + assert recorded_action(mock_strategy) == "CreateMandate" + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + assert service["Name"].lower() == "emandateb2b" diff --git a/tests/unit/builders/solutions/test_concrete_solutions_contract.py b/tests/unit/builders/solutions/test_concrete_solutions_contract.py index dc99b28..e66135f 100644 --- a/tests/unit/builders/solutions/test_concrete_solutions_contract.py +++ b/tests/unit/builders/solutions/test_concrete_solutions_contract.py @@ -28,6 +28,8 @@ # ``SolutionMethodFactory._solution_methods``. CANONICAL_ACTIONS: Dict[str, str] = { "subscription": "CreateSubscription", + "emandate": "GetIssuerList", + "emandateb2b": "GetIssuerList", } diff --git a/tests/unit/builders/solutions/test_emandate_builder.py b/tests/unit/builders/solutions/test_emandate_builder.py new file mode 100644 index 0000000..9370f66 --- /dev/null +++ b/tests/unit/builders/solutions/test_emandate_builder.py @@ -0,0 +1,301 @@ +"""Unit tests for :class:`EmandateBuilder`.""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.solutions.emandate_builder import EmandateB2BBuilder, EmandateBuilder +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from buckaroo.exceptions._parameter_validation_error import RequiredParameterMissingError +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action + + +def test_construction_with_client_succeeds(client): + builder = EmandateBuilder(client) + assert isinstance(builder, EmandateBuilder) + assert isinstance(builder, SolutionBuilder) + + +def test_get_service_name_returns_emandate(client): + assert EmandateBuilder(client).get_service_name() == "emandate" + + +def test_get_allowed_service_parameters_get_issuer_list_returns_empty(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("GetIssuerList") == {} + + +def test_get_allowed_service_parameters_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("getissuerlist") == ( + builder.get_allowed_service_parameters("GetIssuerList") + ) + + +def test_get_allowed_service_parameters_create_mandate_marks_debtor_reference_required(client): + builder = EmandateBuilder(client) + params = builder.get_allowed_service_parameters("CreateMandate") + + assert set(params.keys()) == { + "debtorBankId", + "debtorReference", + "sequenceType", + "purchaseId", + "language", + "emandateReason", + "maxAmount", + } + assert params["debtorReference"]["required"] is True + for name, config in params.items(): + if name != "debtorReference": + assert config["required"] is False, f"{name} should be optional" + + +def test_get_allowed_service_parameters_create_mandate_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("createmandate") == ( + builder.get_allowed_service_parameters("CreateMandate") + ) + + +def test_issuer_list_posts_to_data_request_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-123", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "GetIssuerList", + "Parameters": [ + {"Name": "Issuer", "Value": "ABNANL2A"}, + {"Name": "IssuerName", "Value": "ABN AMRO"}, + ], + } + ], + }, + ) + ) + + response = EmandateBuilder(client).issuer_list() + + assert response.key == "emandate-key-123" + assert response.get_service_parameter("Issuer") == "ABNANL2A" + assert response.get_service_parameter("IssuerName") == "ABN AMRO" + assert recorded_action(mock_strategy) == "GetIssuerList" + + +def test_create_mandate_posts_to_data_request_and_parses_mandate_id(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-456", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "CreateMandate", + "Parameters": [ + {"Name": "MandateId", "Value": "MND-001"}, + ], + } + ], + }, + ) + ) + + builder = EmandateBuilder(client) + builder.add_parameter("debtorReference", "DEBTOR-001") + response = builder.create_mandate() + + assert response.key == "emandate-key-456" + assert response.get_service_parameter("MandateId") == "MND-001" + assert recorded_action(mock_strategy) == "CreateMandate" + + +def test_create_mandate_raises_when_debtor_reference_missing(client): + builder = EmandateBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_mandate() + + assert exc.value.parameter_name == "debtorReference" + + +def test_get_allowed_service_parameters_get_status_marks_mandate_id_required(client): + builder = EmandateBuilder(client) + params = builder.get_allowed_service_parameters("GetStatus") + + assert set(params.keys()) == {"mandateId"} + assert params["mandateId"]["required"] is True + + +def test_get_allowed_service_parameters_get_status_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("getstatus") == ( + builder.get_allowed_service_parameters("GetStatus") + ) + + +def test_status_posts_to_data_request_and_parses_status(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-789", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "GetStatus", + "Parameters": [ + {"Name": "Status", "Value": "Active"}, + ], + } + ], + }, + ) + ) + + builder = EmandateBuilder(client) + builder.add_parameter("mandateId", "MND-001") + response = builder.status() + + assert response.key == "emandate-key-789" + assert response.get_service_parameter("Status") == "Active" + assert recorded_action(mock_strategy) == "GetStatus" + + +def test_status_raises_when_mandate_id_missing(client): + builder = EmandateBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.status() + + assert exc.value.parameter_name == "mandateId" + + +def test_get_allowed_service_parameters_modify_mandate_marks_mandate_id_required(client): + builder = EmandateBuilder(client) + params = builder.get_allowed_service_parameters("ModifyMandate") + + assert set(params.keys()) == {"mandateId", "maxAmount", "language", "emandateReason"} + assert params["mandateId"]["required"] is True + for name, config in params.items(): + if name != "mandateId": + assert config["required"] is False, f"{name} should be optional" + + +def test_get_allowed_service_parameters_modify_mandate_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("modifymandate") == ( + builder.get_allowed_service_parameters("ModifyMandate") + ) + + +def test_modify_mandate_posts_to_data_request_and_parses_mandate_id(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-321", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "ModifyMandate", + "Parameters": [ + {"Name": "MandateId", "Value": "MND-002"}, + ], + } + ], + }, + ) + ) + + builder = EmandateBuilder(client) + builder.add_parameter("mandateId", "MND-002") + builder.add_parameter("maxAmount", "500.00") + response = builder.modify_mandate() + + assert response.key == "emandate-key-321" + assert response.get_service_parameter("MandateId") == "MND-002" + assert recorded_action(mock_strategy) == "ModifyMandate" + + +def test_modify_mandate_raises_when_mandate_id_missing(client): + builder = EmandateBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.modify_mandate() + + assert exc.value.parameter_name == "mandateId" + + +def test_get_allowed_service_parameters_cancel_mandate_marks_mandate_id_required(client): + builder = EmandateBuilder(client) + params = builder.get_allowed_service_parameters("CancelMandate") + + assert set(params.keys()) == {"mandateId", "purchaseId"} + assert params["mandateId"]["required"] is True + assert params["purchaseId"]["required"] is False + + +def test_get_allowed_service_parameters_cancel_mandate_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("cancelmandate") == ( + builder.get_allowed_service_parameters("CancelMandate") + ) + + +def test_cancel_mandate_posts_to_data_request_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-654", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "CancelMandate", + "Parameters": [], + } + ], + }, + ) + ) + + builder = EmandateBuilder(client) + builder.add_parameter("mandateId", "MND-003") + response = builder.cancel_mandate() + + assert response.key == "emandate-key-654" + assert recorded_action(mock_strategy) == "CancelMandate" + + +def test_cancel_mandate_raises_when_mandate_id_missing(client): + builder = EmandateBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.cancel_mandate() + + assert exc.value.parameter_name == "mandateId" + + +def test_b2b_get_service_name_returns_emandateb2b(client): + assert EmandateB2BBuilder(client).get_service_name() == "emandateb2b" + + +def test_b2b_builder_is_a_subclass_of_emandate_builder(client): + builder = EmandateB2BBuilder(client) + assert isinstance(builder, EmandateBuilder) diff --git a/tests/unit/factories/test_solution_method_factory.py b/tests/unit/factories/test_solution_method_factory.py index 552500a..15e76a4 100644 --- a/tests/unit/factories/test_solution_method_factory.py +++ b/tests/unit/factories/test_solution_method_factory.py @@ -6,6 +6,7 @@ from buckaroo.builders.solutions.solution_builder import SolutionBuilder from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder from buckaroo.builders.solutions.default_builder import DefaultBuilder +from buckaroo.builders.solutions.emandate_builder import EmandateB2BBuilder, EmandateBuilder @pytest.fixture(autouse=True) @@ -106,6 +107,13 @@ def test_detect_method_from_payload_lowercases_uppercase_method(): ) +def test_create_builder_resolves_both_emandate_keys(client): + assert isinstance(SolutionMethodFactory.create_builder("emandate", client), EmandateBuilder) + assert isinstance( + SolutionMethodFactory.create_builder("emandateb2b", client), EmandateB2BBuilder + ) + + # SolutionMethodFactory.detect_method_from_payload deliberately does NOT warn # on fallback — diverges from PaymentMethodFactory. Locked in here. @pytest.mark.parametrize("payload", [{}, {"other": "thing"}], ids=["empty", "missing_method_key"])