diff --git a/README.md b/README.md index ee090a3..b5b1a91 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - [Requirements](#requirements) - [Pip Installation](#pip-installation) - [Example](#example) +- [iDIN](#idin) - [Contribute](#contribute) - [Versioning](#versioning) - [Additional information](#additional-information) @@ -93,6 +94,25 @@ response = ( Find our full documentation online on [docs.buckaroo.io](https://docs.buckaroo.io). +### iDIN + +iDIN lets Dutch banks confirm a consumer's identity on your behalf. It carries no amount or currency — only the return URLs plus the `issuerId` (BIC code of the consumer's bank) service parameter. Three actions are available: `identify()`, `verify()` (age 18+), and `login()`. + +```python +response = payments.create_payment("idin", { + "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": {"issuerId": "BANKNL2Y"}, # sandbox issuer +}).identify() + +print("key:", response.key) +print("redirect:", response.get_redirect_url()) +``` + +See [`examples/idin.py`](examples/idin.py) for a runnable demo of all three actions. + ### Contribute We really appreciate it when developers contribute to improve the Buckaroo plugins. diff --git a/buckaroo/builders/payments/idin_builder.py b/buckaroo/builders/payments/idin_builder.py new file mode 100644 index 0000000..7a3ec80 --- /dev/null +++ b/buckaroo/builders/payments/idin_builder.py @@ -0,0 +1,78 @@ +from __future__ import annotations +from typing import Dict, Any +from ...models.payment_response import PaymentResponse +from .payment_builder import PaymentBuilder + + +class IdinBuilder(PaymentBuilder): + """Builder for iDIN identification, age verification, and login. + + iDIN lets Dutch banks confirm a consumer's identity on the merchant's + behalf. Every action is a DataRequest keyed on a single ``issuerId`` + (BIC code of the consumer's bank) service parameter: + + - ``identify()``: full identification, returns the bank-registered + customer details on the async push. + - ``verify()``: age verification (18+). + - ``login()``: returns a unique consumer ID for login purposes. + + The immediate response only carries ``Status`` and the redirect + (``RequiredAction.RedirectURL``); the bank's personal data arrives later + via push and is already parseable by the existing :class:`PaymentResponse`. + """ + + def required_fields(self, action: str = "identify") -> Dict[str, Any]: + """ + Get the required fields for this payment method. + + iDIN carries no amount or currency; only the ReturnURL family is + required. + + Returns: + Dict[str, Any]: Dictionary mapping field names to their current values + """ + return { + "return_url": self._return_url, + "return_url_cancel": self._return_url_cancel, + "return_url_error": self._return_url_error, + "return_url_reject": self._return_url_reject, + } + + def get_service_name(self) -> str: + """Get the service name for iDIN requests.""" + return "Idin" + + def get_allowed_service_parameters(self, action: str = "identify") -> Dict[str, Any]: + """Get the allowed service parameters for iDIN requests based on action.""" + + if action.lower() in ["identify", "verify", "login"]: + return { + "issuerId": { + "type": str, + "required": True, + "description": "BIC code of the issuing bank of the consumer", + }, + } + + return {} + + def identify(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: + """Request full identification from the consumer's bank.""" + payment_request = self.build( + "identify", validate=validate, strict_validation=strict_validation + ) + return self._post_data_request(payment_request.to_dict()) + + def verify(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: + """Verify whether the consumer is 18 years or older.""" + payment_request = self.build( + "verify", validate=validate, strict_validation=strict_validation + ) + return self._post_data_request(payment_request.to_dict()) + + def login(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: + """Request a unique consumer ID for login purposes.""" + payment_request = self.build( + "login", validate=validate, strict_validation=strict_validation + ) + return self._post_data_request(payment_request.to_dict()) diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index 8a43f6b..9f6890a 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -18,6 +18,7 @@ from buckaroo.builders.payments.giftcards_builder import GiftcardsBuilder from buckaroo.builders.payments.google_pay_builder import GooglePayBuilder from buckaroo.builders.payments.ideal_qr_builder import IdealQrBuilder +from buckaroo.builders.payments.idin_builder import IdinBuilder from buckaroo.builders.payments.in3_builder import In3Builder from buckaroo.builders.payments.kbc_builder import KBCBuilder from buckaroo.builders.payments.billink_builder import BillinkBuilder @@ -68,6 +69,7 @@ class PaymentMethodFactory(BuilderFactory): "googlepay": GooglePayBuilder, "ideal": IdealBuilder, "idealqr": IdealQrBuilder, + "idin": IdinBuilder, "in3": In3Builder, "kbc": KBCBuilder, "knaken": KnakenBuilder, diff --git a/examples/idin.py b/examples/idin.py new file mode 100644 index 0000000..61b3930 --- /dev/null +++ b/examples/idin.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Demo of the iDIN payment method. + +iDIN lets Dutch banks confirm a consumer's identity on the merchant's behalf. +Every action is a DataRequest keyed on a single ``issuerId`` (BIC code of the +consumer's bank) service parameter; the sandbox issuer is ``BANKNL2Y``. The +demo drives all three actions: identify, verify (age 18+), and login. 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 + +_ISSUER_ID = "BANKNL2Y" + + +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 _idin_payload() -> dict: + # iDIN carries no amount/currency; only the ReturnURL family plus the + # issuerId service parameter (BIC code of the consumer's bank). + return { + "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": {"issuerId": _ISSUER_ID}, + } + + +def demo_identify() -> None: + """Request full identification from the consumer's bank.""" + print("\n1. iDIN identify") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment("idin", _idin_payload()).identify() + print(f" status.code={response.status.code.code} key={response.key}") + print(f" redirect={response.get_redirect_url()}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_verify() -> None: + """Verify whether the consumer is 18 years or older.""" + print("\n2. iDIN verify (age 18+)") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment("idin", _idin_payload()).verify() + print(f" status.code={response.status.code.code} key={response.key}") + print(f" redirect={response.get_redirect_url()}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_login() -> None: + """Request a unique consumer ID for login purposes.""" + print("\n3. iDIN login") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment("idin", _idin_payload()).login() + print(f" status.code={response.status.code.code} key={response.key}") + print(f" redirect={response.get_redirect_url()}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — IDIN DEMO") + print("=" * 60) + demo_identify() + demo_verify() + demo_login() + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tests/feature/payments/test_idin.py b/tests/feature/payments/test_idin.py new file mode 100644 index 0000000..d447562 --- /dev/null +++ b/tests/feature/payments/test_idin.py @@ -0,0 +1,79 @@ +"""Feature test: iDIN identify/verify/login round-trip through the full stack. + +Every iDIN action is a DataRequest carrying a single ``issuerId`` service +parameter (BIC code of the consumer's bank); the sandbox issuer is +``BANKNL2Y``. The immediate response only carries the redirect — bank +personal data arrives later via push — so this pins the action name, the +``issuerId`` reaching the wire, and ``response.key`` parsing from the mock. +""" + +from tests.support.helpers import Helpers +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_service_parameters + +_ISSUER_ID = "BANKNL2Y" + + +def _idin_payload(**overrides): + payload = { + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + "service_parameters": {"issuerId": _ISSUER_ID}, + } + payload.update(overrides) + return payload + + +class TestIdinFeature: + def test_identify_sends_action_and_issuer_id(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "Idin", "Action": "identify", "Parameters": []}], + "ServiceCode": "IDIN", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.payments.create_payment("idin", _idin_payload()).identify() + + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "identify" + + sent = {p["Name"].lower(): p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert sent["issuerid"] == _ISSUER_ID + + def test_verify_sends_action_and_issuer_id(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "Idin", "Action": "verify", "Parameters": []}], + "ServiceCode": "IDIN", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.payments.create_payment("idin", _idin_payload()).verify() + + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "verify" + + sent = {p["Name"].lower(): p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert sent["issuerid"] == _ISSUER_ID + + def test_login_sends_action_and_issuer_id(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "Idin", "Action": "login", "Parameters": []}], + "ServiceCode": "IDIN", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.payments.create_payment("idin", _idin_payload()).login() + + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "login" + + sent = {p["Name"].lower(): p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert sent["issuerid"] == _ISSUER_ID diff --git a/tests/unit/builders/payments/test_idin_builder.py b/tests/unit/builders/payments/test_idin_builder.py new file mode 100644 index 0000000..8683e6b --- /dev/null +++ b/tests/unit/builders/payments/test_idin_builder.py @@ -0,0 +1,85 @@ +"""Unit coverage for :class:`IdinBuilder`. + +iDIN drives three DataRequest actions — ``identify`` (full identification), +``verify`` (age verification), and ``login`` (unique consumer ID) — each +carrying a single ``issuerId`` (BIC code of the consumer's bank) service +parameter. Bank personal data arrives later via push; the immediate response +only carries the redirect, so no custom response model is needed. +""" + +from __future__ import annotations + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.idin_builder import IdinBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.factories.payment_method_factory import PaymentMethodFactory + + +def test_builder_instantiates_as_payment_builder(client: BuckarooClient) -> None: + builder = IdinBuilder(client) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_idin(client: BuckarooClient) -> None: + assert IdinBuilder(client).get_service_name() == "Idin" + + +_ISSUER_ID_SPEC = { + "issuerId": { + "type": str, + "required": True, + "description": "BIC code of the issuing bank of the consumer", + }, +} + + +def test_get_allowed_service_parameters_identify_requires_issuer_id( + client: BuckarooClient, +) -> None: + assert IdinBuilder(client).get_allowed_service_parameters("identify") == _ISSUER_ID_SPEC + + +def test_get_allowed_service_parameters_verify_requires_issuer_id( + client: BuckarooClient, +) -> None: + assert IdinBuilder(client).get_allowed_service_parameters("verify") == _ISSUER_ID_SPEC + + +def test_get_allowed_service_parameters_login_requires_issuer_id( + client: BuckarooClient, +) -> None: + assert IdinBuilder(client).get_allowed_service_parameters("login") == _ISSUER_ID_SPEC + + +def test_get_allowed_service_parameters_is_case_insensitive(client: BuckarooClient) -> None: + builder = IdinBuilder(client) + assert builder.get_allowed_service_parameters( + "Identify" + ) == builder.get_allowed_service_parameters("identify") + + +def test_get_allowed_service_parameters_unknown_action_returns_empty( + client: BuckarooClient, +) -> None: + assert IdinBuilder(client).get_allowed_service_parameters("Pay") == {} + + +def test_required_fields_only_return_url_family(client: BuckarooClient) -> None: + builder = ( + IdinBuilder(client) + .return_url("https://example.com/return") + .return_url_cancel("https://example.com/cancel") + .return_url_error("https://example.com/error") + .return_url_reject("https://example.com/reject") + ) + assert builder.required_fields("identify") == { + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + } + + +def test_create_builder_returns_idin_builder(client: BuckarooClient) -> None: + builder = PaymentMethodFactory.create_builder("idin", client) + assert isinstance(builder, IdinBuilder)