diff --git a/buckaroo/builders/payments/in3_builder.py b/buckaroo/builders/payments/in3_builder.py index 71e5dab..3df0ca8 100644 --- a/buckaroo/builders/payments/in3_builder.py +++ b/buckaroo/builders/payments/in3_builder.py @@ -1,8 +1,9 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder +from .capabilities.authorize_capture_capable import AuthorizeCaptureCapable -class In3Builder(PaymentBuilder): +class In3Builder(PaymentBuilder, AuthorizeCaptureCapable): """Builder for IN3 payments with bank transfer capabilities.""" def get_service_name(self) -> str: @@ -12,8 +13,8 @@ def get_service_name(self) -> str: def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for IN3 payments based on action.""" - if action.lower() in ["pay"]: - return { + if action.lower() in ["pay", "authorize"]: + params = { "billingCustomer": { "type": list, "required": True, @@ -27,4 +28,16 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: "article": {"type": list, "required": True, "description": "IN3 articles"}, } + # Authorize is only used for the ABN AMRO "Zakelijk op rekening" + # (business-on-account) flow, which the gateway routes via the + # required Route parameter (e.g. "abn_b2b"). Pay does not use it. + if action.lower() == "authorize": + params["route"] = { + "type": str, + "required": True, + "description": 'Financing route, e.g. "abn_b2b" for ABN AMRO business', + } + + return params + return {} diff --git a/examples/in3.py b/examples/in3.py new file mode 100644 index 0000000..afe2157 --- /dev/null +++ b/examples/in3.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Demo of the In3 payment method. + +Shows the regular ``.pay()`` flow and the ABN-AMRO "Zakelijk op rekening" +(business-on-account) ``.authorize()`` / ``.capture()`` flow. All demos are 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 + + +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_pay() -> None: + """Regular In3 flow: pay in three installments.""" + print("\n1. In3 pay") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment( + "in3", + { + "currency": "EUR", + "amount": 250.00, + "description": "in3 pay demo", + "invoice": "IN3-PAY-001", + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": { + "article": [ + {"Description": "Widget", "Quantity": "2", "GrossUnitPrice": "125.00"}, + ], + "billingCustomer": [_customer()], + "shippingCustomer": [_customer()], + }, + }, + ).pay() + print(f" status.code={response.status.code.code} redirect={response.get_redirect_url()}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_authorize_capture() -> None: + """ABN-AMRO "Zakelijk op rekening": authorize first, capture later. + + This business-on-account flow uses ``.authorize()`` instead of ``.pay()`` and + requires the ``route`` service parameter set to ``"AbnB2b"``. Note the exact + Buckaroo field names: ``GrossUnitPrice`` (not Price), ``StreetNumber`` (not + house number), ``CountryCode`` (not country). Capture needs no service params. + """ + print("\n2. In3 authorize / capture (ABN-AMRO Zakelijk op rekening)") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + + billing = _customer() + billing.update({"Category": "B2B", "CompanyName": "Acme B.V.", "CocNumber": "12345678"}) + + authorize_response = app.payments.create_payment( + "in3", + { + "currency": "EUR", + "amount": 250.00, + "description": "in3 authorize demo", + "invoice": "IN3-AUTH-001", + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": { + "route": "AbnB2b", + "article": [ + {"Description": "Widget", "Quantity": "2", "GrossUnitPrice": "125.00"}, + ], + "billingCustomer": [billing], + "shippingCustomer": [_customer()], + }, + }, + ).authorize() + print( + f" authorize: status.code={authorize_response.status.code.code} " + f"key={authorize_response.key}" + ) + + # Once the order is confirmed, capture the authorized amount using the + # transaction key from the authorize response. + capture_response = app.payments.create_payment( + "in3", + { + "currency": "EUR", + "amount": 250.00, + "description": "in3 capture demo", + "invoice": "IN3-AUTH-001", + }, + ).capture(original_transaction_key=authorize_response.key, amount=250.00) + print(f" capture: status.code={capture_response.status.code.code}") + except Exception as e: + print(f" ❌ {e}") + + +def _customer() -> dict: + """A minimal In3 customer using the exact Buckaroo field names.""" + return { + "FirstName": "John", + "LastName": "Doe", + "Phone": "0612345678", + "Email": "john@example.com", + "Street": "Hoofdstraat", + "StreetNumber": "1", + "PostalCode": "1000AA", + "City": "Amsterdam", + "CountryCode": "NL", + } + + +def main() -> None: + print("BUCKAROO SDK — IN3 DEMOS") + print("=" * 60) + demo_pay() + demo_authorize_capture() + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tests/feature/payments/test_in3.py b/tests/feature/payments/test_in3.py index 96bfd7f..2071711 100644 --- a/tests/feature/payments/test_in3.py +++ b/tests/feature/payments/test_in3.py @@ -1,6 +1,8 @@ """Feature test: in3 pay() round-trip through full stack with MockBuckaroo.""" from tests.support.helpers import Helpers +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_service_parameters class TestIn3Feature: @@ -13,7 +15,7 @@ def test_in3_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): payload_overrides={"amount": 25.00, "description": "Test in3"}, service_params={ "article": [ - {"description": "Widget", "quantity": "2", "price": "12.50"}, + {"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}, ], "billingCustomer": [ {"firstName": "John", "lastName": "Doe"}, @@ -23,3 +25,108 @@ def test_in3_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): ], }, ) + + def test_in3_authorize_returns_pending_with_redirect(self, buckaroo, mock_strategy): + def add_service_params(builder): + builder.add_parameter("route", "AbnB2b") + builder.add_parameter( + "article", [{"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}] + ) + builder.add_parameter("billingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + builder.add_parameter("shippingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + + Helpers.assert_action_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="in3", + invoice="INV-IN3-002", + action_name="Authorize", + call_method="authorize", + payload_overrides={"amount": 25.00, "description": "Test in3 authorize"}, + extra_builder_setup=add_service_params, + ) + + def test_in3_authorize_then_capture_round_trip(self, buckaroo, mock_strategy): + auth_response_body = Helpers.pending_redirect_response("in3", "Authorize") + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", auth_response_body) + ) + + builder = buckaroo.payments.create_payment( + "in3", + Helpers.standard_payload( + invoice="INV-IN3-003", amount=25.00, description="Test in3 authorize" + ), + ) + builder.add_parameter("route", "AbnB2b") + builder.add_parameter( + "article", [{"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}] + ) + builder.add_parameter("billingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + builder.add_parameter("shippingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + authorize_response = builder.authorize() + assert authorize_response.is_pending() + + capture_response_body = Helpers.success_response( + { + "Services": [{"Name": "in3", "Action": "Capture", "Parameters": []}], + "ServiceCode": "in3", + } + ) + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", capture_response_body) + ) + capture_response = builder.capture( + original_transaction_key=authorize_response.key, amount=25.00 + ) + + assert capture_response.status.code.code == 190 + assert capture_response.key == capture_response_body["Key"] + + def test_in3_authorize_with_business_customer_returns_pending_with_redirect( + self, buckaroo, mock_strategy + ): + """ABN-AMRO "Zakelijk op rekening" (business-on-account) runs through the + In3 Authorize action with route="AbnB2b". The business customer is marked + by Category=B2B/CompanyName/CocNumber inside billingCustomer; those + sub-fields flow through the existing billingCustomer list group. + """ + + def add_service_params(builder): + builder.add_parameter("route", "AbnB2b") + builder.add_parameter( + "article", [{"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}] + ) + builder.add_parameter( + "billingCustomer", + [ + { + "Category": "B2B", + "CompanyName": "Acme B.V.", + "CocNumber": "12345678", + "firstName": "John", + "lastName": "Doe", + } + ], + ) + builder.add_parameter("shippingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + + Helpers.assert_action_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="in3", + invoice="INV-IN3-004", + action_name="Authorize", + call_method="authorize", + payload_overrides={"amount": 25.00, "description": "Test in3 authorize B2B"}, + extra_builder_setup=add_service_params, + ) + + billing_params = { + param["Name"]: param["Value"] + for param in recorded_service_parameters(mock_strategy) + if param["GroupType"] == "Billingcustomer" + } + assert billing_params["Category"] == "B2B" + assert billing_params["Companyname"] == "Acme B.V." + assert billing_params["Cocnumber"] == "12345678" diff --git a/tests/unit/builders/payments/test_concrete_builders_contract.py b/tests/unit/builders/payments/test_concrete_builders_contract.py index a66dbe8..181974a 100644 --- a/tests/unit/builders/payments/test_concrete_builders_contract.py +++ b/tests/unit/builders/payments/test_concrete_builders_contract.py @@ -144,6 +144,7 @@ def test_capability_method_present_and_callable( EXPECTED_CAPABILITIES: Dict[str, set] = { "creditcard": {EncryptedPayCapable, AuthorizeCaptureCapable}, "riverty": {AuthorizeCaptureCapable}, + "in3": {AuthorizeCaptureCapable}, "ideal": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, "paybybank": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, "payconiq": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, diff --git a/tests/unit/builders/payments/test_in3_builder.py b/tests/unit/builders/payments/test_in3_builder.py index fef7206..181ea7a 100644 --- a/tests/unit/builders/payments/test_in3_builder.py +++ b/tests/unit/builders/payments/test_in3_builder.py @@ -54,7 +54,24 @@ def test_get_allowed_service_parameters_pay_is_case_insensitive(client): ) -@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "UnknownAction"]) +def test_get_allowed_service_parameters_authorize_snapshot(client): + builder = In3Builder(client) + + authorize = builder.get_allowed_service_parameters("Authorize") + + # Authorize allows every Pay param plus the required Route parameter + # (In3 Authorize only exists for the ABN AMRO "Zakelijk op rekening" flow). + assert authorize == { + **builder.get_allowed_service_parameters("Pay"), + "route": { + "type": str, + "required": True, + "description": 'Financing route, e.g. "abn_b2b" for ABN AMRO business', + }, + } + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "UnknownAction"]) def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): assert In3Builder(client).get_allowed_service_parameters(action) == {}