From a56994f70139d3a500417f03a2f0573deada62fd Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Apr 2026 10:55:58 +0800 Subject: [PATCH 01/21] Refactor builder architecture and modernize core infrastructure for v1.0.0 - Introduce a cleaner inheritance model with `BaseBuilder` and `PaymentBuilder`. - Decouple transaction execution from builders via a new `TransactionExecutor` service. - Implement `TransactionContext` to handle follow-up operations (refund, capture, cancel) explicitly instead of relying on internal payload state. - Standardize method naming to PEP 8 snake_case across all builders and capability mixins. - Enhance the HTTP client with better error wrapping, logging, and FIPS-compliant MD5 hashing. - Add GitHub Actions CI, a formal changelog, and modernized packaging metadata for the v1.0.0 release. --- .github/workflows/ci.yml | 54 ++ .gitignore | 10 + CHANGELOG.md | 23 + buckaroo/__init__.py | 7 + buckaroo/_buckaroo_client.py | 12 +- buckaroo/_version.py | 1 + buckaroo/app.py | 21 +- buckaroo/builders/base_builder.py | 273 ++------- .../capabilities/authorize_capture_capable.py | 79 +-- .../capabilities/encrypted_pay_capable.py | 19 +- .../capabilities/fast_checkout_capable.py | 23 +- .../capabilities/instant_refund_capable.py | 21 +- .../builders/payments/credit_card_builder.py | 95 +-- .../builders/payments/klarnakp_builder.py | 46 +- buckaroo/builders/payments/payment_builder.py | 580 ++++-------------- .../solutions/subscription_builder.py | 6 +- buckaroo/config/buckaroo_config.py | 16 +- buckaroo/exceptions/__init__.py | 10 + buckaroo/exceptions/_buckaroo_error.py | 30 +- .../exceptions/_parameter_validation_error.py | 53 +- buckaroo/factories/payment_method_factory.py | 2 +- buckaroo/http/client.py | 60 +- buckaroo/http/strategies/curl_strategy.py | 26 +- buckaroo/http/strategies/http_strategy.py | 82 +-- buckaroo/http/strategies/requests_strategy.py | 18 +- buckaroo/models/__init__.py | 19 +- buckaroo/models/payment_request.py | 75 ++- buckaroo/models/payment_response.py | 98 ++- buckaroo/models/transaction_context.py | 34 + buckaroo/observers/logging_observer.py | 7 +- buckaroo/py.typed | 0 .../services/service_parameter_validator.py | 63 +- buckaroo/services/transaction_service.py | 73 +++ pyproject.toml | 3 + requirements.txt | 11 +- setup.py | 24 +- 36 files changed, 771 insertions(+), 1203 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 buckaroo/__init__.py create mode 100644 buckaroo/_version.py create mode 100644 buckaroo/exceptions/__init__.py create mode 100644 buckaroo/models/transaction_context.py create mode 100644 buckaroo/py.typed create mode 100644 pyproject.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..151b086 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [master, develop] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 mypy types-requests + + - name: Flake8 + run: flake8 buckaroo/ --max-line-length=120 --extend-ignore=E501 + + - name: Mypy + run: mypy buckaroo/ --ignore-missing-imports + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Verify import + run: python -c "import buckaroo; print(buckaroo.__version__)" diff --git a/.gitignore b/.gitignore index a6cdbfe..8ddd79e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,16 @@ /.idea .DS_Store *.pyc +__pycache__/ +*.pyo + +# local environment .env +.venv/ +venv/ +# logs *.log + +# type checking +.mypy_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9b0ec87 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-04-15 + +### Added +- Initial release of the Buckaroo Python SDK +- Payment method builders for iDEAL, Credit Card, Apple Pay, Google Pay, Bancontact, Alipay, Sofort, Payconiq, iDEAL QR, KBC, MBWay, Multibanco, PayPal, Przelewy24, Riverty, SEPA Direct Debit, Swish, Transfer, Trustly, Twint, Voucher, WeChatPay, Wero, Giftcards, Belfius, Bizum, Blik, BuckarooVoucher, ClickToPay, EPS, ExternalPayment, In3, Klarna, KlarnaKP, Knaken, PayByBank +- Solution builders: SolutionBuilder, SubscriptionBuilder +- Strategy-based HTTP client (requests library or system curl) +- HMAC-SHA256 authentication following Buckaroo API specification +- Configurable logging observer with sensitive data masking +- Parameter validation with strict and non-strict modes +- Authorize/capture capability for credit card payments +- Encrypted payment support for credit cards +- Fast checkout support for iDEAL +- Instant refund support for applicable payment methods +- Factory pattern for payment method and solution builders +- Docker Compose setup for local development diff --git a/buckaroo/__init__.py b/buckaroo/__init__.py new file mode 100644 index 0000000..806163f --- /dev/null +++ b/buckaroo/__init__.py @@ -0,0 +1,7 @@ +""" +Buckaroo Python SDK +""" + +from buckaroo._version import VERSION + +__version__ = VERSION diff --git a/buckaroo/_buckaroo_client.py b/buckaroo/_buckaroo_client.py index 8930fef..606564d 100644 --- a/buckaroo/_buckaroo_client.py +++ b/buckaroo/_buckaroo_client.py @@ -1,6 +1,8 @@ +import logging from typing import Optional, Union from .exceptions._authentication_error import AuthenticationError +from .exceptions._buckaroo_error import BuckarooError from .config.buckaroo_config import BuckarooConfig, create_config_from_mode from .http.client import BuckarooHttpClient @@ -122,7 +124,15 @@ def confirm_credential(self) -> bool: try: response = self.http_client.get('/json/Transaction/Specification/ideal') return response.success - except Exception: + except BuckarooError: + raise + except Exception as e: + logging.warning( + "confirm_credential: unexpected error during credential check " + "(network issue or unexpected API response): %s", + e, + exc_info=True, + ) return False def get_config_info(self) -> dict: diff --git a/buckaroo/_version.py b/buckaroo/_version.py new file mode 100644 index 0000000..3277f64 --- /dev/null +++ b/buckaroo/_version.py @@ -0,0 +1 @@ +VERSION = "1.0.0" diff --git a/buckaroo/app.py b/buckaroo/app.py index 37aaec9..8751ff4 100644 --- a/buckaroo/app.py +++ b/buckaroo/app.py @@ -14,19 +14,18 @@ from buckaroo._buckaroo_client import BuckarooClient from buckaroo.observers import ( - BuckarooLoggingObserver, - create_logger, + BuckarooLoggingObserver, + create_logger, create_logger_from_env, - LogLevel, + LogLevel, LogDestination, LogConfig ) -from buckaroo.config.buckaroo_config import BuckarooConfig from buckaroo.exceptions._authentication_error import AuthenticationError @dataclass -class BuckarooConfig: +class BuckarooAppConfig: """Configuration for Buckaroo Application.""" # API Configuration store_key: Optional[str] = None @@ -45,7 +44,7 @@ class BuckarooConfig: retry_attempts: int = 3 @classmethod - def from_env(cls) -> 'BuckarooConfig': + def from_env(cls) -> 'BuckarooAppConfig': """Create configuration from environment variables.""" # Get log level from env log_level_str = os.getenv("BUCKAROO_LOG_LEVEL", "INFO").upper() @@ -62,7 +61,7 @@ def from_env(cls) -> 'BuckarooConfig': mode=os.getenv("BUCKAROO_MODE", "test"), log_level=log_level, log_destination=log_destination, - log_file=os.getenv("BUCKAROO_LOG_FILE", "buckaroo_app.log"), + log_file=os.path.basename(os.getenv("BUCKAROO_LOG_FILE", "buckaroo_app.log")), mask_sensitive_data=os.getenv("BUCKAROO_LOG_MASK_SENSITIVE", "true").lower() == "true", timeout=int(os.getenv("BUCKAROO_TIMEOUT", "30")), retry_attempts=int(os.getenv("BUCKAROO_RETRY_ATTEMPTS", "3")) @@ -82,14 +81,14 @@ class Buckaroo: >>> response = app.execute_payment(payment) """ - def __init__(self, config: Optional[BuckarooConfig] = None): + def __init__(self, config: Optional[BuckarooAppConfig] = None): """ Initialize Buckaroo Application. Args: config: Application configuration. If None, uses environment variables. """ - self.config = config or BuckarooConfig.from_env() + self.config = config or BuckarooAppConfig.from_env() self.logger: Optional[BuckarooLoggingObserver] = None self.client: Optional[BuckarooClient] = None @@ -100,7 +99,7 @@ def __init__(self, config: Optional[BuckarooConfig] = None): @classmethod def from_env(cls) -> 'Buckaroo': """Create Buckaroo app from environment variables.""" - return cls(BuckarooConfig.from_env()) + return cls(BuckarooAppConfig.from_env()) @classmethod def quick_setup(cls, store_key: str, secret_key: str, mode: str = "test", @@ -117,7 +116,7 @@ def quick_setup(cls, store_key: str, secret_key: str, mode: str = "test", Returns: Configured Buckaroo app """ - config = BuckarooConfig( + config = BuckarooAppConfig( store_key=store_key, secret_key=secret_key, mode=mode, diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index f713ac5..ab96daf 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -1,16 +1,30 @@ from abc import ABC, abstractmethod from typing import Dict, Any, Optional, List +try: + from typing import Self +except ImportError: + from typing_extensions import Self from ..models.payment_request import PaymentRequest, ClientIP, Service, ServiceList, Parameter from ..models.payment_response import PaymentResponse from ..services.service_parameter_validator import ServiceParameterValidator +from ..services.transaction_service import TransactionExecutor, ITransactionExecutor +from ..exceptions._parameter_validation_error import ParameterValidationError, RequiredParameterMissingError class BaseBuilder(ABC): """Abstract base class for all builders (payments and solutions).""" - - def __init__(self, client): - """Initialize with client instance.""" + + def __init__(self, client, executor: Optional[ITransactionExecutor] = None): + """Initialize with client instance. + + Args: + client: BuckarooClient instance. + executor: Optional transaction executor. If omitted a ``TransactionExecutor`` + is created automatically. Pass a mock here in unit tests to avoid + real HTTP calls. + """ self._client = client + self._executor: ITransactionExecutor = executor if executor is not None else TransactionExecutor(client) self._currency: Optional[str] = None self._amount_debit: Optional[float] = None self._description: Optional[str] = None @@ -25,69 +39,72 @@ def __init__(self, client): self._client_ip: Optional[ClientIP] = None self._service_parameters: List[Parameter] = [] self._payload: Dict[str, Any] = {} # Store original payload - self._validator = ServiceParameterValidator(self) + self._validator = ServiceParameterValidator( + self.get_allowed_service_parameters, + self.get_service_name, + ) - def currency(self, currency: str) -> 'BaseBuilder': + def currency(self, currency: str) -> Self: """Set the currency for the payment.""" self._currency = currency return self - def amount(self, amount: float) -> 'BaseBuilder': + def amount(self, amount: float) -> Self: """Set the amount for the payment.""" self._amount_debit = amount return self - def description(self, description: str) -> 'BaseBuilder': + def description(self, description: str) -> Self: """Set the description for the payment.""" self._description = description return self - def invoice(self, invoice: str) -> 'BaseBuilder': + def invoice(self, invoice: str) -> Self: """Set the invoice number for the payment.""" self._invoice = invoice return self - def return_url(self, url: str) -> 'BaseBuilder': + def return_url(self, url: str) -> Self: """Set the return URL for successful payment.""" self._return_url = url return self - def return_url_cancel(self, url: str) -> 'BaseBuilder': + def return_url_cancel(self, url: str) -> Self: """Set the return URL for cancelled payment.""" self._return_url_cancel = url return self - def return_url_error(self, url: str) -> 'BaseBuilder': + def return_url_error(self, url: str) -> Self: """Set the return URL for payment error.""" self._return_url_error = url return self - def return_url_reject(self, url: str) -> 'BaseBuilder': + def return_url_reject(self, url: str) -> Self: """Set the return URL for rejected payment.""" self._return_url_reject = url return self - def continue_on_incomplete(self, continue_incomplete: str) -> 'BaseBuilder': + def continue_on_incomplete(self, continue_incomplete: str) -> Self: """Set whether to continue on incomplete payment.""" self._continue_on_incomplete = continue_incomplete return self - def push_url(self, url: str) -> 'BaseBuilder': + def push_url(self, url: str) -> Self: """Set the Push (webhook) URL.""" self._push_url = url return self - def push_url_failure(self, url: str) -> 'BaseBuilder': + def push_url_failure(self, url: str) -> Self: """Set the Push URL for failure notifications.""" self._push_url_failure = url return self - def client_ip(self, ip_address: str, ip_type: int = 0) -> 'BaseBuilder': + def client_ip(self, ip_address: str, ip_type: int = 0) -> Self: """Set the client IP information.""" self._client_ip = ClientIP(type=ip_type, address=ip_address) return self - def add_parameter(self, key: str, value: Any, group_type: str = "", group_id: str = "") -> 'BaseBuilder': + def add_parameter(self, key: str, value: Any, group_type: Optional[str] = None, group_id: Optional[str] = None) -> Self: """Add a custom parameter to the service. Args: @@ -117,10 +134,10 @@ def add_parameter(self, key: str, value: Any, group_type: str = "", group_id: st str_value = str(value).lower() if isinstance(value, bool) else str(value) parameter = Parameter( - name=key.capitalize(), - value=str_value, - group_type=group_type.capitalize(), - group_id=group_id + name=key.capitalize(), + value=str_value, + group_type=group_type.capitalize() if group_type else None, + group_id=group_id, ) self._service_parameters.append(parameter) @@ -156,7 +173,7 @@ def _validate_and_filter_service_parameters(self, action: str = "Pay", strict: b self._service_parameters, action, strict=strict ) - def from_dict(self, data: Dict[str, Any]) -> 'BaseBuilder': + def from_dict(self, data: Dict[str, Any]) -> Self: """ Populate the builder from a dictionary of parameters. @@ -284,8 +301,12 @@ def _validate_required_fields(self, action: str = "Pay") -> None: action (str): The action being performed (Pay, Authorize, Refund, Capture, etc.) """ missing_fields = [field for field, value in self.required_fields(action).items() if value is None] - if missing_fields: - raise ValueError(f"Missing required fields: {', '.join(missing_fields)}") + if len(missing_fields) == 1: + raise RequiredParameterMissingError(missing_fields[0], action=action) + elif missing_fields: + raise ParameterValidationError( + f"Missing required fields: {', '.join(missing_fields)}", action=action + ) def build(self, action: str = "Pay", validate: bool = True, strict_validation: bool = False) -> PaymentRequest: """Build the payment request. @@ -336,204 +357,10 @@ def build(self, action: str = "Pay", validate: bool = True, strict_validation: b return payment_request - def pay(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: - """ - Execute the payment operation. - - Args: - validate (bool): Whether to validate service parameters before building - strict_validation (bool): If True, throws exceptions for missing required parameters - - Returns: - PaymentResponse: Structured payment response object - - Raises: - ValueError: If required fields are missing - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) - AuthenticationError: If authentication fails - BuckarooApiError: If API returns an error - """ - # Build the payment request - payment_request = self.build("Pay", validate=validate, strict_validation=strict_validation) - - # Convert to dictionary for API - request_data = payment_request.to_dict() - - return self._post_transaction(request_data) - - def refund(self, validate: bool = True) -> PaymentResponse: - """ - Execute a refund transaction. - - Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The refund response - - Raises: - ValueError: If required fields are missing - """ - # Get original_transaction_key from parameter or payload - txn_key = self._payload.get('originalTransactionKey') - if not txn_key: - raise ValueError("Original transaction key is required for refunds (provide as parameter or in payload)") - - # Get amount from parameter or payload - refund_amount = self._payload.get('refund_amount') - - # Build refund request with original transaction reference - payment_request = self.build('Refund', validate=validate) - - # Convert to dictionary and modify for refund - request_data = payment_request.to_dict() - request_data['OriginalTransactionKey'] = txn_key - - # Set refund amount if specified, otherwise use original amount - if refund_amount is not None: - request_data['AmountCredit'] = refund_amount - # Remove debit amount for refunds - if 'AmountDebit' in request_data: - del request_data['AmountDebit'] - else: - # Full refund - swap debit to credit - if 'AmountDebit' in request_data: - request_data['AmountCredit'] = request_data['AmountDebit'] - del request_data['AmountDebit'] - - return self._post_transaction(request_data) - - def capture(self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None, validate: bool = True) -> PaymentResponse: - """ - Capture a previously authorized payment. - - Args: - original_transaction_key (str, optional): The transaction key of the authorization. - If None, will try to get from payload. - amount (float, optional): Amount to capture. If None, will try to get from payload - or capture the full authorized amount. - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The capture response - """ - # Get authorization key from parameter or payload - auth_key = original_transaction_key or self._payload.get('authorization_key') or self._payload.get('original_transaction_key') - if not auth_key: - raise ValueError("Authorization key is required for captures (provide as parameter or in payload)") - - # Get capture amount from parameter or payload - capture_amount = amount or self._payload.get('capture_amount') - - # Build capture request - payment_request = self.build('Capture', validate=validate) - request_data = payment_request.to_dict() - - # Set capture-specific parameters - request_data['OriginalTransactionKey'] = auth_key - - # Set capture amount if specified - if capture_amount is not None: - request_data['AmountDebit'] = capture_amount - - return self._post_transaction(request_data) - - def cancel(self, original_transaction_key: Optional[str] = None) -> PaymentResponse: - """ - Cancel a pending or authorized transaction. - - Args: - original_transaction_key (str, optional): The transaction key to cancel. - If None, will try to get from payload. - - Returns: - PaymentResponse: The cancellation response - """ - # Get transaction key from parameter or payload - txn_key = original_transaction_key or self._payload.get('cancel_key') or self._payload.get('original_transaction_key') - if not txn_key: - raise ValueError("Transaction key is required for cancellations (provide as parameter or in payload)") - - # Build cancel request - payment_request = self.build() - request_data = payment_request.to_dict() - - # Set cancellation parameters - request_data['OriginalTransactionKey'] = txn_key - # Remove amounts for cancellation - request_data.pop('AmountDebit', None) - request_data.pop('AmountCredit', None) - - return self._post_transaction(request_data) - - def partial_refund(self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None) -> PaymentResponse: - """ - Execute a partial refund transaction. - - Args: - original_transaction_key (str, optional): The transaction key of the original payment. - If None, will try to get from payload. - amount (float, optional): Amount to refund. If None, will try to get from payload. - - Returns: - PaymentResponse: The partial refund response - - Raises: - ValueError: If amount is not provided or invalid - """ - # Get amount from parameter or payload - refund_amount = amount or self._payload.get('refund_amount') or self._payload.get('partial_refund_amount') - if not refund_amount or refund_amount <= 0: - raise ValueError("Partial refund amount must be greater than 0 (provide as parameter or in payload)") - - return self.refund(original_transaction_key, refund_amount) - def _post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse: - """Helper method to post data request and handle response.""" - # Send to Buckaroo API - response = self._client.http_client.post('/json/DataRequest', request_data) - - # Check if response is valid and convert to dict - if response is None: - # Return a PaymentResponse with empty data for None responses - return PaymentResponse({}) - - # Return structured response object - return PaymentResponse(response.to_dict()) - + """Post a data request to the Buckaroo API.""" + return self._executor.post_data_request(request_data) + 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) - - # Check if response is valid and convert to dict - if response is None: - # Return a PaymentResponse with empty data for None responses - return PaymentResponse({}) - - # Return structured response object - return PaymentResponse(response.to_dict()) - - def execute_action(self, action: str, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: - """ - Execute a custom action for the payment method. - - This is a generic method that can be used for any action supported - by the payment method (instantRefund, payFastCheckout, etc.). - - Args: - action (str): The action to execute - validate (bool): Whether to validate service parameters before building - strict_validation (bool): If True, throws exceptions for missing required parameters - - Returns: - PaymentResponse: The action response - - Raises: - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) - """ - payment_request = self.build(action, validate=validate, strict_validation=strict_validation) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) + """Post a transaction to the Buckaroo API.""" + return self._executor.post_transaction(request_data) diff --git a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py index 37a9ed6..17f5a7d 100644 --- a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py +++ b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py @@ -9,68 +9,40 @@ from typing import Optional, TYPE_CHECKING from ....models.payment_response import PaymentResponse +from ....models.transaction_context import TransactionContext if TYPE_CHECKING: from ..payment_builder import PaymentBuilder class AuthorizeCaptureCapable: """Mixin for payment methods that support authorization (Credit Card).""" - + def authorize(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - Authorize a payment without capturing it. - - Available for: Credit Card - Not available for: iDEAL, Sofort, PayConiq (immediate transfer) - - Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The authorization response - """ - payment_request = self.build("Authorize", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) - - def authorizeEncrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - Authorize a payment without capturing it. - - Available for: Credit Card - Not available for: iDEAL, Sofort, PayConiq (immediate transfer) - + """Authorize a payment without capturing it (Credit Card only).""" + return self.execute_action("Authorize", validate=validate) + + def authorize_encrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + """Authorize a payment using encrypted card data (Credit Card only).""" + return self.execute_action("AuthorizeEncrypted", validate=validate) + + def cancel_authorize(self: 'PaymentBuilder', ctx: Optional[TransactionContext] = None, validate: bool = True) -> PaymentResponse: + """Cancel a previously authorized payment. + Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The authorization response - """ - payment_request = self.build("AuthorizeEncrypted", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) - - def cancelAuthorize(self: 'PaymentBuilder', original_transaction_key: Optional[str] = None, validate: bool = True) -> PaymentResponse: - """ - Cancel a previously authorized payment. + ctx: Explicit transaction context. Falls back to ``_payload`` when omitted. Uses AmountCredit (not AmountDebit) per Buckaroo API requirements. """ txn_key = ( - original_transaction_key - or self._payload.get('original_transaction_key') - or self._payload.get('authorization_key') - ) + ctx.original_transaction_key if ctx else None + ) or self._payload.get('original_transaction_key') or self._payload.get('authorization_key') if not txn_key: raise ValueError( - "Original transaction key is required for cancelAuthorize " - "(provide 'original_transaction_key' in payload)" + "Original transaction key is required for cancel_authorize " + "(pass a TransactionContext or set 'original_transaction_key' in payload)" ) - payment_request = self.build("CancelAuthorize", validate=validate) - request_data = payment_request.to_dict() - - request_data['OriginalTransactionKey'] = txn_key + request_data = self._build_keyed_request("CancelAuthorize", txn_key, validate=validate) # Buckaroo API requires AmountCredit for cancel-authorize, not AmountDebit if 'AmountDebit' in request_data: @@ -79,17 +51,6 @@ def cancelAuthorize(self: 'PaymentBuilder', original_transaction_key: Optional[s return self._post_transaction(request_data) def capture(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - Capture a previously authorized payment. - - Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The capture response - """ - - payment_request = self.build("Capture", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) + """Capture a previously authorized payment.""" + return self.execute_action("Capture", validate=validate) \ No newline at end of file diff --git a/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py index 2bb539d..f80c051 100644 --- a/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py +++ b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py @@ -16,19 +16,6 @@ class EncryptedPayCapable: """Mixin for payment methods that support encryption (Credit Card).""" - def payEncrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - Process a payment with encryption. - - Available for: Credit Card - Not available for: iDEAL, Sofort, PayConiq (immediate transfer) - - Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The payment response - """ - payment_request = self.build("PayEncrypted", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) \ No newline at end of file + def pay_encrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + """Process a payment using encrypted card data (Credit Card only).""" + return self.execute_action("PayEncrypted", validate=validate) \ No newline at end of file diff --git a/buckaroo/builders/payments/capabilities/fast_checkout_capable.py b/buckaroo/builders/payments/capabilities/fast_checkout_capable.py index be1999a..eb160f6 100644 --- a/buckaroo/builders/payments/capabilities/fast_checkout_capable.py +++ b/buckaroo/builders/payments/capabilities/fast_checkout_capable.py @@ -16,23 +16,8 @@ class FastCheckoutCapable: """Mixin for payment methods that support fast checkout (iDEAL, Sofort, PayConiq).""" - - def payFastCheckout(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - Enable PayFast Checkout. - - Available for: iDEAL, Sofort, PayConiq - Not available for: Credit Card, PayPal - - Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The fast checkout response - """ - payment_request = self.build("payFastCheckout", validate=validate) - - request_data = payment_request.to_dict() - - return self._post_transaction(request_data) + + def pay_fast_checkout(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + """Enable PayFast Checkout (iDEAL, Sofort, PayConiq only).""" + return self.execute_action("payFastCheckout", validate=validate) diff --git a/buckaroo/builders/payments/capabilities/instant_refund_capable.py b/buckaroo/builders/payments/capabilities/instant_refund_capable.py index 59d9b5a..c9a5902 100644 --- a/buckaroo/builders/payments/capabilities/instant_refund_capable.py +++ b/buckaroo/builders/payments/capabilities/instant_refund_capable.py @@ -16,20 +16,7 @@ class InstantRefundCapable: """Mixin for payment methods that support instant refunds (iDEAL, Sofort, PayConiq).""" - - def instantRefund(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - Initiate an instant refund. - - Available for: iDEAL, Sofort, PayConiq - Not available for: Credit Card, PayPal (use regular refund instead) - - Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The instant refund response - """ - payment_request = self.build("instantRefund", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) + + def instant_refund(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + """Initiate an instant refund (iDEAL, Sofort, PayConiq only).""" + return self.execute_action("instantRefund", validate=validate) diff --git a/buckaroo/builders/payments/credit_card_builder.py b/buckaroo/builders/payments/credit_card_builder.py index 45d9d44..d677dea 100644 --- a/buckaroo/builders/payments/credit_card_builder.py +++ b/buckaroo/builders/payments/credit_card_builder.py @@ -6,97 +6,50 @@ from .capabilities.authorize_capture_capable import AuthorizeCaptureCapable from ...models.payment_response import PaymentResponse + class CreditcardBuilder(PaymentBuilder, EncryptedPayCapable, AuthorizeCaptureCapable): """Builder for Credit Card payments with authorization capabilities.""" - - _serviceName = "creditcard" - + def get_service_name(self) -> str: """Get the service name for Creditcard payments.""" return self._payload.get('brand', 'CreditCard') def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Credit Card payments based on action.""" + action_lower = action.lower() - if action.lower() == "payencrypted": - # Encrypted payment uses encrypted data instead of raw card details - return { - "encryptedcarddata": {"type": str, "required": True, "description": "Encrypted card data"}, - } - - if action.lower() == "paywithsecuritycode": - # Payment with security code uses encrypted data instead of raw card details - return { - "encryptedsecuritycode": {"type": str, "required": True, "description": "Encrypted security code"}, - } + if action_lower == "payencrypted": + return {"encryptedcarddata": {"type": str, "required": True, "description": "Encrypted card data"}} - if action.lower() == "paywithtoken": - # Hosted Fields inline payment: token from submitSession() - return { - "sessionid": {"type": str, "required": True, "description": "Session ID token from Hosted Fields submitSession()"}, - } + if action_lower == "paywithsecuritycode": + return {"encryptedsecuritycode": {"type": str, "required": True, "description": "Encrypted security code"}} - if action.lower() == "authorizewithtoken": - # Hosted Fields inline authorize: token from submitSession() - return { - "sessionid": {"type": str, "required": True, "description": "Session ID token from Hosted Fields submitSession()"}, - } + if action_lower in ("paywithtoken", "authorizewithtoken"): + return {"sessionid": {"type": str, "required": True, "description": "Session ID token from Hosted Fields submitSession()"}} return {} - - def payWithSecurityCode(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - Process a payment with a security code. - - Args: - validate (bool): Whether to validate service parameters before building - Returns: - PaymentResponse: The payment response - """ - payment_request = self.build("PayWithSecurityCode", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) - - def payWithToken(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - Process a payment using a Hosted Fields session token. + def pay_with_security_code(self, validate: bool = True) -> PaymentResponse: + """Process a payment with a security code.""" + return self.execute_action("PayWithSecurityCode", validate=validate) - The SessionId parameter must be set via add_parameter('SessionId', token) - before calling this method. The token comes from the Hosted Fields - submitSession() call on the client side. + def pay_with_token(self, validate: bool = True) -> PaymentResponse: + """Process a payment using a Hosted Fields session token. + Set the SessionId parameter via add_parameter('SessionId', token) before calling. + The token comes from the Hosted Fields submitSession() call on the client side. The response may include a RequiredAction for 3DS authentication. """ - payment_request = self.build("PayWithToken", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) + return self.execute_action("PayWithToken", validate=validate) - def authorizeWithToken(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - Authorize a payment using a Hosted Fields session token. - - The SessionId parameter must be set via add_parameter('SessionId', token) - before calling this method. The token comes from the Hosted Fields - submitSession() call on the client side. + def authorize_with_token(self, validate: bool = True) -> PaymentResponse: + """Authorize a payment using a Hosted Fields session token. + Set the SessionId parameter via add_parameter('SessionId', token) before calling. The response may include a RequiredAction for 3DS authentication. """ - payment_request = self.build("AuthorizeWithToken", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) + return self.execute_action("AuthorizeWithToken", validate=validate) - def payRecurrent(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - """ - PayRecurrent a previously authorized payment. - - Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The payment response - """ - - payment_request = self.build("PayRecurrent", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) \ No newline at end of file + def pay_recurrent(self, validate: bool = True) -> PaymentResponse: + """Execute a recurring payment against a previously stored token.""" + return self.execute_action("PayRecurrent", validate=validate) \ No newline at end of file diff --git a/buckaroo/builders/payments/klarnakp_builder.py b/buckaroo/builders/payments/klarnakp_builder.py index b61af7b..1aafb9b 100644 --- a/buckaroo/builders/payments/klarnakp_builder.py +++ b/buckaroo/builders/payments/klarnakp_builder.py @@ -70,36 +70,26 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: return {} def reserve(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + """Create a Klarna KP reservation.""" payment_request = self.build("Reserve", validate=validate) - request_data = payment_request.to_dict() - - return self._post_data_request(request_data) - - def cancelReservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + return self._post_data_request(payment_request.to_dict()) + + def cancel_reservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + """Cancel a Klarna KP reservation.""" payment_request = self.build("CancelReservation", validate=validate) - request_data = payment_request.to_dict() - - return self._post_data_request(request_data) - - def updateReservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + return self._post_data_request(payment_request.to_dict()) + + def update_reservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + """Update a Klarna KP reservation.""" payment_request = self.build("UpdateReservation", validate=validate) - request_data = payment_request.to_dict() - - return self._post_data_request(request_data) - - def extendReservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + return self._post_data_request(payment_request.to_dict()) + + def extend_reservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + """Extend a Klarna KP reservation.""" payment_request = self.build("ExtendReservation", validate=validate) - request_data = payment_request.to_dict() - - return self._post_data_request(request_data) - - def addShippingInfo(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + return self._post_data_request(payment_request.to_dict()) + + def add_shipping_info(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + """Add shipping information to a Klarna KP order.""" payment_request = self.build("AddShippingInfo", validate=validate) - request_data = payment_request.to_dict() - - return self._post_data_request(request_data) \ No newline at end of file + return self._post_data_request(payment_request.to_dict()) \ No newline at end of file diff --git a/buckaroo/builders/payments/payment_builder.py b/buckaroo/builders/payments/payment_builder.py index a19b84c..eabac25 100644 --- a/buckaroo/builders/payments/payment_builder.py +++ b/buckaroo/builders/payments/payment_builder.py @@ -1,495 +1,159 @@ from typing import Dict, Any, Optional + from ..base_builder import BaseBuilder -from ...models.payment_request import ClientIP, Parameter, PaymentRequest, Service, ServiceList from ...models.payment_response import PaymentResponse +from ...models.transaction_context import TransactionContext +from ...exceptions._parameter_validation_error import ParameterValidationError, RequiredParameterMissingError class PaymentBuilder(BaseBuilder): - """Abstract base class for payment builders.""" - pass - - def currency(self, currency: str) -> 'PaymentBuilder': - """Set the currency for the payment.""" - self._currency = currency - return self - - def amount(self, amount: float) -> 'PaymentBuilder': - """Set the amount for the payment.""" - self._amount_debit = amount - return self - - def description(self, description: str) -> 'PaymentBuilder': - """Set the description for the payment.""" - self._description = description - return self - - def invoice(self, invoice: str) -> 'PaymentBuilder': - """Set the invoice number for the payment.""" - self._invoice = invoice - return self - - def return_url(self, url: str) -> 'PaymentBuilder': - """Set the return URL for successful payment.""" - self._return_url = url - return self - - def return_url_cancel(self, url: str) -> 'PaymentBuilder': - """Set the return URL for cancelled payment.""" - self._return_url_cancel = url - return self - - def return_url_error(self, url: str) -> 'PaymentBuilder': - """Set the return URL for payment error.""" - self._return_url_error = url - return self - - def return_url_reject(self, url: str) -> 'PaymentBuilder': - """Set the return URL for rejected payment.""" - self._return_url_reject = url - return self - - def continue_on_incomplete(self, continue_incomplete: str) -> 'PaymentBuilder': - """Set whether to continue on incomplete payment.""" - self._continue_on_incomplete = continue_incomplete - return self - - def client_ip(self, ip_address: str, ip_type: int = 0) -> 'PaymentBuilder': - """Set the client IP information.""" - self._client_ip = ClientIP(type=ip_type, address=ip_address) - return self - - def add_parameter(self, key: str, value: Any, group_type: str = "", group_id: str = "") -> 'PaymentBuilder': - """Add a custom parameter to the service. - - Args: - key: Parameter name - value: Parameter value (will be converted to string unless it's a list/dict) - group_type: Optional group type for grouped parameters - group_id: Optional group ID for grouped parameters - """ - # Handle list of dictionaries (e.g., articles) - if isinstance(value, list): - for index, item in enumerate(value): - if isinstance(item, dict): - # Each item in the list becomes a group - for item_key, item_value in item.items(): - - str_value = str(item_value).lower() if isinstance(item_value, bool) else str(item_value) - parameter = Parameter( - name=item_key.capitalize(), - value=str_value, - group_type=key.capitalize(), # e.g., "articles" - group_id=str(index + 1) # 1-based index - ) - self._service_parameters.append(parameter) - return self - - # Handle regular parameters - # Convert value to string for API compatibility - str_value = str(value).lower() if isinstance(value, bool) else str(value) - - parameter = Parameter( - name=key.capitalize(), - value=str_value, - group_type=group_type.capitalize(), - group_id=group_id - ) + """Base class for all payment method builders. - self._service_parameters.append(parameter) - return self - - # Validation convenience methods - def is_parameter_allowed(self, param_name: str, action: str = "Pay") -> bool: - """Check if a parameter is allowed for the given action.""" - return self._validator.is_parameter_allowed(param_name, action) - - def get_parameter_info(self, action: str = "Pay") -> Dict[str, Any]: - """Get information about allowed parameters for an action.""" - return self._validator.get_parameter_info(action) - - def get_normalized_parameter_name(self, param_name: str, action: str = "Pay") -> str: - """Get the official parameter name that matches the input.""" - return self._validator.get_normalized_parameter_name(param_name, action) - - def _validate_and_filter_service_parameters(self, action: str = "Pay", strict: bool = False) -> None: - """ - Validate and filter service parameters just before building. - - Args: - action (str): The action being performed - strict (bool): If True, throws exceptions for missing required parameters. - If False, filters invalid parameters and only warns. - - Raises: - RequiredParameterMissingError: If required parameters are missing (when strict=True) - ParameterValidationError: If parameters are invalid (when strict=True) - """ - self._service_parameters = self._validator.validate_all_parameters( - self._service_parameters, action, strict=strict - ) - - def from_dict(self, data: Dict[str, Any]) -> 'PaymentBuilder': - """ - Populate the builder from a dictionary of parameters. - - Args: - data (Dict[str, Any]): Dictionary containing payment parameters - action (str): The action being performed (Pay, Authorize, Refund, etc.) - - Returns: - PaymentBuilder: Self for method chaining - - Supported keys: - - currency: Payment currency (e.g., 'EUR', 'USD') - - amount: Payment amount (float) - - description: Payment description (str) - - invoice: Invoice number (str) - - return_url: Success return URL (str) - - return_url_cancel: Cancel return URL (str) - - return_url_error: Error return URL (str) - - return_url_reject: Reject return URL (str) - - continue_on_incomplete: Continue on incomplete flag (str) - - client_ip: Client IP address (str or dict with 'address' and 'type') - - service_parameters: Additional service-specific parameters (dict) - """ - # Map dictionary keys to builder methods - if 'currency' in data: - self.currency(data['currency']) - - if 'amount' in data: - self.amount(data['amount']) - - if 'description' in data: - self.description(data['description']) - - if 'invoice' in data: - self.invoice(data['invoice']) - - if 'return_url' in data: - self.return_url(data['return_url']) - - if 'return_url_cancel' in data: - self.return_url_cancel(data['return_url_cancel']) - - if 'return_url_error' in data: - self.return_url_error(data['return_url_error']) - - if 'return_url_reject' in data: - self.return_url_reject(data['return_url_reject']) - - if 'continue_on_incomplete' in data: - self.continue_on_incomplete(data['continue_on_incomplete']) - - if 'push_url' in data: - self.push_url(data['push_url']) - if 'push_url_failure' in data: - self.push_url_failure(data['push_url_failure']) - - if 'client_ip' in data: - client_ip_data = data['client_ip'] - if isinstance(client_ip_data, str): - self.client_ip(client_ip_data) - elif isinstance(client_ip_data, dict): - address = client_ip_data.get('address', '0.0.0.0') - ip_type = client_ip_data.get('type', 0) - self.client_ip(address, ip_type) - - if 'service_parameters' in data: - service_params = data['service_parameters'] - - for key, value in service_params.items(): - if isinstance(value, dict): - for sub_key, sub_value in value.items(): - self.add_parameter(sub_key, sub_value, key) - else: - self.add_parameter(key, value) - - # Store the original payload for later use - self._payload = data.copy() - - return self - - - def required_fields(self, action: str = "Pay") -> Dict[str, Any]: - """ - Get the required fields for this payment method and action. - Can be overridden by specific payment builders to customize required fields based on action. - - Args: - action (str): The action being performed (Pay, Authorize, Refund, Capture, etc.) - - Returns: - Dict[str, Any]: Dictionary mapping field names to their current values - """ - return { - 'currency': self._currency, - 'amount_debit': self._amount_debit, - 'description': self._description, - 'invoice': self._invoice, - '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 _validate_required_fields(self, action: str = "Pay") -> None: - """Validate that all required fields are set. - - Args: - action (str): The action being performed (Pay, Authorize, Refund, Capture, etc.) - """ - missing_fields = [field for field, value in self.required_fields(action).items() if value is None] - if missing_fields: - raise ValueError(f"Missing required fields: {', '.join(missing_fields)}") - - def build(self, action: str = "Pay", validate: bool = True, strict_validation: bool = False) -> PaymentRequest: - """Build the payment request. - - Args: - action (str): The action to perform (Pay, Authorize, Refund, etc.) - validate (bool): Whether to validate and filter service parameters - strict_validation (bool): If True, throws exceptions for missing required parameters. - If False, filters invalid parameters and only warns. - - Raises: - ValueError: If required payment fields are missing - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) - """ - self._validate_required_fields(action) - - # Validate and filter service parameters if enabled - if validate: - self._validate_and_filter_service_parameters(action, strict=strict_validation) - - # Create service with parameters - service = Service( - name=self.get_service_name(), - action=action, - parameters=self._service_parameters if self._service_parameters else None - ) - - # Create service list - service_list = ServiceList(services=[service]) - - # Build payment request - payment_request = PaymentRequest( - currency=self._currency, - amount_debit=self._amount_debit, - description=self._description, - invoice=self._invoice, - 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, - continue_on_incomplete=self._continue_on_incomplete, - push_url=self._push_url, - push_url_failure=self._push_url_failure, - client_ip=self._client_ip, - services=service_list - ) - - return payment_request + Adds the payment lifecycle on top of the shared builder scaffolding in + ``BaseBuilder``: pay, refund, capture, cancel, and their helpers. + + ``SolutionBuilder`` inherits from ``BaseBuilder`` directly and intentionally + does *not* get these methods. + """ + + # ── Primary action ──────────────────────────────────────────────────────── def pay(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: - """ - Execute the payment operation. - + """Execute the payment. + Args: - validate (bool): Whether to validate service parameters before building - strict_validation (bool): If True, throws exceptions for missing required parameters - - Returns: - PaymentResponse: Structured payment response object - + validate: Validate and filter service parameters before building. + strict_validation: Raise on invalid parameters instead of filtering. + Raises: - ValueError: If required fields are missing - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) - AuthenticationError: If authentication fails - BuckarooApiError: If API returns an error + RequiredParameterMissingError: If a required field is missing. + ParameterValidationError: If a service parameter is invalid (strict mode). + AuthenticationError: If HMAC authentication fails. """ - # Build the payment request - payment_request = self.build("Pay", validate=validate, strict_validation=strict_validation) - - # Convert to dictionary for API - request_data = payment_request.to_dict() - + request_data = self.build("Pay", validate=validate, strict_validation=strict_validation).to_dict() return self._post_transaction(request_data) - - - def refund(self, validate: bool = True) -> PaymentResponse: - """ - Execute a refund transaction. - + + # ── Follow-up operations ────────────────────────────────────────────────── + + def refund(self, ctx: Optional[TransactionContext] = None, validate: bool = True) -> PaymentResponse: + """Execute a full refund. + Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The refund response - + ctx: Carries ``original_transaction_key`` and an optional ``amount`` + override. Falls back to ``_payload['originalTransactionKey']`` + when omitted (set by ``from_dict()``). + Raises: - ValueError: If required fields are missing + RequiredParameterMissingError: If no transaction key can be resolved. """ - # Get original_transaction_key from parameter or payload - txn_key = self._payload.get('originalTransactionKey') + txn_key = (ctx.original_transaction_key if ctx else None) or self._payload.get('originalTransactionKey') if not txn_key: - raise ValueError("Original transaction key is required for refunds (provide as parameter or in payload)") - - # Get amount from parameter or payload - refund_amount = self._payload.get('refund_amount') - - # Build refund request with original transaction reference - payment_request = self.build('Refund', validate=validate) - - # Convert to dictionary and modify for refund - request_data = payment_request.to_dict() - request_data['OriginalTransactionKey'] = txn_key - - # Set refund amount if specified, otherwise use original amount + raise RequiredParameterMissingError("originalTransactionKey", action="Refund") + + refund_amount = (ctx.amount if ctx else None) or self._payload.get('refund_amount') + request_data = self._build_keyed_request('Refund', txn_key, validate=validate) + if refund_amount is not None: request_data['AmountCredit'] = refund_amount - # Remove debit amount for refunds - if 'AmountDebit' in request_data: - del request_data['AmountDebit'] + request_data.pop('AmountDebit', None) else: - # Full refund - swap debit to credit if 'AmountDebit' in request_data: - request_data['AmountCredit'] = request_data['AmountDebit'] - del request_data['AmountDebit'] - + request_data['AmountCredit'] = request_data.pop('AmountDebit') + return self._post_transaction(request_data) - - def capture(self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None, validate: bool = True) -> PaymentResponse: - """ - Capture a previously authorized payment. - + + def partial_refund(self, ctx: TransactionContext) -> PaymentResponse: + """Execute a partial refund. + + Args: + ctx: ``ctx.amount`` must be set and greater than 0. + + Raises: + ParameterValidationError: If ``ctx.amount`` is missing or non-positive. + """ + if not ctx.amount or ctx.amount <= 0: + raise ParameterValidationError( + "Partial refund amount must be greater than 0", + parameter_name="refund_amount", + action="Refund", + ) + return self.refund(ctx) + + def capture(self, ctx: Optional[TransactionContext] = None, validate: bool = True) -> PaymentResponse: + """Capture a previously authorized payment. + Args: - original_transaction_key (str, optional): The transaction key of the authorization. - If None, will try to get from payload. - amount (float, optional): Amount to capture. If None, will try to get from payload - or capture the full authorized amount. - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The capture response + ctx: ``ctx.original_transaction_key`` is the authorization key; + ``ctx.amount`` overrides the capture amount when set. + Falls back to ``_payload`` when omitted. + + Raises: + RequiredParameterMissingError: If no transaction key can be resolved. """ - # Get authorization key from parameter or payload - auth_key = original_transaction_key or self._payload.get('authorization_key') or self._payload.get('original_transaction_key') + auth_key = ( + (ctx.original_transaction_key if ctx else None) + or self._payload.get('authorization_key') + or self._payload.get('original_transaction_key') + ) if not auth_key: - raise ValueError("Authorization key is required for captures (provide as parameter or in payload)") - - # Get capture amount from parameter or payload - capture_amount = amount or self._payload.get('capture_amount') - - # Build capture request - payment_request = self.build('Capture', validate=validate) - request_data = payment_request.to_dict() - - # Set capture-specific parameters - request_data['OriginalTransactionKey'] = auth_key - - # Set capture amount if specified + raise RequiredParameterMissingError("originalTransactionKey", action="Capture") + + capture_amount = (ctx.amount if ctx else None) or self._payload.get('capture_amount') + request_data = self._build_keyed_request('Capture', auth_key, validate=validate) + if capture_amount is not None: request_data['AmountDebit'] = capture_amount - + return self._post_transaction(request_data) - - def cancel(self, original_transaction_key: Optional[str] = None) -> PaymentResponse: - """ - Cancel a pending or authorized transaction. - + + def cancel(self, ctx: Optional[TransactionContext] = None) -> PaymentResponse: + """Cancel a pending or authorized transaction. + Args: - original_transaction_key (str, optional): The transaction key to cancel. - If None, will try to get from payload. - - Returns: - PaymentResponse: The cancellation response + ctx: Carries the transaction key to cancel. + Falls back to ``_payload`` when omitted. + + Raises: + RequiredParameterMissingError: If no transaction key can be resolved. """ - # Get transaction key from parameter or payload - txn_key = original_transaction_key or self._payload.get('cancel_key') or self._payload.get('original_transaction_key') + txn_key = ( + (ctx.original_transaction_key if ctx else None) + or self._payload.get('cancel_key') + or self._payload.get('original_transaction_key') + ) if not txn_key: - raise ValueError("Transaction key is required for cancellations (provide as parameter or in payload)") - - # Build cancel request - payment_request = self.build() - request_data = payment_request.to_dict() - - # Set cancellation parameters - request_data['OriginalTransactionKey'] = txn_key - # Remove amounts for cancellation + raise RequiredParameterMissingError("originalTransactionKey", action="Cancel") + + request_data = self._build_keyed_request('Pay', txn_key) request_data.pop('AmountDebit', None) request_data.pop('AmountCredit', None) - + return self._post_transaction(request_data) - - def partial_refund(self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None) -> PaymentResponse: - """ - Execute a partial refund transaction. - - Args: - original_transaction_key (str, optional): The transaction key of the original payment. - If None, will try to get from payload. - amount (float, optional): Amount to refund. If None, will try to get from payload. - - Returns: - PaymentResponse: The partial refund response - - Raises: - ValueError: If amount is not provided or invalid - """ - # Get amount from parameter or payload - refund_amount = amount or self._payload.get('refund_amount') or self._payload.get('partial_refund_amount') - if not refund_amount or refund_amount <= 0: - raise ValueError("Partial refund amount must be greater than 0 (provide as parameter or in payload)") - - return self.refund(original_transaction_key, refund_amount) - - def _post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse: - """Helper method to post data request and handle response.""" - # Send to Buckaroo API - response = self._client.http_client.post('/json/DataRequest', request_data) - - # Check if response is valid and convert to dict - if response is None: - # Return a PaymentResponse with empty data for None responses - return PaymentResponse({}) - - # Return structured response object - return PaymentResponse(response.to_dict()) - - 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) - - # Check if response is valid and convert to dict - if response is None: - # Return a PaymentResponse with empty data for None responses - return PaymentResponse({}) - - # Return structured response object - return PaymentResponse(response.to_dict()) - + + # ── Generic action helper ───────────────────────────────────────────────── + def execute_action(self, action: str, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: - """ - Execute a custom action for the payment method. - - This is a generic method that can be used for any action supported - by the payment method (instantRefund, payFastCheckout, etc.). - + """Execute any named action supported by this payment method. + + Used by capability mixins (``pay_encrypted``, ``instant_refund``, etc.) + and by concrete builder methods that map directly to a single Buckaroo action. + Args: - action (str): The action to execute - validate (bool): Whether to validate service parameters before building - strict_validation (bool): If True, throws exceptions for missing required parameters - - Returns: - PaymentResponse: The action response - - Raises: - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) + action: Buckaroo action string, e.g. ``'PayEncrypted'``, ``'instantRefund'``. + validate: Validate and filter service parameters before building. + strict_validation: Raise on invalid parameters instead of filtering. """ - payment_request = self.build(action, validate=validate, strict_validation=strict_validation) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) \ No newline at end of file + request_data = self.build(action, validate=validate, strict_validation=strict_validation).to_dict() + return self._post_transaction(request_data) + + # ── Internal helper ─────────────────────────────────────────────────────── + + def _build_keyed_request(self, action: str, txn_key: str, validate: bool = True) -> Dict[str, Any]: + """Build a request dict that references an original transaction. + + Sets ``OriginalTransactionKey`` so callers only need to handle + any additional amount/field adjustments. + """ + request_data = self.build(action, validate=validate).to_dict() + request_data['OriginalTransactionKey'] = txn_key + return request_data diff --git a/buckaroo/builders/solutions/subscription_builder.py b/buckaroo/builders/solutions/subscription_builder.py index 9f8d363..892a913 100644 --- a/buckaroo/builders/solutions/subscription_builder.py +++ b/buckaroo/builders/solutions/subscription_builder.py @@ -18,9 +18,7 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: return {} - def createSubscription(self: 'SolutionBuilder', validate: bool = True) -> Any: + def create_subscription(self, validate: bool = True) -> Any: """Create a subscription.""" payload = self.build("CreateSubscription", validate=validate) - request_data = payload.to_dict() - - return self._post_data_request(request_data) \ No newline at end of file + return self._post_data_request(payload.to_dict()) \ No newline at end of file diff --git a/buckaroo/config/buckaroo_config.py b/buckaroo/config/buckaroo_config.py index 5999d73..89170f5 100644 --- a/buckaroo/config/buckaroo_config.py +++ b/buckaroo/config/buckaroo_config.py @@ -5,6 +5,7 @@ customization of API endpoints, timeouts, retry logic, and other settings. """ +import warnings from typing import Optional, Dict, Any from dataclasses import dataclass from enum import Enum @@ -205,15 +206,6 @@ def copy(self, **changes) -> 'BuckarooConfig': return self.from_dict(config_dict) -class DefaultConfig(BuckarooConfig): - """ - Default configuration for Buckaroo SDK. - - This class provides sensible defaults for most use cases. - """ - pass - - class TestConfig(BuckarooConfig): """ Configuration optimized for testing. @@ -320,6 +312,12 @@ def enable_ssl_verification(self) -> 'ConfigBuilder': def disable_ssl_verification(self) -> 'ConfigBuilder': """Disable SSL verification (not recommended for production).""" + warnings.warn( + "SSL verification is disabled. This exposes the connection to " + "man-in-the-middle attacks and must never be used in production.", + SecurityWarning, + stacklevel=2, + ) self._config_dict["verify_ssl"] = False return self diff --git a/buckaroo/exceptions/__init__.py b/buckaroo/exceptions/__init__.py new file mode 100644 index 0000000..498ae0f --- /dev/null +++ b/buckaroo/exceptions/__init__.py @@ -0,0 +1,10 @@ +from ._buckaroo_error import BuckarooError +from ._authentication_error import AuthenticationError +from ._parameter_validation_error import ParameterValidationError, RequiredParameterMissingError + +__all__ = [ + "BuckarooError", + "AuthenticationError", + "ParameterValidationError", + "RequiredParameterMissingError", +] diff --git a/buckaroo/exceptions/_buckaroo_error.py b/buckaroo/exceptions/_buckaroo_error.py index c22cc2b..ef4febc 100644 --- a/buckaroo/exceptions/_buckaroo_error.py +++ b/buckaroo/exceptions/_buckaroo_error.py @@ -1,11 +1,25 @@ from typing import Dict, Optional + class BuckarooError(Exception): - _message: Optional[str] - http_body: Optional[str] - http_status: Optional[int] - json_body: Optional[object] - headers: Optional[Dict[str, str]] - code: Optional[str] - request_id: Optional[str] - error: Optional["ErrorObject"] + def __init__( + self, + message: Optional[str] = None, + http_body: Optional[str] = None, + http_status: Optional[int] = None, + json_body: Optional[object] = None, + headers: Optional[Dict[str, str]] = None, + code: Optional[str] = None, + request_id: Optional[str] = None, + ): + super().__init__(message) + self._message = message + self.http_body = http_body + self.http_status = http_status + self.json_body = json_body + self.headers = headers + self.code = code + self.request_id = request_id + + def __str__(self) -> str: + return self._message or super().__str__() diff --git a/buckaroo/exceptions/_parameter_validation_error.py b/buckaroo/exceptions/_parameter_validation_error.py index ca4a530..012a2e6 100644 --- a/buckaroo/exceptions/_parameter_validation_error.py +++ b/buckaroo/exceptions/_parameter_validation_error.py @@ -2,55 +2,46 @@ Exception for parameter validation errors. """ +from typing import Optional from ._buckaroo_error import BuckarooError class ParameterValidationError(BuckarooError): """Exception raised when parameter validation fails.""" - - def __init__(self, message: str, parameter_name: str = None, expected_type: str = None, - action: str = None, service_name: str = None): - """ - Initialize parameter validation error. - - Args: - message (str): Error message - parameter_name (str, optional): Name of the parameter that failed validation - expected_type (str, optional): Expected parameter type - action (str, optional): Action being performed when validation failed - service_name (str, optional): Service name where validation failed - """ - super().__init__(message) + + def __init__( + self, + message: str, + parameter_name: Optional[str] = None, + expected_type: Optional[str] = None, + action: Optional[str] = None, + service_name: Optional[str] = None, + **kwargs, + ): + super().__init__(message, **kwargs) self.parameter_name = parameter_name self.expected_type = expected_type self.action = action self.service_name = service_name - self._message = message - - def __str__(self): - """Return string representation of the error.""" - return self._message class RequiredParameterMissingError(ParameterValidationError): """Exception raised when a required parameter is missing.""" - - def __init__(self, parameter_name: str, action: str = None, service_name: str = None): - """ - Initialize required parameter missing error. - - Args: - parameter_name (str): Name of the missing required parameter - action (str, optional): Action being performed - service_name (str, optional): Service name - """ + + def __init__( + self, + parameter_name: str, + action: Optional[str] = None, + service_name: Optional[str] = None, + **kwargs, + ): service_info = f" for {service_name}" if service_name else "" action_info = f" {action} action" if action else "" message = f"Required parameter '{parameter_name}' is missing{service_info}{action_info}" - super().__init__( message=message, parameter_name=parameter_name, action=action, - service_name=service_name + service_name=service_name, + **kwargs, ) \ No newline at end of file diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index 725352c..eb73a96 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -152,7 +152,7 @@ def is_method_supported(cls, method: str) -> bool: return method.lower() in cls._payment_methods @classmethod - def detect_method_from_payload(cls, payload: Dict) -> str: + def detect_method_from_payload(cls, payload: Dict[str, Any]) -> str: """ Detect the payment method from payload parameters. diff --git a/buckaroo/http/client.py b/buckaroo/http/client.py index 8faae7a..cd5780c 100644 --- a/buckaroo/http/client.py +++ b/buckaroo/http/client.py @@ -16,6 +16,7 @@ from ..config.buckaroo_config import BuckarooConfig from ..exceptions._authentication_error import AuthenticationError +from ..exceptions._buckaroo_error import BuckarooError from .strategies import HttpStrategyFactory, HttpResponse @@ -75,11 +76,18 @@ def _generate_hmac_signature( nonce = str(uuid.uuid4()) - # Process content following C# implementation pattern + # Process content following C# implementation pattern. + # MD5 is mandated by the Buckaroo HMAC authentication specification; + # the content digest is an input component to HMAC-SHA256 and is not + # used as a standalone integrity primitive. if content: - # Convert content to bytes and compute MD5 hash content_bytes = content.encode('utf-8') - md5_hash = hashlib.md5(content_bytes).digest() + try: + # usedforsecurity=False satisfies FIPS-mode environments (Python 3.9+) + md5_hash = hashlib.md5(content_bytes, usedforsecurity=False).digest() + except TypeError: + # Python < 3.9 does not support usedforsecurity keyword argument + md5_hash = hashlib.md5(content_bytes).digest() content_b64 = base64.b64encode(md5_hash).decode('utf-8') else: content_b64 = '' @@ -162,26 +170,22 @@ def _make_request( timeout=self.config.timeout, verify_ssl=self.config.verify_ssl ) - - # Create Buckaroo response object - buckaroo_response = BuckarooResponse(http_response) - - # Handle authentication errors - if http_response.status_code == 401: - raise AuthenticationError("Authentication failed - check your store key and secret key") - elif http_response.status_code == 403: - raise AuthenticationError("Access forbidden - check your API permissions") - - return buckaroo_response - + except BuckarooError: + raise except Exception as e: - # Convert strategy exceptions to BuckarooApiError if "timeout" in str(e).lower(): - raise BuckarooApiError(str(e)) - elif "connection" in str(e).lower(): - raise BuckarooApiError(str(e)) - else: - raise BuckarooApiError(f"Request failed: {str(e)}") + raise BuckarooApiError(str(e)) from e + if "connection" in str(e).lower(): + raise BuckarooApiError(str(e)) from e + raise BuckarooApiError(f"Request failed: {str(e)}") from e + + # Handle authentication errors before wrapping in BuckarooResponse + if http_response.status_code == 401: + raise AuthenticationError("Authentication failed - check your store key and secret key") + if http_response.status_code == 403: + raise AuthenticationError("Access forbidden - check your API permissions") + + return BuckarooResponse(http_response) class BuckarooResponse: @@ -336,16 +340,16 @@ def to_dict(self) -> Dict[str, Any]: "success": self.success, "data": self.data, "headers": self.headers, - # "is_successful_payment": self.is_successful_payment(), - # "payment_key": self.get_payment_key(), - # "transaction_key": self.get_transaction_key(), - # "buckaroo_status_code": self.get_status_code(), - # "buckaroo_status_message": self.get_status_message(), - # "redirect_url": self.get_redirect_url() + "is_successful_payment": self.is_successful_payment(), + "payment_key": self.get_payment_key(), + "transaction_key": self.get_transaction_key(), + "buckaroo_status_code": self.get_status_code(), + "buckaroo_status_message": self.get_status_message(), + "redirect_url": self.get_redirect_url(), } -class BuckarooApiError(Exception): +class BuckarooApiError(BuckarooError): """Exception raised for Buckaroo API errors.""" def __init__(self, message: str, response: Optional[BuckarooResponse] = None): diff --git a/buckaroo/http/strategies/curl_strategy.py b/buckaroo/http/strategies/curl_strategy.py index 63e28c9..faf876a 100644 --- a/buckaroo/http/strategies/curl_strategy.py +++ b/buckaroo/http/strategies/curl_strategy.py @@ -5,7 +5,6 @@ """ import subprocess -import json as json_module import shutil from typing import Dict, Any, Optional, List from .http_strategy import HttpStrategy, HttpResponse @@ -20,26 +19,10 @@ class CurlStrategy(HttpStrategy): """ def __init__(self): - self._timeout = 30 - self._verify_ssl = True - self._retry_attempts = 3 - self._default_headers = {} - + super().__init__() + def configure(self, **kwargs) -> None: - """ - Configure the curl strategy settings. - - Args: - **kwargs: Configuration parameters - - timeout: Request timeout in seconds - - verify_ssl: Whether to verify SSL certificates - - retry_attempts: Number of retry attempts - - default_headers: Default headers to include - """ - self._timeout = kwargs.get('timeout', 30) - self._verify_ssl = kwargs.get('verify_ssl', True) - self._retry_attempts = kwargs.get('retry_attempts', 3) - self._default_headers = kwargs.get('default_headers', {}) + super().configure(**kwargs) def request( self, @@ -103,9 +86,6 @@ def request( last_exception = Exception(f"Request failed: {str(e)}") if attempt == self._retry_attempts - 1: raise last_exception - - # This should never be reached, but just in case - raise last_exception or Exception("Request failed after all retry attempts") def _build_curl_command( self, diff --git a/buckaroo/http/strategies/http_strategy.py b/buckaroo/http/strategies/http_strategy.py index 20c3607..b62f9a5 100644 --- a/buckaroo/http/strategies/http_strategy.py +++ b/buckaroo/http/strategies/http_strategy.py @@ -4,6 +4,7 @@ This module defines the abstract base class for HTTP client strategies. """ +import json as _json from abc import ABC, abstractmethod from typing import Dict, Any, Optional from dataclasses import dataclass @@ -13,40 +14,46 @@ class HttpResponse: """ Response object returned by HTTP strategies. - + This provides a consistent interface across different HTTP implementations. """ status_code: int headers: Dict[str, str] text: str success: bool - + def json(self) -> Dict[str, Any]: """Parse response text as JSON.""" - import json try: - return json.loads(self.text) if self.text else {} - except json.JSONDecodeError: + return _json.loads(self.text) if self.text else {} + except _json.JSONDecodeError: return {"raw_content": self.text} class HttpStrategy(ABC): """ Abstract base class for HTTP client strategies. - - This defines the interface that all HTTP client implementations must follow. + + Stores common configuration fields and provides a default :meth:`configure` + implementation. Subclasses that need extra setup should call + ``super().configure(**kwargs)`` before their own logic. """ - - @abstractmethod + + def __init__(self) -> None: + self._timeout: int = 30 + self._verify_ssl: bool = True + self._retry_attempts: int = 3 + self._retry_delay: float = 1.0 + self._default_headers: Dict[str, str] = {} + def configure(self, **kwargs) -> None: - """ - Configure the HTTP client with settings like timeout, retry, etc. - - Args: - **kwargs: Configuration parameters specific to the implementation - """ - pass - + """Store common configuration shared by all strategies.""" + self._timeout = kwargs.get('timeout', self._timeout) + self._verify_ssl = kwargs.get('verify_ssl', self._verify_ssl) + self._retry_attempts = kwargs.get('retry_attempts', self._retry_attempts) + self._retry_delay = kwargs.get('retry_delay', self._retry_delay) + self._default_headers = kwargs.get('default_headers', self._default_headers) + @abstractmethod def request( self, @@ -55,43 +62,14 @@ def request( headers: Optional[Dict[str, str]] = None, data: Optional[str] = None, timeout: Optional[int] = None, - verify_ssl: bool = True + verify_ssl: bool = True, ) -> HttpResponse: - """ - Make an HTTP request. - - Args: - method: HTTP method (GET, POST, etc.) - url: Request URL - headers: Request headers - data: Request body data - timeout: Request timeout in seconds - verify_ssl: Whether to verify SSL certificates - - Returns: - HttpResponse: Response object - - Raises: - Exception: If the request fails - """ - pass - + """Make an HTTP request and return an :class:`HttpResponse`.""" + @abstractmethod def is_available(self) -> bool: - """ - Check if this HTTP strategy is available on the system. - - Returns: - bool: True if the strategy can be used - """ - pass - + """Return True if this strategy can be used on the current system.""" + @abstractmethod def get_name(self) -> str: - """ - Get the name of this HTTP strategy. - - Returns: - str: Strategy name - """ - pass \ No newline at end of file + """Return a short identifier for this strategy (e.g. ``'requests'``).""" diff --git a/buckaroo/http/strategies/requests_strategy.py b/buckaroo/http/strategies/requests_strategy.py index 0d2dfb9..da75889 100644 --- a/buckaroo/http/strategies/requests_strategy.py +++ b/buckaroo/http/strategies/requests_strategy.py @@ -33,28 +33,16 @@ class RequestsStrategy(HttpStrategy): """ def __init__(self): + super().__init__() self.session = None - self._retry_attempts = 3 - self._retry_delay = 1.0 - + def configure(self, **kwargs) -> None: - """ - Configure the requests session with retry logic and adapters. - - Args: - **kwargs: Configuration parameters - - retry_attempts: Number of retry attempts - - retry_delay: Delay between retries - - default_headers: Default headers to set - """ if not REQUESTS_AVAILABLE: raise ImportError( "The 'requests' library is required for RequestsStrategy. " "Please install it with: pip install requests" ) - - self._retry_attempts = kwargs.get('retry_attempts', 3) - self._retry_delay = kwargs.get('retry_delay', 1.0) + super().configure(**kwargs) # Create session self.session = requests.Session() diff --git a/buckaroo/models/__init__.py b/buckaroo/models/__init__.py index 021da0b..543b479 100644 --- a/buckaroo/models/__init__.py +++ b/buckaroo/models/__init__.py @@ -4,13 +4,26 @@ This package contains all data models and response objects. """ -from .payment_response import PaymentResponse, Status, StatusCode, RequiredAction, Service, ServiceParameter +from .payment_request import Parameter +from .payment_response import ( + BuckarooStatusCode, + PaymentResponse, + Status, + StatusCode, + RequiredAction, + Service, + ServiceParameter, +) +from .transaction_context import TransactionContext __all__ = [ + 'Parameter', + 'BuckarooStatusCode', 'PaymentResponse', - 'Status', + 'Status', 'StatusCode', 'RequiredAction', 'Service', - 'ServiceParameter' + 'ServiceParameter', + 'TransactionContext', ] \ No newline at end of file diff --git a/buckaroo/models/payment_request.py b/buckaroo/models/payment_request.py index 1349289..0d4f669 100644 --- a/buckaroo/models/payment_request.py +++ b/buckaroo/models/payment_request.py @@ -4,21 +4,34 @@ @dataclass class Parameter: - """Model for a service parameter.""" + """Model for a service parameter (used for both requests and responses).""" name: str value: str - group_type: str = "" - group_id: str = "" - + group_type: Optional[str] = None + group_id: Optional[str] = None + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API request.""" return { "Name": self.name, - "GroupType": self.group_type, - "GroupID": self.group_id, + "GroupType": self.group_type or "", + "GroupID": self.group_id or "", "Value": self.value } + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Parameter': + """Create Parameter from API response dictionary.""" + if data is None: + data = {} + value = data.get('Value') + return cls( + name=data.get('Name', ''), + value=str(value) if value is not None else '', + group_type=data.get('GroupType') or None, + group_id=data.get('GroupID') or None, + ) + @dataclass class ClientIP: @@ -74,48 +87,54 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class PaymentRequest: """Model for complete payment request.""" - currency: str - amount_debit: float - description: str - invoice: str - return_url: str - return_url_cancel: str - return_url_error: str - return_url_reject: str + currency: Optional[str] = None + amount_debit: Optional[float] = None + description: Optional[str] = None + invoice: Optional[str] = None + return_url: Optional[str] = None + return_url_cancel: Optional[str] = None + return_url_error: Optional[str] = None + return_url_reject: Optional[str] = None continue_on_incomplete: str = "1" push_url: Optional[str] = None push_url_failure: Optional[str] = None client_ip: Optional[ClientIP] = None services: Optional[ServiceList] = None - + def __post_init__(self): """Set default values after initialization.""" if self.client_ip is None: self.client_ip = ClientIP() - + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API request.""" - request_dict = { - "Currency": self.currency, - "AmountDebit": self.amount_debit, - "Description": self.description, - "Invoice": self.invoice, - "ReturnURL": self.return_url, - "ReturnURLCancel": self.return_url_cancel, - "ReturnURLError": self.return_url_error, - "ReturnURLReject": self.return_url_reject, + request_dict: Dict[str, Any] = { "ContinueOnIncomplete": self.continue_on_incomplete, } + if self.currency is not None: + request_dict["Currency"] = self.currency + if self.amount_debit is not None: + request_dict["AmountDebit"] = self.amount_debit + if self.description is not None: + request_dict["Description"] = self.description + if self.invoice is not None: + request_dict["Invoice"] = self.invoice + if self.return_url is not None: + request_dict["ReturnURL"] = self.return_url + if self.return_url_cancel is not None: + request_dict["ReturnURLCancel"] = self.return_url_cancel + if self.return_url_error is not None: + request_dict["ReturnURLError"] = self.return_url_error + if self.return_url_reject is not None: + request_dict["ReturnURLReject"] = self.return_url_reject if self.push_url: request_dict["PushURL"] = self.push_url if self.push_url_failure: request_dict["PushURLFailure"] = self.push_url_failure - if self.client_ip: request_dict["ClientIP"] = self.client_ip.to_dict() - if self.services: request_dict["Services"] = self.services.to_dict() - + return request_dict \ No newline at end of file diff --git a/buckaroo/models/payment_response.py b/buckaroo/models/payment_response.py index 796192e..6ec37cb 100644 --- a/buckaroo/models/payment_response.py +++ b/buckaroo/models/payment_response.py @@ -7,6 +7,36 @@ from typing import Dict, Any, Optional, List from dataclasses import dataclass from datetime import datetime +from .payment_request import Parameter + + +# Named constants for Buckaroo transaction status codes +class BuckarooStatusCode: + """Named constants for Buckaroo transaction status codes.""" + # Success + SUCCESS = 190 + + # Pending + PENDING_INPUT = 790 + PENDING_PROCESSING = 791 + AWAITING_CONSUMER = 792 + ON_HOLD = 793 + + # Failed + PAYMENT_FAILED = 490 + VALIDATION_FAILED = 491 + TECHNICAL_ERROR = 492 + REJECTED = 690 + CANCELLED_BY_MERCHANT = 691 + CANCELLED_BY_CONSUMER = 692 + + # Cancelled + CANCELLED = 890 + CANCELLED_BY_CONSUMER_LATE = 891 + + PENDING_CODES = {PENDING_INPUT, PENDING_PROCESSING, AWAITING_CONSUMER, ON_HOLD} + FAILED_CODES = {PAYMENT_FAILED, VALIDATION_FAILED, TECHNICAL_ERROR, REJECTED, CANCELLED_BY_MERCHANT, CANCELLED_BY_CONSUMER} + CANCELLED_CODES = {CANCELLED, CANCELLED_BY_CONSUMER_LATE} @dataclass @@ -14,33 +44,20 @@ class StatusCode: """Represents a Buckaroo status code.""" code: int description: str - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'StatusCode': """Create StatusCode from dictionary.""" if data is None: data = {} - - # Handle nested Code structure: {"Code": 490, "Description": "Failed"} - if isinstance(data, dict) and "Code" in data and "Description" in data: + if isinstance(data, int): + return cls(code=data, description='') + if isinstance(data, dict): return cls( code=data.get('Code', 0), description=data.get('Description', '') ) - # Handle simple structure: {"Code": 490} or just integer - elif isinstance(data, dict): - return cls( - code=data.get('Code', 0), - description=data.get('Description', '') - ) - # Handle direct integer - elif isinstance(data, int): - return cls( - code=data, - description='' - ) - else: - return cls(code=0, description='') + return cls(code=0, description='') @dataclass @@ -91,21 +108,8 @@ def from_dict(cls, data: Dict[str, Any]) -> 'RequiredAction': ) -@dataclass -class ServiceParameter: - """Represents a service parameter.""" - name: str - value: Any - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'ServiceParameter': - """Create ServiceParameter from dictionary.""" - if data is None: - data = {} - return cls( - name=data.get('Name', ''), - value=data.get('Value') - ) +# Backward-compatible alias: ServiceParameter was the old name for Parameter in responses +ServiceParameter = Parameter @dataclass @@ -113,18 +117,18 @@ class Service: """Represents a payment service.""" name: str action: Optional[str] - parameters: List[ServiceParameter] - + parameters: List[Parameter] + @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'Service': """Create Service from dictionary.""" if data is None: data = {} - + parameters = [] if 'Parameters' in data and data['Parameters']: - parameters = [ServiceParameter.from_dict(param) for param in data['Parameters']] - + parameters = [Parameter.from_dict(param) for param in data['Parameters']] + return cls( name=data.get('Name', ''), action=data.get('Action'), @@ -210,29 +214,23 @@ def _parse_response(self): def is_pending(self) -> bool: """Check if the payment is pending.""" if self.status and self.status.code: - # Common pending status codes - pending_codes = [790, 791, 792, 793] - return self.status.code.code in pending_codes + return self.status.code.code in BuckarooStatusCode.PENDING_CODES return False - + def is_successful(self) -> bool: """Check if the payment was successful.""" return self.is_successful_payment - + def is_cancelled(self) -> bool: """Check if the payment was cancelled.""" if self.status and self.status.code: - # Common cancelled status codes - cancelled_codes = [890, 891] - return self.status.code.code in cancelled_codes + return self.status.code.code in BuckarooStatusCode.CANCELLED_CODES return False - + def is_failed(self) -> bool: """Check if the payment failed.""" if self.status and self.status.code: - # Common failed status codes - failed_codes = [490, 491, 492, 690, 691, 692] - return self.status.code.code in failed_codes + return self.status.code.code in BuckarooStatusCode.FAILED_CODES return False def requires_action(self) -> bool: diff --git a/buckaroo/models/transaction_context.py b/buckaroo/models/transaction_context.py new file mode 100644 index 0000000..ee8e9f9 --- /dev/null +++ b/buckaroo/models/transaction_context.py @@ -0,0 +1,34 @@ +""" +TransactionContext — explicit carrier for follow-up transaction operations. + +Pass a ``TransactionContext`` to ``refund()``, ``capture()``, ``cancel()``, and +``cancel_authorize()`` instead of relying on the ``_payload`` side-channel. This +makes the required inputs visible at the call site and removes hidden state lookups. + +Example:: + + ctx = TransactionContext(original_transaction_key="ABC123") + response = builder.refund(ctx) + + ctx = TransactionContext(original_transaction_key="ABC123", amount=5.00) + response = builder.partial_refund(ctx) +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class TransactionContext: + """Carries the identifiers needed for follow-up transaction operations. + + Attributes: + original_transaction_key: The key of the transaction being referenced + (authorization, original payment, etc.). + amount: Optional amount override. For ``refund``/``partial_refund`` this + is the credit amount; for ``capture`` this overrides the debit amount. + When ``None`` the builder uses the amount already on the request. + """ + + original_transaction_key: str + amount: Optional[float] = None diff --git a/buckaroo/observers/logging_observer.py b/buckaroo/observers/logging_observer.py index 9de5c86..a1fb947 100644 --- a/buckaroo/observers/logging_observer.py +++ b/buckaroo/observers/logging_observer.py @@ -66,8 +66,9 @@ def __init__(self, config: Optional[LogConfig] = None): self.config = config or LogConfig() self.logger = self._setup_logger() self._sensitive_fields = { - 'secret_key', 'password', 'token', 'authorization', 'cvv', - 'cardnumber', 'card_number', 'iban', 'account_number' + 'secret_key', 'password', 'token', 'authorization', 'cvv', + 'cardnumber', 'card_number', 'iban', 'account_number', + 'store_key', 'x-buckaroo-store-key', } def _setup_logger(self) -> logging.Logger: @@ -465,7 +466,7 @@ def create_logger_from_env() -> BuckarooLoggingObserver: dest_str = os.getenv("BUCKAROO_LOG_DESTINATION", "both").lower() destination = LogDestination(dest_str) if dest_str in [d.value for d in LogDestination] else LogDestination.BOTH - log_file = os.getenv("BUCKAROO_LOG_FILE", "buckaroo_sdk.log") + log_file = os.path.basename(os.getenv("BUCKAROO_LOG_FILE", "buckaroo_sdk.log")) mask_sensitive = os.getenv("BUCKAROO_LOG_MASK_SENSITIVE", "true").lower() == "true" config = LogConfig( diff --git a/buckaroo/py.typed b/buckaroo/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/buckaroo/services/service_parameter_validator.py b/buckaroo/services/service_parameter_validator.py index 66e6e75..dd0de60 100644 --- a/buckaroo/services/service_parameter_validator.py +++ b/buckaroo/services/service_parameter_validator.py @@ -2,23 +2,28 @@ Service parameter validation for payment builders. """ -from typing import Dict, Any, List -from abc import ABC, abstractmethod +import logging +from typing import Callable, Dict, Any, List from buckaroo.models.payment_request import Parameter from buckaroo.exceptions._parameter_validation_error import ParameterValidationError, RequiredParameterMissingError class ServiceParameterValidator: """Handles validation and filtering of service parameters for payment methods.""" - - def __init__(self, payment_builder): + + def __init__( + self, + get_allowed_params: Callable[[str], Dict[str, Any]], + get_service_name: Callable[[], str], + ): """ - Initialize validator with payment builder reference. - Args: - payment_builder: The payment builder instance to validate for + get_allowed_params: Callable that returns the allowed parameters dict + for a given action string. + get_service_name: Callable that returns the service name string. """ - self.payment_builder = payment_builder + self._get_allowed_params = get_allowed_params + self._get_service_name = get_service_name def normalize_parameter_name(self, param_name: str) -> str: """Normalize parameter name to lowercase and remove underscores for matching. @@ -64,7 +69,7 @@ def validate_parameter_type(self, key: str, value: Any, param_config: Dict[str, f"Parameter '{key}' must be one of types {type_names} or 'true'/'false' string", parameter_name=key, expected_type=str(type_names), - service_name=self.payment_builder.get_service_name() + service_name=self._get_service_name() ) else: type_names = [t.__name__ for t in expected_type] @@ -72,7 +77,7 @@ def validate_parameter_type(self, key: str, value: Any, param_config: Dict[str, f"Parameter '{key}' must be one of types {type_names}, got {type(value).__name__}", parameter_name=key, expected_type=str(type_names), - service_name=self.payment_builder.get_service_name() + service_name=self._get_service_name() ) else: if not isinstance(value, expected_type): @@ -83,14 +88,14 @@ def validate_parameter_type(self, key: str, value: Any, param_config: Dict[str, f"Parameter '{key}' must be a boolean or 'true'/'false' string", parameter_name=key, expected_type=expected_type.__name__, - service_name=self.payment_builder.get_service_name() + service_name=self._get_service_name() ) else: raise ParameterValidationError( f"Parameter '{key}' must be of type {expected_type.__name__}, got {type(value).__name__}", parameter_name=key, expected_type=expected_type.__name__, - service_name=self.payment_builder.get_service_name() + service_name=self._get_service_name() ) def validate_single_parameter(self, key: str, value: Any, action: str = "Pay") -> None: @@ -105,15 +110,15 @@ def validate_single_parameter(self, key: str, value: Any, action: str = "Pay") - Raises: ParameterValidationError: If parameter is not allowed or invalid """ - allowed_params = self.payment_builder.get_allowed_service_parameters(action) + allowed_params = self._get_allowed_params(action) if key not in allowed_params: raise ParameterValidationError( - f"Parameter '{key}' is not allowed for {self.payment_builder.get_service_name()} {action} action. " + f"Parameter '{key}' is not allowed for {self._get_service_name()} {action} action. " f"Allowed parameters: {list(allowed_params.keys())}", parameter_name=key, action=action, - service_name=self.payment_builder.get_service_name() + service_name=self._get_service_name() ) param_config = allowed_params[key] @@ -144,7 +149,7 @@ def validate_required_parameters(self, parameters: List[Parameter], action: str Raises: RequiredParameterMissingError: If any required parameter is missing """ - allowed_params = self.payment_builder.get_allowed_service_parameters(action) + allowed_params = self._get_allowed_params(action) # Create a normalized lookup for provided parameters # Include both regular parameters and group_types @@ -175,14 +180,14 @@ def validate_required_parameters(self, parameters: List[Parameter], action: str raise RequiredParameterMissingError( parameter_name=missing_required[0], action=action, - service_name=self.payment_builder.get_service_name() + service_name=self._get_service_name() ) else: # Multiple missing parameters raise ParameterValidationError( - f"Required parameters missing for {self.payment_builder.get_service_name()} {action} action: {', '.join(missing_required)}", + f"Required parameters missing for {self._get_service_name()} {action} action: {', '.join(missing_required)}", action=action, - service_name=self.payment_builder.get_service_name() + service_name=self._get_service_name() ) def validate_and_filter_parameters(self, parameters: List[Parameter], action: str = "Pay") -> List[Parameter]: @@ -199,7 +204,7 @@ def validate_and_filter_parameters(self, parameters: List[Parameter], action: st if not parameters: return [] - allowed_params = self.payment_builder.get_allowed_service_parameters(action) + allowed_params = self._get_allowed_params(action) # Create normalized lookup for allowed parameters normalized_allowed = {self.normalize_parameter_name(key): key for key in allowed_params.keys()} @@ -215,7 +220,7 @@ def validate_and_filter_parameters(self, parameters: List[Parameter], action: st # Grouped parameter is valid - no need to validate individual fields valid_parameters.append(param) else: - invalid_params.append(f"{param.name} (group: {param.group_type}): group not allowed for {self.payment_builder.get_service_name()} {action} action") + invalid_params.append(f"{param.name} (group: {param.group_type}): group not allowed for {self._get_service_name()} {action} action") else: # Regular parameter - validate including source check normalized_param_name = self.normalize_parameter_name(param.name) @@ -243,10 +248,10 @@ def validate_and_filter_parameters(self, parameters: List[Parameter], action: st except ParameterValidationError as e: invalid_params.append(f"{param.name}: {str(e)}") else: - invalid_params.append(f"{param.name}: not allowed for {self.payment_builder.get_service_name()} {action} action") + invalid_params.append(f"{param.name}: not allowed for {self._get_service_name()} {action} action") if invalid_params: - print(f"Warning: Filtered out invalid service parameters for {action} action: {invalid_params}") + logging.warning("Filtered out invalid service parameters for %s action: %s", action, invalid_params) return valid_parameters @@ -273,7 +278,7 @@ def validate_all_parameters(self, parameters: List[Parameter], action: str = "Pa # Then validate each parameter individually - strict mode throws exceptions for param in parameters: normalized_param_name = self.normalize_parameter_name(param.name) - allowed_params = self.payment_builder.get_allowed_service_parameters(action) + allowed_params = self._get_allowed_params(action) normalized_allowed = {self.normalize_parameter_name(key): key for key in allowed_params.keys()} if normalized_param_name in normalized_allowed: @@ -282,10 +287,10 @@ def validate_all_parameters(self, parameters: List[Parameter], action: str = "Pa self.validate_single_parameter(allowed_param_name, param_value, action) else: raise ParameterValidationError( - f"Parameter '{param.name}' is not allowed for {self.payment_builder.get_service_name()} {action} action", + f"Parameter '{param.name}' is not allowed for {self._get_service_name()} {action} action", parameter_name=param.name, action=action, - service_name=self.payment_builder.get_service_name() + service_name=self._get_service_name() ) return parameters @@ -305,7 +310,7 @@ def get_parameter_info(self, action: str = "Pay") -> Dict[str, Any]: Returns: Dict[str, Any]: Parameter information including types and requirements """ - return self.payment_builder.get_allowed_service_parameters(action) + return self._get_allowed_params(action) def is_parameter_allowed(self, param_name: str, action: str = "Pay") -> bool: """ @@ -318,7 +323,7 @@ def is_parameter_allowed(self, param_name: str, action: str = "Pay") -> bool: Returns: bool: True if parameter is allowed, False otherwise """ - allowed_params = self.payment_builder.get_allowed_service_parameters(action) + allowed_params = self._get_allowed_params(action) normalized_allowed = {self.normalize_parameter_name(key): key for key in allowed_params.keys()} normalized_param = self.normalize_parameter_name(param_name) @@ -335,7 +340,7 @@ def get_normalized_parameter_name(self, param_name: str, action: str = "Pay") -> Returns: str: Official parameter name, or empty string if not found """ - allowed_params = self.payment_builder.get_allowed_service_parameters(action) + allowed_params = self._get_allowed_params(action) normalized_allowed = {self.normalize_parameter_name(key): key for key in allowed_params.keys()} normalized_param = self.normalize_parameter_name(param_name) diff --git a/buckaroo/services/transaction_service.py b/buckaroo/services/transaction_service.py index e69de29..a7457ba 100644 --- a/buckaroo/services/transaction_service.py +++ b/buckaroo/services/transaction_service.py @@ -0,0 +1,73 @@ +""" +Transaction execution service for Buckaroo SDK. + +Centralises the logic for posting requests to the Buckaroo API so that +builders only need to construct the request payload, not manage HTTP concerns. +""" + +from typing import Dict, Any +try: + from typing import Protocol, runtime_checkable +except ImportError: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + +from ..models.payment_response import PaymentResponse + + +@runtime_checkable +class ITransactionExecutor(Protocol): + """Protocol that any transaction executor must satisfy. + + Builders depend on this abstraction, not on ``TransactionExecutor`` directly, + so tests can inject a mock without touching the network. + """ + + def post_transaction(self, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a standard payment transaction.""" + ... + + def post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a data request (subscriptions, Klarna KP, etc.).""" + ... + + +class TransactionExecutor: + """ + Handles posting payment requests to the Buckaroo API. + + Builders hold a reference to this executor and delegate all HTTP calls + to it, keeping the HTTP concern out of the builder layer. + """ + + _TRANSACTION_ENDPOINT = '/json/transaction' + _DATA_REQUEST_ENDPOINT = '/json/DataRequest' + + def __init__(self, client) -> None: + """ + Args: + client: BuckarooClient instance that owns the http_client. + """ + self._client = client + + def post(self, endpoint: str, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a request to the given Buckaroo endpoint. + + Args: + endpoint: API path, e.g. '/json/transaction' + request_data: Serialised payment request dictionary + + Returns: + PaymentResponse: Structured response object (never None) + """ + response = self._client.http_client.post(endpoint, request_data) + if response is None: + return PaymentResponse({}) + return PaymentResponse(response.to_dict()) + + def post_transaction(self, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a standard payment transaction.""" + return self.post(self._TRANSACTION_ENDPOINT, request_data) + + def post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a data request (used for subscriptions, Klarna KP, etc.).""" + return self.post(self._DATA_REQUEST_ENDPOINT, request_data) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e677f70 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.backends.legacy:build" diff --git a/requirements.txt b/requirements.txt index 33a7568..ecde1e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,8 @@ -requests>=2.20.0 -urllib3>=1.25.0 -typing_extensions>=4.5.0 \ No newline at end of file +# Runtime dependencies — kept in sync with install_requires in setup.py +requests>=2.20 +typing_extensions>=4.5.0 + +# Development / CI dependencies +flake8 +mypy +types-requests diff --git a/setup.py b/setup.py index 12446af..37c199f 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ import os +import re from codecs import open from setuptools import setup, find_packages @@ -7,18 +8,20 @@ long_description = open(os.path.join(ROOT_DIR, "README.md"), encoding="utf-8").read() -version_contents = {} with open(os.path.join(ROOT_DIR, "buckaroo", "_version.py"), encoding="utf-8") as f: - exec(f.read(), version_contents) + version_match = re.search(r'^VERSION\s*=\s*["\']([^"\']+)["\']', f.read(), re.MULTILINE) +if not version_match: + raise RuntimeError("Cannot find VERSION in buckaroo/_version.py") +version_contents = {"VERSION": version_match.group(1)} setup( name="buckaroo-sdk-python", version=version_contents["VERSION"], description="Python bindings for the Buckaroo API", long_description=long_description, - long_description_content_type="text/x-rst", + long_description_content_type="text/markdown", author="Buckaroo", - author_email="wecare@buckaroon.nl", + author_email="wecare@buckaroo.nl", url="https://github.com/buckaroo-it/BuckarooSDK_Python", license="MIT", keywords="buckaroo api payments", @@ -26,17 +29,14 @@ package_data={"buckaroo": ["data/ca-certificates.crt", "py.typed"]}, zip_safe=False, install_requires=[ - 'typing_extensions <= 4.2.0, > 3.7.2; python_version < "3.7"', - # The best typing support comes from 4.5.0+ but we can support down to - # 3.7.2 without throwing exceptions. - 'typing_extensions >= 4.5.0; python_version >= "3.7"', - 'requests >= 2.20; python_version >= "3.0"', + 'typing_extensions >= 4.5.0', + 'requests >= 2.20', ], - python_requires=">=3.6", + python_requires=">=3.8", project_urls={ "Bug Tracker": "https://github.com/buckaroo-it/BuckarooSDK_Python/issues", "Changes": "https://github.com/buckaroo-it/BuckarooSDK_Python//blob/master/CHANGELOG.md", - "Documentation": "https://stripe.com/docs/api/?lang=python", + "Documentation": "https://docs.buckaroo.io/", "Source Code": "https://github.com/buckaroo-it/BuckarooSDK_Python/", }, classifiers=[ @@ -47,8 +47,6 @@ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", From 8b1019466e0fc8dc4a1c47dfe871404d93649152 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Apr 2026 11:03:02 +0800 Subject: [PATCH 02/21] feat: add usage examples for common payment methods Adds comprehensive example scripts for Credit Card, iDEAL, In3, and PayPal. These examples demonstrate standard integration flows, including encrypted card data, Hosted Fields, two-step authorization/capture, recurring payments, and refunds. --- examples/credit_card_payment.py | 203 ++++++++++++++++++++++++++++++++ examples/ideal_payment.py | 145 +++++++++++++++++++++++ examples/in3_payment.py | 137 +++++++++++++++++++++ examples/paypal_payment.py | 108 +++++++++++++++++ 4 files changed, 593 insertions(+) create mode 100644 examples/credit_card_payment.py create mode 100644 examples/ideal_payment.py create mode 100644 examples/in3_payment.py create mode 100644 examples/paypal_payment.py diff --git a/examples/credit_card_payment.py b/examples/credit_card_payment.py new file mode 100644 index 0000000..671b95f --- /dev/null +++ b/examples/credit_card_payment.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +Credit card payment examples. + +Covers: + - Pay with encrypted card data (most common server-side flow) + - Pay with Hosted Fields token (pay_with_token) + - Authorize then capture (two-step flow) + - Authorize then cancel (cancel_authorize) + - Recurring payment (pay_recurrent) + - Refund + +The credit card service name is dynamic: it reflects the card brand +(visa, mastercard, amex, …). Pass it in ``from_dict`` as ``'brand'``. + +Run: + BUCKAROO_STORE_KEY=... BUCKAROO_SECRET_KEY=... python examples/credit_card_payment.py +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo +from buckaroo.models import TransactionContext + +RETURN_BASE = "https://yourshop.com" + +app = Buckaroo.quick_setup( + store_key=os.environ["BUCKAROO_STORE_KEY"], + secret_key=os.environ["BUCKAROO_SECRET_KEY"], + mode="test", +) + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def print_response(label, response): + print(f"\n{'─' * 55}") + print(f" {label}") + print(f"{'─' * 55}") + print(f" HTTP status : {response.status_code}") + print(f" Success : {response.success}") + if response.status: + print(f" Buckaroo code : {response.status.code.code} — {response.status.code.description}") + if response.requires_action(): + print(f" Redirect URL : {response.get_redirect_url()}") + if response.transaction_key: + print(f" Txn key : {response.transaction_key}") + + +def base_builder(brand: str = "visa"): + """Return a pre-filled credit card builder for the given brand.""" + return app.payments.create_payment("creditcard", { + "brand": brand, + "currency": "EUR", + "amount": 49.99, + "description": "Order #2001", + "invoice": "INV-2001", + "return_url": f"{RETURN_BASE}/return", + "return_url_cancel":f"{RETURN_BASE}/cancel", + "return_url_error": f"{RETURN_BASE}/error", + "return_url_reject":f"{RETURN_BASE}/reject", + }) + + +# ── 1. Pay with encrypted card data ────────────────────────────────────────── + +def example_pay_encrypted(encrypted_card_data: str): + """ + Standard server-side encrypted payment. + + The ``encryptedCardData`` string is produced by the Buckaroo JavaScript + encryption library on the client side, then posted to your server. + Your server passes it straight through — it never sees raw card numbers. + """ + response = ( + base_builder() + .add_parameter("encryptedCardData", encrypted_card_data) + .pay_encrypted() + ) + print_response("Pay encrypted (Visa)", response) + return response + + +# ── 2. Pay with Hosted Fields token ────────────────────────────────────────── + +def example_pay_with_token(session_id: str): + """ + Hosted Fields inline payment. + + The ``session_id`` comes from Buckaroo's Hosted Fields ``submitSession()`` + JavaScript call on your checkout page. Pass it here; Buckaroo tokenises + the card on its side. + + The response may include a ``RequiredAction`` for 3-D Secure authentication. + """ + response = ( + base_builder() + .add_parameter("sessionId", session_id) + .pay_with_token() + ) + print_response("Pay with Hosted Fields token", response) + return response + + +# ── 3. Authorize then capture (two-step) ───────────────────────────────────── + +def example_authorize_and_capture(encrypted_card_data: str): + """ + Reserve the funds at authorisation time; capture later (e.g. on dispatch). + """ + builder = ( + base_builder() + .add_parameter("encryptedCardData", encrypted_card_data) + ) + + # Step 1 — authorise (no money moves yet) + auth_response = builder.authorize() + print_response("Authorize", auth_response) + + auth_key = auth_response.transaction_key + if not auth_key: + print(" No transaction key returned — cannot capture.") + return + + # Step 2 — capture (money moves now) + ctx = TransactionContext(original_transaction_key=auth_key) + capture_response = builder.capture(ctx) + print_response("Capture", capture_response) + + return capture_response + + +# ── 4. Authorize then cancel ────────────────────────────────────────────────── + +def example_authorize_and_cancel(encrypted_card_data: str): + """Release the reserved funds without charging the customer.""" + builder = ( + base_builder() + .add_parameter("encryptedCardData", encrypted_card_data) + ) + + auth_response = builder.authorize() + print_response("Authorize (to be cancelled)", auth_response) + + auth_key = auth_response.transaction_key + if not auth_key: + print(" No transaction key returned — cannot cancel.") + return + + ctx = TransactionContext(original_transaction_key=auth_key) + cancel_response = builder.cancel_authorize(ctx) + print_response("Cancel authorize", cancel_response) + + return cancel_response + + +# ── 5. Recurring payment ────────────────────────────────────────────────────── + +def example_pay_recurrent(original_transaction_key: str): + """ + Charge a customer again using a stored token from a previous transaction. + The original transaction must have been created with ``startRecurrent=true``. + """ + response = ( + base_builder() + .add_parameter("originalTransactionKey", original_transaction_key) + .pay_recurrent() + ) + print_response("Recurring payment", response) + return response + + +# ── 6. Refund ───────────────────────────────────────────────────────────────── + +def example_refund(original_transaction_key: str): + """Full refund of a captured or completed payment.""" + ctx = TransactionContext(original_transaction_key=original_transaction_key) + response = base_builder().refund(ctx) + print_response("Refund", response) + return response + + +# ── main ────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print("CREDIT CARD PAYMENT EXAMPLES") + print("=" * 55) + + # These values come from the client-side Buckaroo JS library. + # Replace them with real values from your test environment. + ENCRYPTED_CARD_DATA = "REPLACE_WITH_ENCRYPTED_CARD_DATA" + HOSTED_FIELDS_SESSION = "REPLACE_WITH_SESSION_ID" + EXISTING_TXN_KEY = "REPLACE_WITH_REAL_TXN_KEY" + + example_pay_encrypted(ENCRYPTED_CARD_DATA) + example_pay_with_token(HOSTED_FIELDS_SESSION) + example_authorize_and_capture(ENCRYPTED_CARD_DATA) + example_authorize_and_cancel(ENCRYPTED_CARD_DATA) + example_pay_recurrent(EXISTING_TXN_KEY) + example_refund(EXISTING_TXN_KEY) diff --git a/examples/ideal_payment.py b/examples/ideal_payment.py new file mode 100644 index 0000000..fd1abf8 --- /dev/null +++ b/examples/ideal_payment.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +iDEAL payment examples. + +Covers: + - Pay (with and without pre-selected bank) + - Full refund + - Partial refund + - Instant refund + +Run: + BUCKAROO_STORE_KEY=... BUCKAROO_SECRET_KEY=... python examples/ideal_payment.py +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo +from buckaroo.models import TransactionContext + +RETURN_BASE = "https://yourshop.com" + +app = Buckaroo.quick_setup( + store_key=os.environ["BUCKAROO_STORE_KEY"], + secret_key=os.environ["BUCKAROO_SECRET_KEY"], + mode="test", +) + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def print_response(label, response): + print(f"\n{'─' * 55}") + print(f" {label}") + print(f"{'─' * 55}") + print(f" HTTP status : {response.status_code}") + print(f" Success : {response.success}") + if response.status: + print(f" Buckaroo code : {response.status.code.code} — {response.status.code.description}") + if response.requires_action(): + print(f" Redirect URL : {response.get_redirect_url()}") + if response.transaction_key: + print(f" Txn key : {response.transaction_key}") + + +def base_builder(): + """Return a pre-filled iDEAL builder ready to customise.""" + return ( + app.payments.create_payment("ideal") + .currency("EUR") + .amount(25.50) + .description("Order #1042") + .invoice("INV-1042") + .return_url(f"{RETURN_BASE}/return") + .return_url_cancel(f"{RETURN_BASE}/cancel") + .return_url_error(f"{RETURN_BASE}/error") + .return_url_reject(f"{RETURN_BASE}/reject") + .push_url(f"{RETURN_BASE}/push") + ) + + +# ── 1. Pay — bank pre-selected ──────────────────────────────────────────────── + +def example_pay_with_issuer(): + """ + Customer has already chosen their bank (e.g. from a bank picker on your + checkout page). Pass the issuer code so Buckaroo skips its own picker and + redirects straight to the bank. + """ + response = ( + base_builder() + .add_parameter("issuer", "INGBNL2A") + .pay() + ) + print_response("Pay — pre-selected bank (ING)", response) + return response + + +# ── 2. Pay — no issuer (Buckaroo shows bank picker) ─────────────────────────── + +def example_pay_no_issuer(): + """ + Omit the issuer. Buckaroo redirects the customer to its own bank-selection + page. Simpler integration, slightly more friction for the customer. + """ + response = base_builder().pay() + print_response("Pay — Buckaroo bank picker", response) + return response + + +# ── 3. Full refund ──────────────────────────────────────────────────────────── + +def example_full_refund(original_transaction_key: str): + """ + Refund the full amount of a completed transaction. + The amount is read from the builder — no need to specify it again. + """ + ctx = TransactionContext(original_transaction_key=original_transaction_key) + response = base_builder().refund(ctx) + print_response("Full refund", response) + return response + + +# ── 4. Partial refund ───────────────────────────────────────────────────────── + +def example_partial_refund(original_transaction_key: str): + """Refund only part of the original amount.""" + ctx = TransactionContext(original_transaction_key=original_transaction_key, amount=10.00) + response = base_builder().partial_refund(ctx) + print_response("Partial refund (€10.00)", response) + return response + + +# ── 5. Instant refund ───────────────────────────────────────────────────────── + +def example_instant_refund(): + """ + Instant refund sends money back immediately without a separate push + notification cycle. Buckaroo must have the customer IBAN on file. + """ + response = base_builder().instant_refund() + print_response("Instant refund", response) + return response + + +# ── main ────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print("iDEAL PAYMENT EXAMPLES") + print("=" * 55) + + # Run the pay examples + pay_response = example_pay_with_issuer() + example_pay_no_issuer() + + # Use the transaction key from the first payment for follow-up examples. + # In a real application you would store this key after receiving the push + # notification confirming the payment was successful. + txn_key = pay_response.transaction_key or "REPLACE_WITH_REAL_TXN_KEY" + + example_full_refund(txn_key) + example_partial_refund(txn_key) + example_instant_refund() diff --git a/examples/in3_payment.py b/examples/in3_payment.py new file mode 100644 index 0000000..7220a98 --- /dev/null +++ b/examples/in3_payment.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +In3 payment example. + +In3 is a buy-now-pay-later method that requires billing/shipping customer +details and a list of articles (line items) to be sent with the payment. + +Run: + BUCKAROO_STORE_KEY=... BUCKAROO_SECRET_KEY=... python examples/in3_payment.py +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo +from buckaroo.models import TransactionContext + +RETURN_BASE = "https://yourshop.com" + +app = Buckaroo.quick_setup( + store_key=os.environ["BUCKAROO_STORE_KEY"], + secret_key=os.environ["BUCKAROO_SECRET_KEY"], + mode="test", +) + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def print_response(label, response): + print(f"\n{'─' * 55}") + print(f" {label}") + print(f"{'─' * 55}") + print(f" HTTP status : {response.status_code}") + print(f" Success : {response.success}") + if response.status: + print(f" Buckaroo code : {response.status.code.code} — {response.status.code.description}") + if response.requires_action(): + print(f" Redirect URL : {response.get_redirect_url()}") + if response.transaction_key: + print(f" Txn key : {response.transaction_key}") + + +# ── 1. Pay ──────────────────────────────────────────────────────────────────── + +def example_pay(): + """ + In3 requires billing customer, shipping customer, and a list of articles. + Articles are passed as a list of dicts — the SDK expands them into + grouped parameters automatically. + """ + response = app.payments.create_payment("in3", { + "currency": "EUR", + "amount": 49.50, + "description": "Order #3001", + "invoice": "INV-3001", + "return_url": f"{RETURN_BASE}/return", + "return_url_cancel":f"{RETURN_BASE}/cancel", + "return_url_error": f"{RETURN_BASE}/error", + "return_url_reject":f"{RETURN_BASE}/reject", + "service_parameters": { + "billingCustomer": { + "category": "Person", + "gender": "Male", + "initials": "J", + "lastName": "Doe", + "birthDate": "1990-01-01", + "street": "Hoofdstraat", + "houseNumber": "12", + "zipcode": "1234AB", + "city": "Amsterdam", + "country": "NL", + "email": "j.doe@example.com", + "phone": "0612345678", + }, + "shippingCustomer": { + "street": "Hoofdstraat", + "houseNumber": "12", + "zipcode": "1234AB", + "city": "Amsterdam", + "country": "NL", + }, + "article": [ + { + "description": "Blue T-shirt (L)", + "identifier": "TSHIRT-001", + "quantity": 2, + "price": 19.99, + "vatCategory": "High", + }, + { + "description": "Shipping costs", + "identifier": "SHIPPING", + "quantity": 1, + "price": 9.52, + "vatCategory": "High", + }, + ], + }, + }).pay() + + print_response("In3 Pay", response) + return response + + +# ── 2. Refund ───────────────────────────────────────────────────────────────── + +def example_refund(original_transaction_key: str): + """Full refund of a completed In3 payment.""" + builder = app.payments.create_payment("in3", { + "currency": "EUR", + "amount": 49.50, + "description": "Refund for Order #3001", + "invoice": "INV-3001", + "return_url": f"{RETURN_BASE}/return", + "return_url_cancel":f"{RETURN_BASE}/cancel", + "return_url_error": f"{RETURN_BASE}/error", + "return_url_reject":f"{RETURN_BASE}/reject", + }) + + ctx = TransactionContext(original_transaction_key=original_transaction_key) + response = builder.refund(ctx) + print_response("In3 Refund", response) + return response + + +# ── main ────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print("IN3 PAYMENT EXAMPLES") + print("=" * 55) + + pay_response = example_pay() + + txn_key = pay_response.transaction_key or "REPLACE_WITH_REAL_TXN_KEY" + example_refund(txn_key) diff --git a/examples/paypal_payment.py b/examples/paypal_payment.py new file mode 100644 index 0000000..2851685 --- /dev/null +++ b/examples/paypal_payment.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +PayPal payment example. + +Covers: + - Pay + - Refund + +Run: + BUCKAROO_STORE_KEY=... BUCKAROO_SECRET_KEY=... python examples/paypal_payment.py +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo +from buckaroo.models import TransactionContext + +RETURN_BASE = "https://yourshop.com" + +app = Buckaroo.quick_setup( + store_key=os.environ["BUCKAROO_STORE_KEY"], + secret_key=os.environ["BUCKAROO_SECRET_KEY"], + mode="test", +) + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def print_response(label, response): + print(f"\n{'─' * 55}") + print(f" {label}") + print(f"{'─' * 55}") + print(f" HTTP status : {response.status_code}") + print(f" Success : {response.success}") + if response.status: + print(f" Buckaroo code : {response.status.code.code} — {response.status.code.description}") + if response.requires_action(): + print(f" Redirect URL : {response.get_redirect_url()}") + if response.transaction_key: + print(f" Txn key : {response.transaction_key}") + + +def base_builder(): + return ( + app.payments.create_payment("paypal") + .currency("EUR") + .amount(35.00) + .description("Order #4001") + .invoice("INV-4001") + .return_url(f"{RETURN_BASE}/return") + .return_url_cancel(f"{RETURN_BASE}/cancel") + .return_url_error(f"{RETURN_BASE}/error") + .return_url_reject(f"{RETURN_BASE}/reject") + .push_url(f"{RETURN_BASE}/push") + ) + + +# ── 1. Pay ──────────────────────────────────────────────────────────────────── + +def example_pay(): + """ + Buckaroo redirects the customer to PayPal to complete the payment. + The response contains a RedirectURL — send the customer there. + """ + response = ( + base_builder() + .add_parameter("buyerEmail", "customer@example.com") + .add_parameter("productName", "Blue T-shirt") + .pay() + ) + print_response("PayPal Pay", response) + return response + + +# ── 2. Refund ───────────────────────────────────────────────────────────────── + +def example_refund(original_transaction_key: str): + """Full refund of a completed PayPal payment.""" + ctx = TransactionContext(original_transaction_key=original_transaction_key) + response = base_builder().refund(ctx) + print_response("PayPal Refund", response) + return response + + +# ── 3. Partial refund ───────────────────────────────────────────────────────── + +def example_partial_refund(original_transaction_key: str): + """Refund part of a completed PayPal payment.""" + ctx = TransactionContext(original_transaction_key=original_transaction_key, amount=10.00) + response = base_builder().partial_refund(ctx) + print_response("PayPal Partial refund (€10.00)", response) + return response + + +# ── main ────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print("PAYPAL PAYMENT EXAMPLES") + print("=" * 55) + + pay_response = example_pay() + + txn_key = pay_response.transaction_key or "REPLACE_WITH_REAL_TXN_KEY" + example_refund(txn_key) + example_partial_refund(txn_key) From acf98cf0ee3f269646d711c6963d83e80c2a4f9c Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Apr 2026 11:18:21 +0800 Subject: [PATCH 03/21] refactor: simplify iDEAL payment example flow Removes the example for paying with a pre-selected bank issuer and renames the generic payment example to `example_pay`. This streamlines the demonstration to focus on the standard flow where Buckaroo handles bank selection. --- examples/ideal_payment.py | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/examples/ideal_payment.py b/examples/ideal_payment.py index fd1abf8..009623a 100644 --- a/examples/ideal_payment.py +++ b/examples/ideal_payment.py @@ -61,26 +61,10 @@ def base_builder(): ) -# ── 1. Pay — bank pre-selected ──────────────────────────────────────────────── -def example_pay_with_issuer(): - """ - Customer has already chosen their bank (e.g. from a bank picker on your - checkout page). Pass the issuer code so Buckaroo skips its own picker and - redirects straight to the bank. - """ - response = ( - base_builder() - .add_parameter("issuer", "INGBNL2A") - .pay() - ) - print_response("Pay — pre-selected bank (ING)", response) - return response - - -# ── 2. Pay — no issuer (Buckaroo shows bank picker) ─────────────────────────── +# ── 1. Pay (Buckaroo shows bank picker) ─────────────────────────── -def example_pay_no_issuer(): +def example_pay(): """ Omit the issuer. Buckaroo redirects the customer to its own bank-selection page. Simpler integration, slightly more friction for the customer. @@ -90,7 +74,7 @@ def example_pay_no_issuer(): return response -# ── 3. Full refund ──────────────────────────────────────────────────────────── +# ── 2. Full refund ──────────────────────────────────────────────────────────── def example_full_refund(original_transaction_key: str): """ @@ -103,7 +87,7 @@ def example_full_refund(original_transaction_key: str): return response -# ── 4. Partial refund ───────────────────────────────────────────────────────── +# ── 3. Partial refund ───────────────────────────────────────────────────────── def example_partial_refund(original_transaction_key: str): """Refund only part of the original amount.""" @@ -113,7 +97,7 @@ def example_partial_refund(original_transaction_key: str): return response -# ── 5. Instant refund ───────────────────────────────────────────────────────── +# ── 4. Instant refund ───────────────────────────────────────────────────────── def example_instant_refund(): """ @@ -132,8 +116,8 @@ def example_instant_refund(): print("=" * 55) # Run the pay examples - pay_response = example_pay_with_issuer() - example_pay_no_issuer() + pay_response = example_pay() + example_pay() # Use the transaction key from the first payment for follow-up examples. # In a real application you would store this key after receiving the push From 4119d138c65783276b4a2e514bbd24b882548d43 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Thu, 23 Apr 2026 16:35:53 +0800 Subject: [PATCH 04/21] refactor: modernize builder API and streamline transaction execution - Rename `BuckarooAppConfig` to `BuckarooConfig` and improve configuration handling. - Transition from snake_case to camelCase for public builder methods (e.g., `payEncrypted`, `cancelAuthorize`) to align with Buckaroo API naming conventions. - Introduce `TransactionExecutor` to centralize API communication, removing direct HTTP concerns from builders. - Simplify follow-up operations (refund, capture, cancel) by passing transaction keys directly instead of requiring a `TransactionContext` object. - Standardize status code grouping and enhance sensitive field masking in logs. - Refine error messages and validation logic within the builder hierarchy. --- buckaroo/_buckaroo_client.py | 2 - buckaroo/app.py | 15 +- buckaroo/builders/base_builder.py | 9 +- .../capabilities/authorize_capture_capable.py | 23 ++- .../capabilities/encrypted_pay_capable.py | 4 +- .../capabilities/fast_checkout_capable.py | 2 +- .../capabilities/instant_refund_capable.py | 2 +- .../builders/payments/credit_card_builder.py | 12 +- .../builders/payments/klarnakp_builder.py | 37 ++-- buckaroo/builders/payments/payment_builder.py | 159 +++++++----------- .../solutions/subscription_builder.py | 5 +- buckaroo/config/buckaroo_config.py | 2 +- buckaroo/exceptions/_buckaroo_error.py | 7 +- .../exceptions/_parameter_validation_error.py | 12 +- buckaroo/http/client.py | 6 - buckaroo/http/strategies/curl_strategy.py | 4 +- buckaroo/http/strategies/http_strategy.py | 16 +- buckaroo/http/strategies/requests_strategy.py | 1 + buckaroo/models/payment_request.py | 14 +- buckaroo/models/payment_response.py | 4 +- buckaroo/observers/logging_observer.py | 5 +- .../services/service_parameter_validator.py | 17 +- buckaroo/services/transaction_service.py | 73 ++++++++ examples/credit_card_payment.py | 13 +- examples/ideal_payment.py | 2 +- .../solutions/test_default_solution.py | 91 +++++----- .../test_authorize_capture_capable.py | 8 +- tests/unit/builders/test_base_builder.py | 29 ++-- tests/unit/models/test_payment_response.py | 14 +- 29 files changed, 301 insertions(+), 287 deletions(-) create mode 100644 buckaroo/services/transaction_service.py diff --git a/buckaroo/_buckaroo_client.py b/buckaroo/_buckaroo_client.py index 76b28e8..2570a60 100644 --- a/buckaroo/_buckaroo_client.py +++ b/buckaroo/_buckaroo_client.py @@ -121,8 +121,6 @@ def confirm_credential(self) -> bool: try: response = self.http_client.get("/json/Transaction/Specification/ideal") return response.success - except BuckarooError: - raise except Exception as e: logging.warning( "confirm_credential: unexpected error during credential check " diff --git a/buckaroo/app.py b/buckaroo/app.py index 870c356..b405fbb 100644 --- a/buckaroo/app.py +++ b/buckaroo/app.py @@ -25,7 +25,7 @@ @dataclass -class BuckarooAppConfig: +class BuckarooConfig: """Configuration for Buckaroo Application.""" # API Configuration @@ -45,7 +45,7 @@ class BuckarooAppConfig: retry_attempts: int = 3 @classmethod - def from_env(cls) -> 'BuckarooAppConfig': + def from_env(cls) -> 'BuckarooConfig': """Create configuration from environment variables.""" # Get log level from env log_level_str = os.getenv("BUCKAROO_LOG_LEVEL", "INFO").upper() @@ -70,7 +70,7 @@ def from_env(cls) -> 'BuckarooAppConfig': mode=os.getenv("BUCKAROO_MODE", "test"), log_level=log_level, log_destination=log_destination, - log_file=os.path.basename(os.getenv("BUCKAROO_LOG_FILE", "buckaroo_app.log")), + log_file=os.getenv("BUCKAROO_LOG_FILE", "buckaroo_app.log"), mask_sensitive_data=os.getenv("BUCKAROO_LOG_MASK_SENSITIVE", "true").lower() == "true", timeout=int(os.getenv("BUCKAROO_TIMEOUT", "30")), retry_attempts=int(os.getenv("BUCKAROO_RETRY_ATTEMPTS", "3")), @@ -90,14 +90,14 @@ class Buckaroo: >>> response = app.execute_payment(payment) """ - def __init__(self, config: Optional[BuckarooAppConfig] = None): + def __init__(self, config: Optional[BuckarooConfig] = None): """ Initialize Buckaroo Application. Args: config: Application configuration. If None, uses environment variables. """ - self.config = config or BuckarooAppConfig.from_env() + self.config = config or BuckarooConfig.from_env() self.logger: Optional[BuckarooLoggingObserver] = None self.client: Optional[BuckarooClient] = None @@ -108,7 +108,7 @@ def __init__(self, config: Optional[BuckarooAppConfig] = None): @classmethod def from_env(cls) -> "Buckaroo": """Create Buckaroo app from environment variables.""" - return cls(BuckarooAppConfig.from_env()) + return cls(BuckarooConfig.from_env()) @classmethod def quick_setup( @@ -126,7 +126,7 @@ def quick_setup( Returns: Configured Buckaroo app """ - config = BuckarooAppConfig( + config = BuckarooConfig( store_key=store_key, secret_key=secret_key, mode=mode, @@ -242,3 +242,4 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.logger.log_exception(exc_val, context={"context_manager": "exit"}) else: self.logger.log_debug("Exiting Buckaroo app context successfully") + diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index c960a60..6246ecc 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -39,10 +39,7 @@ def __init__(self, client, executor: Optional[ITransactionExecutor] = None): self._client_ip: Optional[ClientIP] = None self._service_parameters: List[Parameter] = [] self._payload: Dict[str, Any] = {} # Store original payload - self._validator = ServiceParameterValidator( - self.get_allowed_service_parameters, - self.get_service_name, - ) + self._validator = ServiceParameterValidator(self) def currency(self, currency: str) -> Self: """Set the currency for the payment.""" @@ -310,8 +307,8 @@ def _validate_required_fields(self, action: str = "Pay") -> None: if len(missing_fields) == 1: raise RequiredParameterMissingError(missing_fields[0], action=action) elif missing_fields: - raise ParameterValidationError( - f"Missing required fields: {', '.join(missing_fields)}", action=action + raise ValueError( + f"Missing required fields: {', '.join(missing_fields)}" ) def build(self, action: str = "Pay", validate: bool = True, strict_validation: bool = False) -> PaymentRequest: diff --git a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py index a1f9619..c84b784 100644 --- a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py +++ b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py @@ -10,7 +10,6 @@ from typing import Optional, TYPE_CHECKING from ....models.payment_response import PaymentResponse -from ....models.transaction_context import TransactionContext if TYPE_CHECKING: from ..payment_builder import PaymentBuilder @@ -23,30 +22,31 @@ def authorize(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: """Authorize a payment without capturing it (Credit Card only).""" return self.execute_action("Authorize", validate=validate) - def authorize_encrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def authorizeEncrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: """Authorize a payment using encrypted card data (Credit Card only).""" return self.execute_action("AuthorizeEncrypted", validate=validate) - def cancel_authorize(self: 'PaymentBuilder', ctx: Optional[TransactionContext] = None, validate: bool = True) -> PaymentResponse: + def cancelAuthorize( + self: 'PaymentBuilder', + original_transaction_key: Optional[str] = None, + validate: bool = True, + ) -> PaymentResponse: """Cancel a previously authorized payment. - Args: - ctx: Explicit transaction context. Falls back to ``_payload`` when omitted. - Uses AmountCredit (not AmountDebit) per Buckaroo API requirements. """ txn_key = ( - ctx.original_transaction_key if ctx else None - ) or self._payload.get('original_transaction_key') or self._payload.get('authorization_key') + original_transaction_key + or self._payload.get('original_transaction_key') + or self._payload.get('authorization_key') + ) if not txn_key: raise ValueError( - "Original transaction key is required for cancel_authorize " - "(pass a TransactionContext or set 'original_transaction_key' in payload)" + "original_transaction_key is required for cancel_authorize" ) request_data = self._build_keyed_request("CancelAuthorize", txn_key, validate=validate) - # Buckaroo API requires AmountCredit for cancel-authorize, not AmountDebit if 'AmountDebit' in request_data: request_data['AmountCredit'] = request_data.pop('AmountDebit') @@ -55,4 +55,3 @@ def cancel_authorize(self: 'PaymentBuilder', ctx: Optional[TransactionContext] = def capture(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: """Capture a previously authorized payment.""" return self.execute_action("Capture", validate=validate) - \ No newline at end of file diff --git a/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py index be06eb3..9163b95 100644 --- a/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py +++ b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py @@ -18,6 +18,6 @@ class EncryptedPayCapable: """Mixin for payment methods that support encryption (Credit Card).""" - def pay_encrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def payEncrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: """Process a payment using encrypted card data (Credit Card only).""" - return self.execute_action("PayEncrypted", validate=validate) \ No newline at end of file + return self.execute_action("PayEncrypted", validate=validate) diff --git a/buckaroo/builders/payments/capabilities/fast_checkout_capable.py b/buckaroo/builders/payments/capabilities/fast_checkout_capable.py index a1c2242..d276a2d 100644 --- a/buckaroo/builders/payments/capabilities/fast_checkout_capable.py +++ b/buckaroo/builders/payments/capabilities/fast_checkout_capable.py @@ -18,6 +18,6 @@ class FastCheckoutCapable: """Mixin for payment methods that support fast checkout (iDEAL, Sofort, PayConiq).""" - def pay_fast_checkout(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def payFastCheckout(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: """Enable PayFast Checkout (iDEAL, Sofort, PayConiq only).""" return self.execute_action("payFastCheckout", validate=validate) diff --git a/buckaroo/builders/payments/capabilities/instant_refund_capable.py b/buckaroo/builders/payments/capabilities/instant_refund_capable.py index 67080f5..b915ae0 100644 --- a/buckaroo/builders/payments/capabilities/instant_refund_capable.py +++ b/buckaroo/builders/payments/capabilities/instant_refund_capable.py @@ -18,6 +18,6 @@ class InstantRefundCapable: """Mixin for payment methods that support instant refunds (iDEAL, Sofort, PayConiq).""" - def instant_refund(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def instantRefund(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: """Initiate an instant refund (iDEAL, Sofort, PayConiq only).""" return self.execute_action("instantRefund", validate=validate) diff --git a/buckaroo/builders/payments/credit_card_builder.py b/buckaroo/builders/payments/credit_card_builder.py index 5ca2d64..111e410 100644 --- a/buckaroo/builders/payments/credit_card_builder.py +++ b/buckaroo/builders/payments/credit_card_builder.py @@ -10,6 +10,8 @@ class CreditcardBuilder(PaymentBuilder, EncryptedPayCapable, AuthorizeCaptureCapable): """Builder for Credit Card payments with authorization capabilities.""" + _serviceName = "creditcard" + def get_service_name(self) -> str: """Get the service name for Creditcard payments.""" return self._payload.get("brand", "CreditCard") @@ -29,11 +31,11 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: return {} - def pay_with_security_code(self, validate: bool = True) -> PaymentResponse: + def payWithSecurityCode(self, validate: bool = True) -> PaymentResponse: """Process a payment with a security code.""" return self.execute_action("PayWithSecurityCode", validate=validate) - def pay_with_token(self, validate: bool = True) -> PaymentResponse: + def payWithToken(self, validate: bool = True) -> PaymentResponse: """Process a payment using a Hosted Fields session token. Set the SessionId parameter via add_parameter('SessionId', token) before calling. @@ -42,7 +44,7 @@ def pay_with_token(self, validate: bool = True) -> PaymentResponse: """ return self.execute_action("PayWithToken", validate=validate) - def authorize_with_token(self, validate: bool = True) -> PaymentResponse: + def authorizeWithToken(self, validate: bool = True) -> PaymentResponse: """Authorize a payment using a Hosted Fields session token. Set the SessionId parameter via add_parameter('SessionId', token) before calling. @@ -50,6 +52,6 @@ def authorize_with_token(self, validate: bool = True) -> PaymentResponse: """ return self.execute_action("AuthorizeWithToken", validate=validate) - def pay_recurrent(self, validate: bool = True) -> PaymentResponse: + def payRecurrent(self, validate: bool = True) -> PaymentResponse: """Execute a recurring payment against a previously stored token.""" - return self.execute_action("PayRecurrent", validate=validate) \ No newline at end of file + return self.execute_action("PayRecurrent", validate=validate) diff --git a/buckaroo/builders/payments/klarnakp_builder.py b/buckaroo/builders/payments/klarnakp_builder.py index 7b7ff12..27a685b 100644 --- a/buckaroo/builders/payments/klarnakp_builder.py +++ b/buckaroo/builders/payments/klarnakp_builder.py @@ -9,16 +9,6 @@ class KlarnaKPBuilder(PaymentBuilder): """Builder for Klarna KP payments with bank transfer capabilities.""" def required_fields(self, action: str = "Pay") -> Dict[str, Any]: - """ - Get the required fields for this payment method and action. - Can be overridden by specific payment builders to customize required fields. - - Args: - action (str): The action being performed (Pay, Reserve, etc.) - - Returns: - Dict[str, Any]: Dictionary mapping field names to their current values - """ if action.lower() == "reserve": return { "currency": self._currency, @@ -84,28 +74,23 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: } return {} - - def reserve(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + + def reserve(self, validate: bool = True) -> PaymentResponse: """Create a Klarna KP reservation.""" - payment_request = self.build("Reserve", validate=validate) - return self._post_data_request(payment_request.to_dict()) + return self._post_data_request(self.build("Reserve", validate=validate).to_dict()) - def cancel_reservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def cancelReservation(self, validate: bool = True) -> PaymentResponse: """Cancel a Klarna KP reservation.""" - payment_request = self.build("CancelReservation", validate=validate) - return self._post_data_request(payment_request.to_dict()) + return self._post_data_request(self.build("CancelReservation", validate=validate).to_dict()) - def update_reservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def updateReservation(self, validate: bool = True) -> PaymentResponse: """Update a Klarna KP reservation.""" - payment_request = self.build("UpdateReservation", validate=validate) - return self._post_data_request(payment_request.to_dict()) + return self._post_data_request(self.build("UpdateReservation", validate=validate).to_dict()) - def extend_reservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def extendReservation(self, validate: bool = True) -> PaymentResponse: """Extend a Klarna KP reservation.""" - payment_request = self.build("ExtendReservation", validate=validate) - return self._post_data_request(payment_request.to_dict()) + return self._post_data_request(self.build("ExtendReservation", validate=validate).to_dict()) - def add_shipping_info(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def addShippingInfo(self, validate: bool = True) -> PaymentResponse: """Add shipping information to a Klarna KP order.""" - payment_request = self.build("AddShippingInfo", validate=validate) - return self._post_data_request(payment_request.to_dict()) \ No newline at end of file + return self._post_data_request(self.build("AddShippingInfo", validate=validate).to_dict()) diff --git a/buckaroo/builders/payments/payment_builder.py b/buckaroo/builders/payments/payment_builder.py index eabac25..97852c1 100644 --- a/buckaroo/builders/payments/payment_builder.py +++ b/buckaroo/builders/payments/payment_builder.py @@ -1,56 +1,36 @@ +from __future__ import annotations + from typing import Dict, Any, Optional from ..base_builder import BaseBuilder from ...models.payment_response import PaymentResponse -from ...models.transaction_context import TransactionContext -from ...exceptions._parameter_validation_error import ParameterValidationError, RequiredParameterMissingError class PaymentBuilder(BaseBuilder): """Base class for all payment method builders. - Adds the payment lifecycle on top of the shared builder scaffolding in - ``BaseBuilder``: pay, refund, capture, cancel, and their helpers. - - ``SolutionBuilder`` inherits from ``BaseBuilder`` directly and intentionally - does *not* get these methods. + Adds payment lifecycle methods (pay, refund, capture, cancel, execute_action) + that are specific to payment flows. ``SolutionBuilder`` inherits from + ``BaseBuilder`` directly and does NOT get these methods. """ - # ── Primary action ──────────────────────────────────────────────────────── - def pay(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: - """Execute the payment. - - Args: - validate: Validate and filter service parameters before building. - strict_validation: Raise on invalid parameters instead of filtering. - - Raises: - RequiredParameterMissingError: If a required field is missing. - ParameterValidationError: If a service parameter is invalid (strict mode). - AuthenticationError: If HMAC authentication fails. - """ + """Execute the payment.""" request_data = self.build("Pay", validate=validate, strict_validation=strict_validation).to_dict() return self._post_transaction(request_data) - # ── Follow-up operations ────────────────────────────────────────────────── - - def refund(self, ctx: Optional[TransactionContext] = None, validate: bool = True) -> PaymentResponse: - """Execute a full refund. - - Args: - ctx: Carries ``original_transaction_key`` and an optional ``amount`` - override. Falls back to ``_payload['originalTransactionKey']`` - when omitted (set by ``from_dict()``). - - Raises: - RequiredParameterMissingError: If no transaction key can be resolved. - """ - txn_key = (ctx.original_transaction_key if ctx else None) or self._payload.get('originalTransactionKey') + def refund( + self, + original_transaction_key: Optional[str] = None, + amount: Optional[float] = None, + validate: bool = True, + ) -> PaymentResponse: + """Execute a full refund.""" + txn_key = original_transaction_key or self._payload.get('original_transaction_key') if not txn_key: - raise RequiredParameterMissingError("originalTransactionKey", action="Refund") + raise ValueError("Original transaction key is required for refund") - refund_amount = (ctx.amount if ctx else None) or self._payload.get('refund_amount') + refund_amount = amount if amount is not None else self._payload.get('refund_amount') request_data = self._build_keyed_request('Refund', txn_key, validate=validate) if refund_amount is not None: @@ -62,43 +42,51 @@ def refund(self, ctx: Optional[TransactionContext] = None, validate: bool = True return self._post_transaction(request_data) - def partial_refund(self, ctx: TransactionContext) -> PaymentResponse: - """Execute a partial refund. - - Args: - ctx: ``ctx.amount`` must be set and greater than 0. - - Raises: - ParameterValidationError: If ``ctx.amount`` is missing or non-positive. - """ - if not ctx.amount or ctx.amount <= 0: - raise ParameterValidationError( - "Partial refund amount must be greater than 0", - parameter_name="refund_amount", - action="Refund", - ) - return self.refund(ctx) - - def capture(self, ctx: Optional[TransactionContext] = None, validate: bool = True) -> PaymentResponse: - """Capture a previously authorized payment. - - Args: - ctx: ``ctx.original_transaction_key`` is the authorization key; - ``ctx.amount`` overrides the capture amount when set. - Falls back to ``_payload`` when omitted. - - Raises: - RequiredParameterMissingError: If no transaction key can be resolved. - """ + def partial_refund( + self, + original_transaction_key: Optional[str] = None, + amount: Optional[float] = None, + ) -> PaymentResponse: + """Execute a partial refund.""" + if not amount or amount <= 0: + raise ValueError("Partial refund amount must be greater than 0") + + _MISSING = object() + saved_key = self._payload.get('original_transaction_key', _MISSING) + saved_amount = self._payload.get('refund_amount', _MISSING) + + if original_transaction_key is not None: + self._payload['original_transaction_key'] = original_transaction_key + self._payload['refund_amount'] = amount + + try: + return self.refund() + finally: + if saved_key is _MISSING: + self._payload.pop('original_transaction_key', None) + else: + self._payload['original_transaction_key'] = saved_key + if saved_amount is _MISSING: + self._payload.pop('refund_amount', None) + else: + self._payload['refund_amount'] = saved_amount + + def capture( + self, + original_transaction_key: Optional[str] = None, + amount: Optional[float] = None, + validate: bool = True, + ) -> PaymentResponse: + """Capture a previously authorized payment.""" auth_key = ( - (ctx.original_transaction_key if ctx else None) + original_transaction_key or self._payload.get('authorization_key') or self._payload.get('original_transaction_key') ) if not auth_key: - raise RequiredParameterMissingError("originalTransactionKey", action="Capture") + raise ValueError("Authorization key is required for capture") - capture_amount = (ctx.amount if ctx else None) or self._payload.get('capture_amount') + capture_amount = amount if amount is not None else self._payload.get('capture_amount') request_data = self._build_keyed_request('Capture', auth_key, validate=validate) if capture_amount is not None: @@ -106,23 +94,15 @@ def capture(self, ctx: Optional[TransactionContext] = None, validate: bool = Tru return self._post_transaction(request_data) - def cancel(self, ctx: Optional[TransactionContext] = None) -> PaymentResponse: - """Cancel a pending or authorized transaction. - - Args: - ctx: Carries the transaction key to cancel. - Falls back to ``_payload`` when omitted. - - Raises: - RequiredParameterMissingError: If no transaction key can be resolved. - """ + def cancel(self, original_transaction_key: Optional[str] = None) -> PaymentResponse: + """Cancel a pending or authorized transaction.""" txn_key = ( - (ctx.original_transaction_key if ctx else None) + original_transaction_key or self._payload.get('cancel_key') or self._payload.get('original_transaction_key') ) if not txn_key: - raise RequiredParameterMissingError("originalTransactionKey", action="Cancel") + raise ValueError("Transaction key is required for cancel") request_data = self._build_keyed_request('Pay', txn_key) request_data.pop('AmountDebit', None) @@ -130,30 +110,13 @@ def cancel(self, ctx: Optional[TransactionContext] = None) -> PaymentResponse: return self._post_transaction(request_data) - # ── Generic action helper ───────────────────────────────────────────────── - def execute_action(self, action: str, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: - """Execute any named action supported by this payment method. - - Used by capability mixins (``pay_encrypted``, ``instant_refund``, etc.) - and by concrete builder methods that map directly to a single Buckaroo action. - - Args: - action: Buckaroo action string, e.g. ``'PayEncrypted'``, ``'instantRefund'``. - validate: Validate and filter service parameters before building. - strict_validation: Raise on invalid parameters instead of filtering. - """ + """Execute any named action supported by this payment method.""" request_data = self.build(action, validate=validate, strict_validation=strict_validation).to_dict() return self._post_transaction(request_data) - # ── Internal helper ─────────────────────────────────────────────────────── - def _build_keyed_request(self, action: str, txn_key: str, validate: bool = True) -> Dict[str, Any]: - """Build a request dict that references an original transaction. - - Sets ``OriginalTransactionKey`` so callers only need to handle - any additional amount/field adjustments. - """ + """Build a request dict that references an original transaction.""" request_data = self.build(action, validate=validate).to_dict() request_data['OriginalTransactionKey'] = txn_key return request_data diff --git a/buckaroo/builders/solutions/subscription_builder.py b/buckaroo/builders/solutions/subscription_builder.py index bccdf48..ab97481 100644 --- a/buckaroo/builders/solutions/subscription_builder.py +++ b/buckaroo/builders/solutions/subscription_builder.py @@ -17,7 +17,6 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: return {} - def create_subscription(self, validate: bool = True) -> Any: + def createSubscription(self, validate: bool = True) -> Any: """Create a subscription.""" - payload = self.build("CreateSubscription", validate=validate) - return self._post_data_request(payload.to_dict()) \ No newline at end of file + return self._post_data_request(self.build("CreateSubscription", validate=validate).to_dict()) \ No newline at end of file diff --git a/buckaroo/config/buckaroo_config.py b/buckaroo/config/buckaroo_config.py index 8b7bf0b..844417f 100644 --- a/buckaroo/config/buckaroo_config.py +++ b/buckaroo/config/buckaroo_config.py @@ -328,7 +328,7 @@ def disable_ssl_verification(self) -> "ConfigBuilder": warnings.warn( "SSL verification is disabled. This exposes the connection to " "man-in-the-middle attacks and must never be used in production.", - SecurityWarning, + UserWarning, stacklevel=2, ) self._config_dict["verify_ssl"] = False diff --git a/buckaroo/exceptions/_buckaroo_error.py b/buckaroo/exceptions/_buckaroo_error.py index bc56f2d..c6fcc7d 100644 --- a/buckaroo/exceptions/_buckaroo_error.py +++ b/buckaroo/exceptions/_buckaroo_error.py @@ -12,7 +12,12 @@ def __init__( code: Optional[str] = None, request_id: Optional[str] = None, ): - super().__init__(message) + if message is None: + super().__init__() + elif http_body is None: + super().__init__(message) + else: + super().__init__(message, http_body) self._message = message self.http_body = http_body self.http_status = http_status diff --git a/buckaroo/exceptions/_parameter_validation_error.py b/buckaroo/exceptions/_parameter_validation_error.py index 6deefeb..bf9b67b 100644 --- a/buckaroo/exceptions/_parameter_validation_error.py +++ b/buckaroo/exceptions/_parameter_validation_error.py @@ -35,9 +35,15 @@ def __init__( service_name: Optional[str] = None, **kwargs, ): - service_info = f" for {service_name}" if service_name else "" - action_info = f" {action} action" if action else "" - message = f"Required parameter '{parameter_name}' is missing{service_info}{action_info}" + if service_name and action: + context = f" for {service_name} {action} action" + elif action: + context = f" for {action} action" + elif service_name: + context = f" for {service_name}" + else: + context = "" + message = f"Required parameter '{parameter_name}' is missing{context}" super().__init__( message=message, parameter_name=parameter_name, diff --git a/buckaroo/http/client.py b/buckaroo/http/client.py index 92d6c0f..3c5738f 100644 --- a/buckaroo/http/client.py +++ b/buckaroo/http/client.py @@ -340,12 +340,6 @@ def to_dict(self) -> Dict[str, Any]: "success": self.success, "data": self.data, "headers": self.headers, - "is_successful_payment": self.is_successful_payment(), - "payment_key": self.get_payment_key(), - "transaction_key": self.get_transaction_key(), - "buckaroo_status_code": self.get_status_code(), - "buckaroo_status_message": self.get_status_message(), - "redirect_url": self.get_redirect_url(), } diff --git a/buckaroo/http/strategies/curl_strategy.py b/buckaroo/http/strategies/curl_strategy.py index c799aac..ec09009 100644 --- a/buckaroo/http/strategies/curl_strategy.py +++ b/buckaroo/http/strategies/curl_strategy.py @@ -22,7 +22,7 @@ def __init__(self): super().__init__() def configure(self, **kwargs) -> None: - super().configure(**kwargs) + self._apply_defaults(**kwargs) def request( self, @@ -61,6 +61,8 @@ def request( ) # Execute curl with retry logic + if self._retry_attempts <= 0: + raise Exception("Request failed after all retry attempts") last_exception = None for attempt in range(self._retry_attempts): try: diff --git a/buckaroo/http/strategies/http_strategy.py b/buckaroo/http/strategies/http_strategy.py index ec95a6f..3da5487 100644 --- a/buckaroo/http/strategies/http_strategy.py +++ b/buckaroo/http/strategies/http_strategy.py @@ -47,13 +47,17 @@ def __init__(self) -> None: self._retry_delay: float = 1.0 self._default_headers: Dict[str, str] = {} + def _apply_defaults(self, **kwargs) -> None: + """Apply configuration kwargs, resetting to defaults for omitted keys.""" + self._timeout = kwargs.get('timeout', 30) + self._verify_ssl = kwargs.get('verify_ssl', True) + self._retry_attempts = kwargs.get('retry_attempts', 3) + self._retry_delay = kwargs.get('retry_delay', 1.0) + self._default_headers = kwargs.get('default_headers', {}) + + @abstractmethod def configure(self, **kwargs) -> None: - """Store common configuration shared by all strategies.""" - self._timeout = kwargs.get('timeout', self._timeout) - self._verify_ssl = kwargs.get('verify_ssl', self._verify_ssl) - self._retry_attempts = kwargs.get('retry_attempts', self._retry_attempts) - self._retry_delay = kwargs.get('retry_delay', self._retry_delay) - self._default_headers = kwargs.get('default_headers', self._default_headers) + """Configure this strategy. Subclasses must implement this.""" @abstractmethod def request( diff --git a/buckaroo/http/strategies/requests_strategy.py b/buckaroo/http/strategies/requests_strategy.py index 7104772..bd6c6f5 100644 --- a/buckaroo/http/strategies/requests_strategy.py +++ b/buckaroo/http/strategies/requests_strategy.py @@ -57,6 +57,7 @@ def configure(self, **kwargs) -> None: "Please install it with: pip install requests" ) + self._apply_defaults(**kwargs) self._retry_attempts = kwargs.get("retry_attempts", 3) self._retry_delay = kwargs.get("retry_delay", 1.0) diff --git a/buckaroo/models/payment_request.py b/buckaroo/models/payment_request.py index 9325367..a24eb82 100644 --- a/buckaroo/models/payment_request.py +++ b/buckaroo/models/payment_request.py @@ -6,7 +6,7 @@ class Parameter: """Model for a service parameter (used for both requests and responses).""" name: str - value: str + value: Optional[str] group_type: Optional[str] = None group_id: Optional[str] = None @@ -27,7 +27,7 @@ def from_dict(cls, data: Dict[str, Any]) -> 'Parameter': value = data.get('Value') return cls( name=data.get('Name', ''), - value=str(value) if value is not None else '', + value=str(value) if value is not None else None, group_type=data.get('GroupType') or None, group_id=data.get('GroupID') or None, ) @@ -77,8 +77,8 @@ def add_parameter(self, parameter: Union[Dict[str, Any], Parameter]) -> "Service parameter = Parameter( name=parameter.get("Name", parameter.get("name", "")), value=parameter.get("Value", parameter.get("value", "")), - group_type=parameter.get("GroupType", parameter.get("group_type", "")), - group_id=parameter.get("GroupID", parameter.get("group_id", "")), + group_type=parameter.get("GroupType", parameter.get("group_type")) or None, + group_id=parameter.get("GroupID", parameter.get("group_id")) or None, ) if self.parameters is None: self.parameters = [] @@ -127,13 +127,11 @@ def __post_init__(self): def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API request.""" request_dict: Dict[str, Any] = { + "Currency": self.currency, + "AmountDebit": self.amount_debit, "ContinueOnIncomplete": self.continue_on_incomplete, } - if self.currency is not None: - request_dict["Currency"] = self.currency - if self.amount_debit is not None: - request_dict["AmountDebit"] = self.amount_debit if self.description is not None: request_dict["Description"] = self.description if self.invoice is not None: diff --git a/buckaroo/models/payment_response.py b/buckaroo/models/payment_response.py index 9f001d0..bec651d 100644 --- a/buckaroo/models/payment_response.py +++ b/buckaroo/models/payment_response.py @@ -35,8 +35,8 @@ class BuckarooStatusCode: CANCELLED_BY_CONSUMER_LATE = 891 PENDING_CODES = {PENDING_INPUT, PENDING_PROCESSING, AWAITING_CONSUMER, ON_HOLD} - FAILED_CODES = {PAYMENT_FAILED, VALIDATION_FAILED, TECHNICAL_ERROR, REJECTED, CANCELLED_BY_MERCHANT, CANCELLED_BY_CONSUMER} - CANCELLED_CODES = {CANCELLED, CANCELLED_BY_CONSUMER_LATE} + FAILED_CODES = {PAYMENT_FAILED, VALIDATION_FAILED, TECHNICAL_ERROR, REJECTED} + CANCELLED_CODES = {CANCELLED, CANCELLED_BY_CONSUMER_LATE, CANCELLED_BY_MERCHANT, CANCELLED_BY_CONSUMER} @dataclass diff --git a/buckaroo/observers/logging_observer.py b/buckaroo/observers/logging_observer.py index 47e1700..6e7cb0f 100644 --- a/buckaroo/observers/logging_observer.py +++ b/buckaroo/observers/logging_observer.py @@ -68,9 +68,10 @@ def __init__(self, config: Optional[LogConfig] = None): self.config = config or LogConfig() self.logger = self._setup_logger() self._sensitive_fields = { - 'secret_key', 'password', 'token', 'authorization', 'cvv', + 'secret_key', 'password', 'token', 'authorization', 'cvv', 'cvc', 'cardnumber', 'card_number', 'iban', 'account_number', 'store_key', 'x-buckaroo-store-key', + 'bic', 'pan', 'expirydate', 'encryptedcarddata', } def _setup_logger(self) -> logging.Logger: @@ -502,7 +503,7 @@ def create_logger_from_env() -> BuckarooLoggingObserver: dest_str = os.getenv("BUCKAROO_LOG_DESTINATION", "both").lower() destination = LogDestination(dest_str) if dest_str in [d.value for d in LogDestination] else LogDestination.BOTH - log_file = os.path.basename(os.getenv("BUCKAROO_LOG_FILE", "buckaroo_sdk.log")) + log_file = os.getenv("BUCKAROO_LOG_FILE", "buckaroo_sdk.log") mask_sensitive = os.getenv("BUCKAROO_LOG_MASK_SENSITIVE", "true").lower() == "true" config = LogConfig( diff --git a/buckaroo/services/service_parameter_validator.py b/buckaroo/services/service_parameter_validator.py index 54536dd..be6de6f 100644 --- a/buckaroo/services/service_parameter_validator.py +++ b/buckaroo/services/service_parameter_validator.py @@ -3,7 +3,7 @@ """ import logging -from typing import Callable, Dict, Any, List +from typing import Any, Dict, List from buckaroo.models.payment_request import Parameter from buckaroo.exceptions._parameter_validation_error import ( ParameterValidationError, @@ -14,19 +14,14 @@ class ServiceParameterValidator: """Handles validation and filtering of service parameters for payment methods.""" - def __init__( - self, - get_allowed_params: Callable[[str], Dict[str, Any]], - get_service_name: Callable[[], str], - ): + def __init__(self, builder) -> None: """ Args: - get_allowed_params: Callable that returns the allowed parameters dict - for a given action string. - get_service_name: Callable that returns the service name string. + builder: A builder object that exposes ``get_allowed_service_parameters(action)`` + and ``get_service_name()`` methods. """ - self._get_allowed_params = get_allowed_params - self._get_service_name = get_service_name + self._get_allowed_params = builder.get_allowed_service_parameters + self._get_service_name = builder.get_service_name def normalize_parameter_name(self, param_name: str) -> str: """Normalize parameter name to lowercase and remove underscores for matching. diff --git a/buckaroo/services/transaction_service.py b/buckaroo/services/transaction_service.py new file mode 100644 index 0000000..a7457ba --- /dev/null +++ b/buckaroo/services/transaction_service.py @@ -0,0 +1,73 @@ +""" +Transaction execution service for Buckaroo SDK. + +Centralises the logic for posting requests to the Buckaroo API so that +builders only need to construct the request payload, not manage HTTP concerns. +""" + +from typing import Dict, Any +try: + from typing import Protocol, runtime_checkable +except ImportError: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + +from ..models.payment_response import PaymentResponse + + +@runtime_checkable +class ITransactionExecutor(Protocol): + """Protocol that any transaction executor must satisfy. + + Builders depend on this abstraction, not on ``TransactionExecutor`` directly, + so tests can inject a mock without touching the network. + """ + + def post_transaction(self, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a standard payment transaction.""" + ... + + def post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a data request (subscriptions, Klarna KP, etc.).""" + ... + + +class TransactionExecutor: + """ + Handles posting payment requests to the Buckaroo API. + + Builders hold a reference to this executor and delegate all HTTP calls + to it, keeping the HTTP concern out of the builder layer. + """ + + _TRANSACTION_ENDPOINT = '/json/transaction' + _DATA_REQUEST_ENDPOINT = '/json/DataRequest' + + def __init__(self, client) -> None: + """ + Args: + client: BuckarooClient instance that owns the http_client. + """ + self._client = client + + def post(self, endpoint: str, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a request to the given Buckaroo endpoint. + + Args: + endpoint: API path, e.g. '/json/transaction' + request_data: Serialised payment request dictionary + + Returns: + PaymentResponse: Structured response object (never None) + """ + response = self._client.http_client.post(endpoint, request_data) + if response is None: + return PaymentResponse({}) + return PaymentResponse(response.to_dict()) + + def post_transaction(self, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a standard payment transaction.""" + return self.post(self._TRANSACTION_ENDPOINT, request_data) + + def post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse: + """Post a data request (used for subscriptions, Klarna KP, etc.).""" + return self.post(self._DATA_REQUEST_ENDPOINT, request_data) diff --git a/examples/credit_card_payment.py b/examples/credit_card_payment.py index 671b95f..663a7a0 100644 --- a/examples/credit_card_payment.py +++ b/examples/credit_card_payment.py @@ -4,10 +4,10 @@ Covers: - Pay with encrypted card data (most common server-side flow) - - Pay with Hosted Fields token (pay_with_token) + - Pay with Hosted Fields token (payWithToken) - Authorize then capture (two-step flow) - Authorize then cancel (cancel_authorize) - - Recurring payment (pay_recurrent) + - Recurring payment (payRecurrent) - Refund The credit card service name is dynamic: it reflects the card brand @@ -78,7 +78,7 @@ def example_pay_encrypted(encrypted_card_data: str): response = ( base_builder() .add_parameter("encryptedCardData", encrypted_card_data) - .pay_encrypted() + .payEncrypted() ) print_response("Pay encrypted (Visa)", response) return response @@ -99,7 +99,7 @@ def example_pay_with_token(session_id: str): response = ( base_builder() .add_parameter("sessionId", session_id) - .pay_with_token() + .payWithToken() ) print_response("Pay with Hosted Fields token", response) return response @@ -150,8 +150,7 @@ def example_authorize_and_cancel(encrypted_card_data: str): print(" No transaction key returned — cannot cancel.") return - ctx = TransactionContext(original_transaction_key=auth_key) - cancel_response = builder.cancel_authorize(ctx) + cancel_response = builder.cancelAuthorize(original_transaction_key=auth_key) print_response("Cancel authorize", cancel_response) return cancel_response @@ -167,7 +166,7 @@ def example_pay_recurrent(original_transaction_key: str): response = ( base_builder() .add_parameter("originalTransactionKey", original_transaction_key) - .pay_recurrent() + .payRecurrent() ) print_response("Recurring payment", response) return response diff --git a/examples/ideal_payment.py b/examples/ideal_payment.py index 009623a..7a6dc49 100644 --- a/examples/ideal_payment.py +++ b/examples/ideal_payment.py @@ -104,7 +104,7 @@ def example_instant_refund(): Instant refund sends money back immediately without a separate push notification cycle. Buckaroo must have the customer IBAN on file. """ - response = base_builder().instant_refund() + response = base_builder().instantRefund() print_response("Instant refund", response) return response diff --git a/tests/feature/solutions/test_default_solution.py b/tests/feature/solutions/test_default_solution.py index f2ae1a6..24e35b1 100644 --- a/tests/feature/solutions/test_default_solution.py +++ b/tests/feature/solutions/test_default_solution.py @@ -1,71 +1,61 @@ -from tests.support.mock_request import BuckarooMockRequest +import pytest + from tests.support.helpers import Helpers class TestDefaultSolutionFeature: - """Default solution uses the fallback DefaultBuilder since 'default' is not - registered in SolutionMethodFactory._solution_methods.""" + """DefaultBuilder is a fallback for unregistered solution methods. + + It inherits from SolutionBuilder (not PaymentBuilder), so it does NOT + have payment lifecycle methods like pay() or refund(). Callers must + use the explicit action builders (e.g. create_subscription) or call + build() directly. + """ + + def test_default_solution_not_in_factory_registry(self): + """DefaultBuilder is a fallback, not a registered solution method.""" + from buckaroo.factories.solution_method_factory import SolutionMethodFactory - def test_default_solution_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): - response_body = Helpers.pending_redirect_response("default") - mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) - response = buckaroo.solutions.create_solution( + assert not SolutionMethodFactory.is_method_supported("default") + + def test_default_solution_has_no_pay_method(self, buckaroo): + """Solution builders do not expose pay() — that belongs to PaymentBuilder.""" + builder = buckaroo.solutions.create_solution( "default", Helpers.standard_payload( invoice="INV-SOL-DEF-001", description="Test default solution", ), - ).pay() - assert response.is_pending() - assert response.get_redirect_url() is not None - assert response.key == response_body["Key"] - - def test_default_solution_pay_success(self, buckaroo, mock_strategy): - response_body = Helpers.success_response( - { - "Services": [{"Name": "default", "Action": "Pay", "Parameters": []}], - "ServiceCode": "default", - "AmountDebit": 25.00, - "Currency": "EUR", - } ) - mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) - response = buckaroo.solutions.create_solution( + assert not hasattr(builder, "pay") + + def test_default_solution_has_no_refund_method(self, buckaroo): + """Solution builders do not expose refund() — that belongs to PaymentBuilder.""" + builder = buckaroo.solutions.create_solution( "default", Helpers.standard_payload( invoice="INV-SOL-DEF-002", - amount=25.00, - description="Test default solution success", + description="Test default solution", + original_transaction_key="ABCD1234", ), - ).pay() - assert response.status.code.code == 190 - assert response.key == response_body["Key"] + ) + assert not hasattr(builder, "refund") - def test_default_solution_refund(self, buckaroo, mock_strategy): - response_body = Helpers.refund_response("default") - mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) - response = buckaroo.solutions.create_solution( + def test_default_solution_can_build_request(self, buckaroo): + """DefaultBuilder can still construct a PaymentRequest via build().""" + builder = buckaroo.solutions.create_solution( "default", Helpers.standard_payload( invoice="INV-SOL-DEF-003", - description="Test default solution refund", - original_transaction_key="ABCD1234", + description="Test default solution build", ), - ).refund() - assert response.status.code.code == 190 - assert response.key == response_body["Key"] - - def test_default_solution_not_in_factory_registry(self): - """DefaultBuilder is a fallback, not a registered solution method.""" - from buckaroo.factories.solution_method_factory import SolutionMethodFactory - - assert not SolutionMethodFactory.is_method_supported("default") + ) + request = builder.build(validate=False).to_dict() + assert request["Invoice"] == "INV-SOL-DEF-003" - def test_default_solution_service_name_from_payload(self, buckaroo, mock_strategy): + def test_default_solution_service_name_from_payload(self, buckaroo): """DefaultBuilder reads service name from payload's 'method' key.""" - response_body = Helpers.pending_redirect_response("custommethod") - mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) - response = buckaroo.solutions.create_solution( + builder = buckaroo.solutions.create_solution( "nonexistent", Helpers.standard_payload( invoice="INV-SOL-DEF-004", @@ -73,5 +63,10 @@ def test_default_solution_service_name_from_payload(self, buckaroo, mock_strateg description="Test custom method fallback", method="custommethod", ), - ).pay() - assert response.is_pending() + ) + assert builder.get_service_name() == "custommethod" + + def test_default_solution_service_name_fallback_when_no_method_key(self, buckaroo): + """DefaultBuilder falls back to 'Unknown' when no method key in payload.""" + builder = buckaroo.solutions.create_solution("default") + assert builder.get_service_name() == "Unknown" diff --git a/tests/unit/builders/payments/capabilities/test_authorize_capture_capable.py b/tests/unit/builders/payments/capabilities/test_authorize_capture_capable.py index 3dd5e75..50b3544 100644 --- a/tests/unit/builders/payments/capabilities/test_authorize_capture_capable.py +++ b/tests/unit/builders/payments/capabilities/test_authorize_capture_capable.py @@ -87,7 +87,7 @@ def test_authorize_encrypted_posts_action_AuthorizeEncrypted(self): # --------------------------------------------------------------------------- # capture() # -# AuthorizeCaptureCapable.capture is dead code: BaseBuilder.capture shadows +# AuthorizeCaptureCapable.capture is dead code: PaymentBuilder.capture shadows # it through the MRO on every real builder (see TestMroShadowing below). Any # test calling the mixin method directly would only exercise unreachable code. # @@ -212,8 +212,8 @@ def test_base_builder_capture_shadows_mixin_capture(self): _, client = wire_recording_http() builder = _ready_builder(client) - # The capture the instance resolves is BaseBuilder's (needs auth key). - assert type(builder).capture.__qualname__ == "BaseBuilder.capture" + # The capture the instance resolves is PaymentBuilder's (needs auth key). + assert type(builder).capture.__qualname__ == "PaymentBuilder.capture" # The mixin's simpler capture is still reachable via the class itself. assert AuthorizeCaptureCapable.capture.__qualname__ == ("AuthorizeCaptureCapable.capture") @@ -240,7 +240,7 @@ class TestMultiCapabilityBuilder: def test_all_six_action_methods_invoke_and_post_expected_actions(self): """MRO-resolved action methods must each post the right Buckaroo Action. - ``capture`` resolves to :meth:`BaseBuilder.capture` (needs an auth + ``capture`` resolves to :meth:`PaymentBuilder.capture` (needs an auth key) - the mixin's ``capture`` is dead code. This test pins both the resolution AND the on-wire Action for every method on a composed builder. diff --git a/tests/unit/builders/test_base_builder.py b/tests/unit/builders/test_base_builder.py index e82d599..c112829 100644 --- a/tests/unit/builders/test_base_builder.py +++ b/tests/unit/builders/test_base_builder.py @@ -1,11 +1,13 @@ -"""Tests for :class:`buckaroo.builders.base_builder.BaseBuilder`. - -Exercises the base builder directly via a tiny concrete subclass — no -coupling to any real payment method and, importantly, no inheritance -from :class:`PaymentBuilder` (which shadows nearly every ``BaseBuilder`` -method with an identical copy). Tests assert through the public API -(``PaymentRequest.to_dict()``, returned ``Parameter`` objects) rather -than private attributes. +"""Tests for :class:`buckaroo.builders.base_builder.BaseBuilder` and +:class:`buckaroo.builders.payments.payment_builder.PaymentBuilder`. + +Shared ``BaseBuilder`` behavior (fluent setters, ``build()``, validation, +``add_parameter``, ``from_dict``) is exercised through ``_ConcreteBaseBuilder`` +which extends ``PaymentBuilder``. Payment lifecycle methods (``pay``, +``refund``, ``capture``, etc.) live on ``PaymentBuilder`` and are also +exercised here since this file owns the lightweight stub infrastructure. +Tests assert through the public API (``PaymentRequest.to_dict()``, +returned ``Parameter`` objects) rather than private attributes. """ from __future__ import annotations @@ -16,6 +18,7 @@ import pytest from buckaroo.builders.base_builder import BaseBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder from buckaroo.exceptions._parameter_validation_error import ( ParameterValidationError, ) @@ -23,11 +26,11 @@ # --------------------------------------------------------------------------- -# Helpers: concrete BaseBuilder subclass with no PaymentBuilder in the MRO. +# Helpers: concrete PaymentBuilder subclass used for testing. -class _ConcreteBaseBuilder(BaseBuilder): - """Minimal concrete :class:`BaseBuilder` for testing its own code paths.""" +class _ConcreteBaseBuilder(PaymentBuilder): + """Minimal concrete subclass for testing ``BaseBuilder`` and ``PaymentBuilder`` code paths.""" def __init__( self, @@ -60,10 +63,6 @@ def _core_allowed_params() -> dict: } -# ``_ConcreteBaseBuilder`` extends :class:`BaseBuilder` directly so these tests -# hit the base-class methods; ``make_test_builder`` returns a -# :class:`PaymentBuilder` subclass, which would shadow nearly every method with -# an identical copy and mask base-class coverage. def _make_builder( *, service_name: str = "dummy", diff --git a/tests/unit/models/test_payment_response.py b/tests/unit/models/test_payment_response.py index 6013be0..5039a62 100644 --- a/tests/unit/models/test_payment_response.py +++ b/tests/unit/models/test_payment_response.py @@ -16,20 +16,18 @@ PENDING_CODES = ( BuckarooStatusCode.PENDING_INPUT, BuckarooStatusCode.PENDING_PROCESSING, - BuckarooStatusCode.PENDING_CONSUMER, - BuckarooStatusCode.AWAITING_TRANSFER, + BuckarooStatusCode.AWAITING_CONSUMER, + BuckarooStatusCode.ON_HOLD, ) CANCELLED_CODES = ( - BuckarooStatusCode.CANCELLED_BY_USER, + BuckarooStatusCode.CANCELLED_BY_CONSUMER, BuckarooStatusCode.CANCELLED_BY_MERCHANT, ) FAILED_CODES = ( - BuckarooStatusCode.FAILED, - BuckarooStatusCode.VALIDATION_FAILURE, - BuckarooStatusCode.TECHNICAL_FAILURE, + BuckarooStatusCode.PAYMENT_FAILED, + BuckarooStatusCode.VALIDATION_FAILED, + BuckarooStatusCode.TECHNICAL_ERROR, BuckarooStatusCode.REJECTED, - BuckarooStatusCode.REJECTED_BY_USER, - BuckarooStatusCode.REJECTED_TECHNICAL, ) From 492600cabe46ce718475ad86bf61bb3bed124a4e Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Tue, 28 Apr 2026 13:35:03 +0800 Subject: [PATCH 05/21] refactor: streamline transaction execution and simplify follow-up API - Replaces `.execute()` with `.pay()` for more intuitive transaction initiation in builders and examples. - Removes the `TransactionContext` model, allowing direct passing of `original_transaction_key` and other parameters for operations like `capture`, `refund`, and `cancelAuthorize`. - Standardizes method naming from `cancel_authorize` to `cancelAuthorize` to align with camelCase conventions. --- buckaroo/builders/base_builder.py | 4 ++-- .../capabilities/authorize_capture_capable.py | 2 +- buckaroo/models/payment_response.py | 2 -- buckaroo/services/payment_service.py | 10 +++++----- buckaroo/services/solution_service.py | 14 +++++++------- examples/credit_card_payment.py | 9 +++------ examples/ideal_payment.py | 7 ++----- examples/in3_payment.py | 4 +--- examples/paypal_payment.py | 7 ++----- 9 files changed, 23 insertions(+), 36 deletions(-) diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index 6246ecc..2fc7763 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -8,13 +8,13 @@ from ..models.payment_response import PaymentResponse from ..services.service_parameter_validator import ServiceParameterValidator from ..services.transaction_service import TransactionExecutor, ITransactionExecutor -from ..exceptions._parameter_validation_error import ParameterValidationError, RequiredParameterMissingError +from ..exceptions._parameter_validation_error import RequiredParameterMissingError class BaseBuilder(ABC): """Abstract base class for all builders (payments and solutions).""" - def __init__(self, client, executor: Optional[ITransactionExecutor] = None): + def __init__(self, client, executor: Optional[ITransactionExecutor] = None) -> None: """Initialize with client instance. Args: diff --git a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py index c84b784..b1f1c0d 100644 --- a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py +++ b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py @@ -42,7 +42,7 @@ def cancelAuthorize( ) if not txn_key: raise ValueError( - "original_transaction_key is required for cancel_authorize" + "original_transaction_key is required for cancelAuthorize" ) request_data = self._build_keyed_request("CancelAuthorize", txn_key, validate=validate) diff --git a/buckaroo/models/payment_response.py b/buckaroo/models/payment_response.py index bec651d..750e8f0 100644 --- a/buckaroo/models/payment_response.py +++ b/buckaroo/models/payment_response.py @@ -139,8 +139,6 @@ def from_dict(cls, data: Dict[str, Any]) -> "Service": parameters=parameters ) - return cls(name=data.get("Name", ""), action=data.get("Action"), parameters=parameters) - class PaymentResponse: """ diff --git a/buckaroo/services/payment_service.py b/buckaroo/services/payment_service.py index c02d67b..6ffd20b 100644 --- a/buckaroo/services/payment_service.py +++ b/buckaroo/services/payment_service.py @@ -35,7 +35,7 @@ def create_payment(self, method: str, parameters: dict = None) -> PaymentBuilder ... .currency("EUR") \\ ... .amount(6.0) \\ ... .description("Test payment") \\ - ... .execute() + ... .pay() >>> # Using parameters dictionary for quick setup >>> payment = client.payments.create_payment("ideal", { @@ -47,13 +47,13 @@ def create_payment(self, method: str, parameters: dict = None) -> PaymentBuilder ... 'return_url_cancel': 'https://example.com/cancel', ... 'return_url_error': 'https://example.com/error', ... 'return_url_reject': 'https://example.com/reject' - ... }).execute() + ... }).pay() >>> # Combining both approaches >>> payment = client.payments.create_payment("ideal", { ... 'currency': 'EUR', ... 'amount': 6.0 - ... }).description("Updated description").execute() + ... }).description("Updated description").pay() """ builder = self._factory.create_builder(method, self._client) @@ -109,7 +109,7 @@ def create(self, payload: dict) -> PaymentBuilder: ... 'issuer': 'ABNANL2A', ... 'return_url': 'https://example.com/success' ... }) - >>> response = payment.execute() + >>> response = payment.pay() >>> # Credit card payment (auto-detected by card fields) >>> payment = app.payments.create({ @@ -120,7 +120,7 @@ def create(self, payload: dict) -> PaymentBuilder: ... 'expiry_year': '2025', ... 'cvv': '123' ... }) - >>> response = payment.execute() + >>> response = payment.pay() >>> # Refund operation (separate method call) >>> refund_response = payment.refund('TXN_123', 10.00) diff --git a/buckaroo/services/solution_service.py b/buckaroo/services/solution_service.py index 3834e97..609e684 100644 --- a/buckaroo/services/solution_service.py +++ b/buckaroo/services/solution_service.py @@ -31,14 +31,14 @@ def create_solution(self, method: str, parameters: dict = None) -> PaymentBuilde Example: >>> # Using fluent interface only - >>> payment = client.solution.create_payment("ideal") \\ + >>> payment = client.solutions.create_solution("ideal") \\ ... .currency("EUR") \\ ... .amount(6.0) \\ ... .description("Test payment") \\ - ... .execute() + ... .pay() >>> # Using parameters dictionary for quick setup - >>> payment = client.solution.create_payment("ideal", { + >>> payment = client.solutions.create_solution("ideal", { ... 'currency': 'EUR', ... 'amount': 6.0, ... 'description': 'Test payment', @@ -47,13 +47,13 @@ def create_solution(self, method: str, parameters: dict = None) -> PaymentBuilde ... 'return_url_cancel': 'https://example.com/cancel', ... 'return_url_error': 'https://example.com/error', ... 'return_url_reject': 'https://example.com/reject' - ... }).execute() + ... }).pay() >>> # Combining both approaches >>> payment = client.solution.create_solution("ideal", { ... 'currency': 'EUR', ... 'amount': 6.0 - ... }).description("Updated description").execute() + ... }).description("Updated description").pay() """ builder = self._factory.create_builder(method, self._client) @@ -109,7 +109,7 @@ def create(self, payload: dict) -> PaymentBuilder: ... 'issuer': 'ABNANL2A', ... 'return_url': 'https://example.com/success' ... }) - >>> response = payment.execute() + >>> response = payment.pay() >>> # Credit card payment (auto-detected by card fields) >>> payment = app.payments.create({ @@ -120,7 +120,7 @@ def create(self, payload: dict) -> PaymentBuilder: ... 'expiry_year': '2025', ... 'cvv': '123' ... }) - >>> response = payment.execute() + >>> response = payment.pay() >>> # Refund operation (separate method call) >>> refund_response = payment.refund('TXN_123', 10.00) diff --git a/examples/credit_card_payment.py b/examples/credit_card_payment.py index 663a7a0..503584e 100644 --- a/examples/credit_card_payment.py +++ b/examples/credit_card_payment.py @@ -6,7 +6,7 @@ - Pay with encrypted card data (most common server-side flow) - Pay with Hosted Fields token (payWithToken) - Authorize then capture (two-step flow) - - Authorize then cancel (cancel_authorize) + - Authorize then cancel (cancelAuthorize) - Recurring payment (payRecurrent) - Refund @@ -23,7 +23,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from buckaroo.app import Buckaroo -from buckaroo.models import TransactionContext RETURN_BASE = "https://yourshop.com" @@ -126,8 +125,7 @@ def example_authorize_and_capture(encrypted_card_data: str): return # Step 2 — capture (money moves now) - ctx = TransactionContext(original_transaction_key=auth_key) - capture_response = builder.capture(ctx) + capture_response = builder.capture(original_transaction_key=auth_key) print_response("Capture", capture_response) return capture_response @@ -176,8 +174,7 @@ def example_pay_recurrent(original_transaction_key: str): def example_refund(original_transaction_key: str): """Full refund of a captured or completed payment.""" - ctx = TransactionContext(original_transaction_key=original_transaction_key) - response = base_builder().refund(ctx) + response = base_builder().refund(original_transaction_key=original_transaction_key) print_response("Refund", response) return response diff --git a/examples/ideal_payment.py b/examples/ideal_payment.py index 7a6dc49..63096ee 100644 --- a/examples/ideal_payment.py +++ b/examples/ideal_payment.py @@ -18,7 +18,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from buckaroo.app import Buckaroo -from buckaroo.models import TransactionContext RETURN_BASE = "https://yourshop.com" @@ -81,8 +80,7 @@ def example_full_refund(original_transaction_key: str): Refund the full amount of a completed transaction. The amount is read from the builder — no need to specify it again. """ - ctx = TransactionContext(original_transaction_key=original_transaction_key) - response = base_builder().refund(ctx) + response = base_builder().refund(original_transaction_key=original_transaction_key) print_response("Full refund", response) return response @@ -91,8 +89,7 @@ def example_full_refund(original_transaction_key: str): def example_partial_refund(original_transaction_key: str): """Refund only part of the original amount.""" - ctx = TransactionContext(original_transaction_key=original_transaction_key, amount=10.00) - response = base_builder().partial_refund(ctx) + response = base_builder().partial_refund(original_transaction_key=original_transaction_key, amount=10.00) print_response("Partial refund (€10.00)", response) return response diff --git a/examples/in3_payment.py b/examples/in3_payment.py index 7220a98..184d549 100644 --- a/examples/in3_payment.py +++ b/examples/in3_payment.py @@ -15,7 +15,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from buckaroo.app import Buckaroo -from buckaroo.models import TransactionContext RETURN_BASE = "https://yourshop.com" @@ -119,8 +118,7 @@ def example_refund(original_transaction_key: str): "return_url_reject":f"{RETURN_BASE}/reject", }) - ctx = TransactionContext(original_transaction_key=original_transaction_key) - response = builder.refund(ctx) + response = builder.refund(original_transaction_key=original_transaction_key) print_response("In3 Refund", response) return response diff --git a/examples/paypal_payment.py b/examples/paypal_payment.py index 2851685..bf46ff9 100644 --- a/examples/paypal_payment.py +++ b/examples/paypal_payment.py @@ -16,7 +16,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from buckaroo.app import Buckaroo -from buckaroo.models import TransactionContext RETURN_BASE = "https://yourshop.com" @@ -79,8 +78,7 @@ def example_pay(): def example_refund(original_transaction_key: str): """Full refund of a completed PayPal payment.""" - ctx = TransactionContext(original_transaction_key=original_transaction_key) - response = base_builder().refund(ctx) + response = base_builder().refund(original_transaction_key=original_transaction_key) print_response("PayPal Refund", response) return response @@ -89,8 +87,7 @@ def example_refund(original_transaction_key: str): def example_partial_refund(original_transaction_key: str): """Refund part of a completed PayPal payment.""" - ctx = TransactionContext(original_transaction_key=original_transaction_key, amount=10.00) - response = base_builder().partial_refund(ctx) + response = base_builder().partial_refund(original_transaction_key=original_transaction_key, amount=10.00) print_response("PayPal Partial refund (€10.00)", response) return response From b595a3b15f0f7f8b0b1e45ac2784cb7feeb34287 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Thu, 2 Jul 2026 16:12:40 +0200 Subject: [PATCH 06/21] feat(BTI-978): add In3 route param for ABN-AMRO Achteraf Betalen --- buckaroo/builders/payments/in3_builder.py | 7 ++ examples/abn_amro_achteraf_betalen.py | 97 +++++++++++++++++++ .../builders/payments/test_in3_builder.py | 45 +++++++++ 3 files changed, 149 insertions(+) create mode 100644 examples/abn_amro_achteraf_betalen.py diff --git a/buckaroo/builders/payments/in3_builder.py b/buckaroo/builders/payments/in3_builder.py index 71e5dab..373d6b5 100644 --- a/buckaroo/builders/payments/in3_builder.py +++ b/buckaroo/builders/payments/in3_builder.py @@ -25,6 +25,13 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: "description": "Shipping customer information", }, "article": {"type": list, "required": True, "description": "IN3 articles"}, + "route": { + "type": str, + "required": False, + "description": ( + "In3 acquirer route, e.g. 'abn_b2b' for ABN-AMRO Achteraf Betalen" + ), + }, } return {} diff --git a/examples/abn_amro_achteraf_betalen.py b/examples/abn_amro_achteraf_betalen.py new file mode 100644 index 0000000..5f38bc0 --- /dev/null +++ b/examples/abn_amro_achteraf_betalen.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Demo of ABN-AMRO Achteraf Betalen via the In3 payment method. + +Same underlying service as In3 V3 — setting the optional ``Route`` service +parameter to ``abn_b2b`` selects ABN-AMRO Achteraf Betalen instead of the +default In3 acquirer. NL/B2B only. 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_abn_amro_achteraf_betalen() -> None: + """Pay via In3 with the ABN-AMRO Achteraf Betalen route.""" + print("\nABN-AMRO Achteraf Betalen (In3, Route=abn_b2b)") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + + # create_payment(method, params) dispatches through the PaymentMethodFactory + # and returns a ready-to-execute In3Builder. + builder = app.payments.create_payment( + "in3", + { + "currency": "EUR", + "amount": 121.00, + "description": "ABN-AMRO Achteraf Betalen demo", + "invoice": "ABN-B2B-DEMO-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": { + "billingCustomer": [ + { + "firstName": "John", + "lastName": "Doe", + "chamberOfCommerce": "12345678", + "companyName": "Acme B.V.", + } + ], + "shippingCustomer": [ + { + "firstName": "John", + "lastName": "Doe", + "chamberOfCommerce": "12345678", + "companyName": "Acme B.V.", + } + ], + "article": [ + {"description": "Widget", "quantity": "1", "price": "100.00"}, + ], + }, + }, + ) + + # Route is optional and NL/B2B only - selects ABN-AMRO Achteraf Betalen + # instead of the default In3 acquirer. + builder.add_parameter("route", "abn_b2b") + + response = builder.pay() + + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — ABN-AMRO ACHTERAF BETALEN DEMO") + print("=" * 60) + print("Env vars read:") + print(" BUCKAROO_STORE_KEY, BUCKAROO_SECRET_KEY, BUCKAROO_MODE") + + demo_abn_amro_achteraf_betalen() + + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tests/unit/builders/payments/test_in3_builder.py b/tests/unit/builders/payments/test_in3_builder.py index fef7206..6f9ad09 100644 --- a/tests/unit/builders/payments/test_in3_builder.py +++ b/tests/unit/builders/payments/test_in3_builder.py @@ -43,9 +43,23 @@ def test_get_allowed_service_parameters_pay_snapshot(client): "required": True, "description": "IN3 articles", }, + "route": { + "type": str, + "required": False, + "description": ( + "In3 acquirer route, e.g. 'abn_b2b' for ABN-AMRO Achteraf Betalen" + ), + }, } +def test_get_allowed_service_parameters_pay_allows_optional_route(client): + allowed = In3Builder(client).get_allowed_service_parameters("Pay") + + assert "route" in allowed + assert allowed["route"]["required"] is False + + def test_get_allowed_service_parameters_pay_is_case_insensitive(client): builder = In3Builder(client) @@ -59,6 +73,37 @@ def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): assert In3Builder(client).get_allowed_service_parameters(action) == {} +def test_build_pay_with_route_keeps_route_parameter(client): + request = ( + populate_required_fields(In3Builder(client), amount=99.95) + .add_parameter("billingCustomer", [{"Name": "John"}]) + .add_parameter("shippingCustomer", [{"Name": "John"}]) + .add_parameter("article", [{"Description": "Widget", "Quantity": 1}]) + .add_parameter("route", "abn_b2b") + .build("Pay") + ) + + service = request.services.services[0] + route_params = [p for p in service.parameters if p.name == "Route"] + + assert len(route_params) == 1 + assert route_params[0].value == "abn_b2b" + + +def test_build_pay_without_route_is_unchanged(client): + request = ( + populate_required_fields(In3Builder(client), amount=99.95) + .add_parameter("billingCustomer", [{"Name": "John"}]) + .add_parameter("shippingCustomer", [{"Name": "John"}]) + .add_parameter("article", [{"Description": "Widget", "Quantity": 1}]) + .build("Pay") + ) + + service = request.services.services[0] + + assert all(p.name != "Route" for p in service.parameters) + + def test_pay_posts_transaction_and_parses_response(client, mock_strategy): mock_strategy.queue( BuckarooMockRequest.json( From cfb2404c4a226bd9a256996f0780bd2166b257f8 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Thu, 2 Jul 2026 16:16:23 +0200 Subject: [PATCH 07/21] style(BTI-978): ruff format in3 builder test --- tests/unit/builders/payments/test_in3_builder.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/builders/payments/test_in3_builder.py b/tests/unit/builders/payments/test_in3_builder.py index 6f9ad09..ecaa087 100644 --- a/tests/unit/builders/payments/test_in3_builder.py +++ b/tests/unit/builders/payments/test_in3_builder.py @@ -46,9 +46,7 @@ def test_get_allowed_service_parameters_pay_snapshot(client): "route": { "type": str, "required": False, - "description": ( - "In3 acquirer route, e.g. 'abn_b2b' for ABN-AMRO Achteraf Betalen" - ), + "description": ("In3 acquirer route, e.g. 'abn_b2b' for ABN-AMRO Achteraf Betalen"), }, } From e5d6437913815584353f6b406f71d6b3387acea5 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Fri, 3 Jul 2026 15:08:00 +0200 Subject: [PATCH 08/21] feat(BTI-721): add Banking service (PaymentOrder payout) --- buckaroo/builders/payments/banking_builder.py | 57 ++++++++ buckaroo/factories/payment_method_factory.py | 2 + tests/feature/payments/test_banking.py | 59 ++++++++ .../builders/payments/test_banking_builder.py | 131 ++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 buckaroo/builders/payments/banking_builder.py create mode 100644 tests/feature/payments/test_banking.py create mode 100644 tests/unit/builders/payments/test_banking_builder.py diff --git a/buckaroo/builders/payments/banking_builder.py b/buckaroo/builders/payments/banking_builder.py new file mode 100644 index 0000000..437e722 --- /dev/null +++ b/buckaroo/builders/payments/banking_builder.py @@ -0,0 +1,57 @@ +from typing import Dict, Any +from .payment_builder import PaymentBuilder +from ...models.payment_response import PaymentResponse + + +class BankingBuilder(PaymentBuilder): + """Builder for Banking payouts. + + Banking is a payout: Buckaroo sends money OUT to a bank account given an + IBAN and account holder name. Unlike the base Pay flow, it uses the + ``PaymentOrder`` action, sends ``AmountCredit`` (not ``AmountDebit``), and + has no return URLs since it's a server-to-server payout with no redirect. + """ + + def get_service_name(self) -> str: + """Get the service name for Banking payouts.""" + return "Banking" + + def get_allowed_service_parameters(self, action: str = "PaymentOrder") -> Dict[str, Any]: + """Get the allowed service parameters for Banking payouts based on action.""" + + if action.lower() in ["paymentorder"]: + return { + "accountholdername": { + "type": str, + "required": True, + "description": "Account holder name", + }, + "iban": {"type": str, "required": True, "description": "IBAN"}, + } + + return {} + + def required_fields(self, action: str = "PaymentOrder") -> Dict[str, Any]: + """Banking is a server-to-server payout with no redirect, so it drops the + ``return_url*`` requirements. Currency, amount, and invoice stay required.""" + if action.lower() == "paymentorder": + return { + "currency": self._currency, + "amount_debit": self._amount_debit, + "invoice": self._invoice, + } + return super().required_fields(action) + + def payment_order(self, validate: bool = True) -> PaymentResponse: + """Execute the Banking payout. + + Uses AmountCredit (not AmountDebit) per Buckaroo API requirements. + """ + payment_request = self.build("PaymentOrder", validate=validate) + request_data = payment_request.to_dict() + + # PaymentRequest.to_dict always writes AmountDebit; swap to AmountCredit + # since Buckaroo expects AmountCredit for payouts. + request_data["AmountCredit"] = request_data.pop("AmountDebit") + + return self._post_transaction(request_data) diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index c040d52..43960fc 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -5,6 +5,7 @@ from buckaroo.builders.payments.alipay_builder import AlipayBuilder from buckaroo.builders.payments.apple_pay_builder import ApplePayBuilder from buckaroo.builders.payments.bancontact_builder import BancontactBuilder +from buckaroo.builders.payments.banking_builder import BankingBuilder from buckaroo.builders.payments.belfius_builder import BelfiusBuilder from buckaroo.builders.payments.bizum_builder import BizumBuilder from buckaroo.builders.payments.blik_builder import BlikBuilder @@ -51,6 +52,7 @@ class PaymentMethodFactory(BuilderFactory): "alipay": AlipayBuilder, "applepay": ApplePayBuilder, "bancontact": BancontactBuilder, + "banking": BankingBuilder, "belfius": BelfiusBuilder, "bizum": BizumBuilder, "billink": BillinkBuilder, diff --git a/tests/feature/payments/test_banking.py b/tests/feature/payments/test_banking.py new file mode 100644 index 0000000..b3b6ee8 --- /dev/null +++ b/tests/feature/payments/test_banking.py @@ -0,0 +1,59 @@ +import json + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestBankingFeature: + """Feature tests for Banking payouts (PaymentOrder action). + + Banking is a payout with no return URLs, so the payload is built + directly here rather than via ``Helpers.standard_payload`` (which + injects return_url* fields that don't apply to a server-to-server + payout). + """ + + def test_banking_payment_order_returns_success(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "Banking", "Action": "PaymentOrder", "Parameters": []}], + "ServiceCode": "banking", + "AmountCredit": 150.0, + "AmountDebit": None, + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + + response = buckaroo.payments.create_payment( + "banking", + { + "currency": "EUR", + "amount": 150.0, + "invoice": "Banking_Test_1", + "description": "Test", + "service_parameters": { + "AccountHolderName": "Arensman", + "IBAN": "NL44RABO0123456789", + }, + }, + ).payment_order() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + + sent = json.loads(mock_strategy.calls[-1]["data"]) + assert sent["Currency"] == "EUR" + assert sent["AmountCredit"] == 150.0 + assert "AmountDebit" not in sent + assert sent["Invoice"] == "Banking_Test_1" + assert sent["Description"] == "Test" + + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "Banking" + assert service["Action"] == "PaymentOrder" + assert {"Name": "Accountholdername", "Value": "Arensman"} in [ + {"Name": p["Name"], "Value": p["Value"]} for p in service["Parameters"] + ] + assert {"Name": "Iban", "Value": "NL44RABO0123456789"} in [ + {"Name": p["Name"], "Value": p["Value"]} for p in service["Parameters"] + ] diff --git a/tests/unit/builders/payments/test_banking_builder.py b/tests/unit/builders/payments/test_banking_builder.py new file mode 100644 index 0000000..6b6a0ca --- /dev/null +++ b/tests/unit/builders/payments/test_banking_builder.py @@ -0,0 +1,131 @@ +"""Unit coverage for :class:`BankingBuilder`. + +Banking is a payout (PaymentOrder action): Buckaroo sends money OUT to a +bank account given an IBAN and account holder name. Unlike the base Pay +flow it has no redirect, so it overrides ``required_fields`` to drop the +``return_url*`` requirements, and it swaps ``AmountDebit`` for +``AmountCredit`` on the wire (same precedent as ``refund()`` and +``cancelAuthorize()``). +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.banking_builder import BankingBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +@pytest.fixture +def builder(client: BuckarooClient) -> BankingBuilder: + return BankingBuilder(client) + + +# --------------------------------------------------------------------------- +# Construction + + +def test_instantiates_as_payment_builder(builder: BankingBuilder) -> None: + assert isinstance(builder, BankingBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_construction_binds_client(builder: BankingBuilder, client: BuckarooClient) -> None: + assert builder._client is client + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_banking(builder: BankingBuilder) -> None: + assert builder.get_service_name() == "Banking" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — PaymentOrder action snapshot + + +def test_get_allowed_service_parameters_paymentorder_snapshot( + builder: BankingBuilder, +) -> None: + assert builder.get_allowed_service_parameters("PaymentOrder") == { + "accountholdername": { + "type": str, + "required": True, + "description": "Account holder name", + }, + "iban": { + "type": str, + "required": True, + "description": "IBAN", + }, + } + + +def test_get_allowed_service_parameters_paymentorder_case_insensitive( + builder: BankingBuilder, +) -> None: + assert builder.get_allowed_service_parameters( + "paymentorder" + ) == builder.get_allowed_service_parameters("PaymentOrder") + + +@pytest.mark.parametrize("action", ["Pay", "Refund", "Authorize", "Capture", "Unknown"]) +def test_get_allowed_service_parameters_non_paymentorder_actions_return_empty( + builder: BankingBuilder, action: str +) -> None: + assert builder.get_allowed_service_parameters(action) == {} + + +# --------------------------------------------------------------------------- +# required_fields — no return URLs required + + +def test_required_fields_currency_amount_and_invoice(builder: BankingBuilder) -> None: + assert builder.required_fields("PaymentOrder") == { + "currency": None, + "amount_debit": None, + "invoice": None, + } + + +def test_build_succeeds_without_return_urls(builder: BankingBuilder) -> None: + builder.currency("EUR").amount(150.0).invoice("Banking_Test_1") + # Should not raise even though no return_url* fields were set. + payment_request = builder.build("PaymentOrder", validate=False) + assert payment_request.currency == "EUR" + + +# --------------------------------------------------------------------------- +# End-to-end payment_order via MockBuckaroo + + +def test_payment_order_posts_banking_service_with_amount_credit(): + mock, stub_client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "BNK-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = BankingBuilder(stub_client) + builder.currency("EUR").amount(150.0).invoice("Banking_Test_1").description("Test") + builder.add_parameter("AccountHolderName", "Arensman") + builder.add_parameter("IBAN", "NL44RABO0123456789") + + response = builder.payment_order(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "Banking" + assert service["Action"] == "PaymentOrder" + assert sent["AmountCredit"] == 150.0 + assert "AmountDebit" not in sent + assert response.key == "BNK-1" + mock.assert_all_consumed() From ec9d0f66dd47fdfd848bde26781fb8fd2f07212b Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Mon, 6 Jul 2026 11:03:09 +0200 Subject: [PATCH 09/21] feat(BTI-1077): add In3 authorize/capture flow --- buckaroo/builders/payments/in3_builder.py | 19 ++- examples/in3.py | 145 ++++++++++++++++++ tests/feature/payments/test_in3.py | 111 +++++++++++++- .../test_concrete_builders_contract.py | 1 + .../builders/payments/test_in3_builder.py | 19 ++- 5 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 examples/in3.py 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..8e51a75 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,110 @@ 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) == {} From dc3040bdbf9819ef0ab018b0ed73140b2233a65f Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Mon, 6 Jul 2026 13:02:01 +0200 Subject: [PATCH 10/21] style(BTI-1077): apply ruff format to in3 feature test --- tests/feature/payments/test_in3.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/feature/payments/test_in3.py b/tests/feature/payments/test_in3.py index 8e51a75..2071711 100644 --- a/tests/feature/payments/test_in3.py +++ b/tests/feature/payments/test_in3.py @@ -109,9 +109,7 @@ def add_service_params(builder): } ], ) - builder.add_parameter( - "shippingCustomer", [{"firstName": "John", "lastName": "Doe"}] - ) + builder.add_parameter("shippingCustomer", [{"firstName": "John", "lastName": "Doe"}]) Helpers.assert_action_returns_pending_with_redirect( buckaroo, From b11ab8d40b16b21b4bcba24ac62c1df18050a99b Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Tue, 7 Jul 2026 11:31:40 +0200 Subject: [PATCH 11/21] test(BTI-715): verify In3 CompanyName/CocNumber flow through pay --- examples/in3.py | 9 ++++++- tests/feature/payments/test_in3.py | 41 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/examples/in3.py b/examples/in3.py index afe2157..7cc6e47 100644 --- a/examples/in3.py +++ b/examples/in3.py @@ -31,6 +31,13 @@ def demo_pay() -> None: try: app = Buckaroo.from_env() + + # CompanyName and CocNumber are optional billingCustomer fields for B2B + # orders. They ride along in the billingCustomer group; omit them for + # regular consumer payments. + billing = _customer() + billing.update({"CompanyName": "Acme B.V.", "CocNumber": "12345678"}) + response = app.payments.create_payment( "in3", { @@ -46,7 +53,7 @@ def demo_pay() -> None: "article": [ {"Description": "Widget", "Quantity": "2", "GrossUnitPrice": "125.00"}, ], - "billingCustomer": [_customer()], + "billingCustomer": [billing], "shippingCustomer": [_customer()], }, }, diff --git a/tests/feature/payments/test_in3.py b/tests/feature/payments/test_in3.py index 2071711..a89a8cf 100644 --- a/tests/feature/payments/test_in3.py +++ b/tests/feature/payments/test_in3.py @@ -26,6 +26,47 @@ def test_in3_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): }, ) + def test_in3_pay_with_company_and_coc_flows_through_billing_customer( + self, buckaroo, mock_strategy + ): + """Optional B2B fields CompanyName and CocNumber (BTI-715) ride along in + the billingCustomer group on the regular In3 Pay flow, reaching the wire + as GroupType=Billingcustomer, GroupID=1. + """ + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="in3", + invoice="INV-IN3-005", + payload_overrides={"amount": 25.00, "description": "Test in3 B2B pay"}, + service_params={ + "article": [ + {"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}, + ], + "billingCustomer": [ + { + "firstName": "John", + "lastName": "Doe", + "CompanyName": "Acme B.V.", + "CocNumber": "12345678", + }, + ], + "shippingCustomer": [ + {"firstName": "John", "lastName": "Doe"}, + ], + }, + ) + + billing = { + param["Name"]: param + for param in recorded_service_parameters(mock_strategy) + if param["GroupType"] == "Billingcustomer" + } + assert billing["Companyname"]["Value"] == "Acme B.V." + assert billing["Cocnumber"]["Value"] == "12345678" + assert billing["Companyname"]["GroupID"] == "1" + assert billing["Cocnumber"]["GroupID"] == "1" + def test_in3_authorize_returns_pending_with_redirect(self, buckaroo, mock_strategy): def add_service_params(builder): builder.add_parameter("route", "AbnB2b") From b7d8c2f7582d763282bf1f017bfa4e437fcc712d Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Tue, 7 Jul 2026 14:44:17 +0200 Subject: [PATCH 12/21] feat(BTI-584): update Klarna MOR to DataRequestKey flow Klarna no longer returns a reservation number; follow-up actions reference the prior Reserve through the Buckaroo DataRequestKey service parameter. - Fix cancelReservation to post to /json/DataRequest with DataRequestKey (was posting to /json/transaction with OriginalTransactionKey) - Add updateReservation and extendReservation DataRequest actions - Align allowed service parameters and required_fields per action with the BPS Klarna docs (operatingCountry mandatory on Reserve; Pay requires an invoice) - Remove AddShippingInfo: the gateway rejects it for the klarna service (ActionUnknown); shipping details ride on the Pay request instead - Refund keeps the base OriginalTransactionKey path Verified end-to-end against the Buckaroo test gateway: Reserve -> hosted-page approval -> ExtendReservation -> Pay (captured) -> Refund all succeed. --- buckaroo/builders/payments/klarna_builder.py | 149 +++++++--- tests/feature/payments/test_klarna.py | 79 +++++ .../builders/payments/test_klarna_builder.py | 270 ++++++++++++++---- .../test_service_parameter_validator.py | 10 +- 4 files changed, 410 insertions(+), 98 deletions(-) diff --git a/buckaroo/builders/payments/klarna_builder.py b/buckaroo/builders/payments/klarna_builder.py index a74e2cb..e19a816 100644 --- a/buckaroo/builders/payments/klarna_builder.py +++ b/buckaroo/builders/payments/klarna_builder.py @@ -1,12 +1,52 @@ from __future__ import annotations -from typing import Dict, Any, Optional +from typing import Dict, Any from buckaroo.models.payment_response import PaymentResponse from .payment_builder import PaymentBuilder class KlarnaBuilder(PaymentBuilder): - """Builder for Klarna MOR (Merchant of Record) payments.""" + """Builder for Klarna MOR (Merchant of Record) payments. + + Klarna no longer returns a reservation number. Every follow-up action + (CancelReservation, UpdateReservation, ExtendReservation, and Pay) + references the prior Reserve through the Buckaroo ``DataRequestKey`` carried + as a service parameter. The reservation actions post to ``/json/DataRequest``; + Pay and Refund are transaction requests. Shipping details are attached to the + Pay request (``shippingMethod`` / ``company`` / ``trackingNumber``); the + gateway has no standalone AddShippingInfo action for this service. + """ + + def required_fields(self, action: str = "Pay") -> Dict[str, Any]: + """Narrow the base required-field set per action. + + Reserve only needs currency + invoice; Pay needs currency + amount; the + DataRequest follow-up actions need none of the base transaction fields. + Refund and any other action fall through to the base requirements. + """ + action = action.lower() + + if action == "reserve": + return { + "currency": self._currency, + "invoice": self._invoice, + } + + if action == "pay": + return { + "currency": self._currency, + "amount_debit": self._amount_debit, + "invoice": self._invoice, + } + + if action in ( + "cancelreservation", + "updatereservation", + "extendreservation", + ): + return {} + + return super().required_fields(action) def get_service_name(self) -> str: """Get the service name for Klarna payments.""" @@ -23,20 +63,30 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: "required": True, "description": "Key of the prior Klarna Reserve", }, + "article": { + "type": list, + "required": False, + "description": "Articles to pay for on a partial delivery", + }, + "shippingMethod": { + "type": str, + "required": False, + "description": "Shipping method", + }, + "company": { + "type": str, + "required": False, + "description": "Shipping company name", + }, + "trackingNumber": { + "type": str, + "required": False, + "description": "Shipping tracking number", + }, } if action == "reserve": return { - "billingCustomer": { - "type": list, - "required": True, - "description": "Billing customer information", - }, - "shippingCustomer": { - "type": list, - "required": True, - "description": "Shipping customer information", - }, "article": { "type": list, "required": True, @@ -44,9 +94,19 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: }, "operatingCountry": { "type": str, - "required": False, + "required": True, "description": "Operating country code", }, + "billingCustomer": { + "type": list, + "required": False, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": False, + "description": "Shipping customer information", + }, "pno": { "type": str, "required": False, @@ -64,37 +124,48 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: }, } - if action == "cancelreservation": - return {} + if action in ("cancelreservation", "extendreservation"): + return { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Buckaroo data request key of the prior Reserve", + }, + } + + if action == "updatereservation": + return { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Buckaroo data request key of the prior Reserve", + }, + "article": { + "type": list, + "required": False, + "description": "Updated Klarna articles", + }, + "shippingCustomer": { + "type": list, + "required": False, + "description": "Updated shipping customer information", + }, + } return {} def reserve(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: - payment_request = self.build("Reserve", validate=validate) - request_data = payment_request.to_dict() - - return self._post_data_request(request_data) - - def cancelReservation( - self: "PaymentBuilder", - original_transaction_key: Optional[str] = None, - validate: bool = True, - ) -> PaymentResponse: - """Cancel a previously-reserved Klarna transaction. - - Mirrors :meth:`AuthorizeCaptureCapable.cancelAuthorize` but with the - ``CancelReservation`` action. - """ - txn_key = original_transaction_key or self._payload.get("original_transaction_key") - if not txn_key: - raise ValueError( - "Original transaction key is required for cancelReservation " - "(provide 'original_transaction_key' in payload)" - ) + return self._post_data_request(payment_request.to_dict()) + def cancelReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: payment_request = self.build("CancelReservation", validate=validate) - request_data = payment_request.to_dict() - request_data["OriginalTransactionKey"] = txn_key + return self._post_data_request(payment_request.to_dict()) + + def updateReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + payment_request = self.build("UpdateReservation", validate=validate) + return self._post_data_request(payment_request.to_dict()) - return self._post_transaction(request_data) + def extendReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + payment_request = self.build("ExtendReservation", validate=validate) + return self._post_data_request(payment_request.to_dict()) diff --git a/tests/feature/payments/test_klarna.py b/tests/feature/payments/test_klarna.py index 7d8eb90..faac666 100644 --- a/tests/feature/payments/test_klarna.py +++ b/tests/feature/payments/test_klarna.py @@ -59,3 +59,82 @@ def test_klarna_pay_as_capture_attaches_data_request_key(self, buckaroo, mock_st services = body["Services"]["ServiceList"] names = [p["Name"] for p in services[0]["Parameters"]] assert "Datarequestkey" in names + + def test_klarna_cancel_reservation_posts_data_request_key(self, buckaroo, mock_strategy): + """Follow-up actions carry the Buckaroo DataRequestKey (no reservation + number) and post to /json/DataRequest.""" + response_body = Helpers.pending_redirect_response("klarna", action="CancelReservation") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload( + invoice="INV-KLARNA-CAN", + service_parameters={"dataRequestKey": "RES-DRK-2"}, + ), + ).cancelReservation() + + assert response.key == response_body["Key"] + body = json.loads(mock_strategy.calls[-1]["data"]) + service = body["Services"]["ServiceList"][0] + assert service["Action"] == "CancelReservation" + assert "OriginalTransactionKey" not in body + names = [p["Name"] for p in service["Parameters"]] + assert "Datarequestkey" in names + + def test_klarna_update_reservation_dispatches_to_data_request(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("klarna", action="UpdateReservation") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload( + invoice="INV-KLARNA-UPD", + service_parameters={ + "dataRequestKey": "RES-DRK-3", + "article": [{"description": "Widget", "quantity": "1", "price": "10.00"}], + }, + ), + ).updateReservation() + + assert response.key == response_body["Key"] + assert ( + json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0]["Action"] + == "UpdateReservation" + ) + + def test_klarna_extend_reservation_dispatches_to_data_request(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("klarna", action="ExtendReservation") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload( + invoice="INV-KLARNA-EXT", + service_parameters={"dataRequestKey": "RES-DRK-4"}, + ), + ).extendReservation() + + assert response.key == response_body["Key"] + assert ( + json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0]["Action"] + == "ExtendReservation" + ) + + def test_klarna_pay_carries_shipping_details_on_transaction(self, buckaroo, mock_strategy): + """Shipping details ride on the Pay transaction (no AddShippingInfo action).""" + capture_body = Helpers.success_response( + overrides={"Key": "PAY-K-SHP", "AmountDebit": 22.45, "ServiceCode": "klarna"} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", capture_body)) + builder = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload(invoice="INV-KLARNA-SHP", amount=22.45), + ) + builder.add_parameter("dataRequestKey", "RES-DRK-5") + builder.add_parameter("trackingNumber", "AAAA1234567890") + response = builder.pay() + + assert response.key == "PAY-K-SHP" + service = json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0] + assert service["Action"] == "Pay" + names = [p["Name"] for p in service["Parameters"]] + assert "Datarequestkey" in names + assert "Trackingnumber" in names diff --git a/tests/unit/builders/payments/test_klarna_builder.py b/tests/unit/builders/payments/test_klarna_builder.py index cf92b4e..739e46c 100644 --- a/tests/unit/builders/payments/test_klarna_builder.py +++ b/tests/unit/builders/payments/test_klarna_builder.py @@ -1,10 +1,11 @@ """Per-builder unit tests for :class:`KlarnaBuilder`. -Covers construction, service-name shape, allowed-parameter snapshots for every -supported action including the grouped article / line-item structure, the -mixin-free baseline (KlarnaBuilder composes no capability mixins despite the -docstring mention of "bank transfer capabilities"), and an end-to-end -``pay()`` dispatch through ``MockBuckaroo``. Phase 7.20. +Klarna MOR no longer returns a reservation number: every follow-up action +(``CancelReservation``, ``UpdateReservation``, ``ExtendReservation``, +``AddShippingInfo``) is a DataRequest keyed on the Buckaroo ``DataRequestKey`` +service parameter and posts to ``/json/DataRequest``. ``Pay``/``Refund`` remain +transaction requests. The source also narrows :meth:`required_fields` per +action so the DataRequest methods pass ``_validate_required_fields``. """ from __future__ import annotations @@ -15,6 +16,7 @@ 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_action, recorded_request, wire_recording_http def test_construct_with_buckaroo_client_returns_payment_builder(client): @@ -26,15 +28,39 @@ def test_get_service_name_returns_klarna(client): assert KlarnaBuilder(client).get_service_name() == "klarna" +# --------------------------------------------------------------------------- +# get_allowed_service_parameters + + def test_get_allowed_service_parameters_pay_snapshot(client): - """Pay-as-capture references the prior Reserve via ``dataRequestKey`` as a - service parameter. Cart contents are reused server-side from the Reserve.""" + """Pay-as-capture references the prior Reserve via ``dataRequestKey`` and + optionally carries partial-delivery articles and shipping details.""" assert KlarnaBuilder(client).get_allowed_service_parameters("Pay") == { "dataRequestKey": { "type": str, "required": True, "description": "Key of the prior Klarna Reserve", }, + "article": { + "type": list, + "required": False, + "description": "Articles to pay for on a partial delivery", + }, + "shippingMethod": { + "type": str, + "required": False, + "description": "Shipping method", + }, + "company": { + "type": str, + "required": False, + "description": "Shipping company name", + }, + "trackingNumber": { + "type": str, + "required": False, + "description": "Shipping tracking number", + }, } @@ -47,20 +73,9 @@ def test_get_allowed_service_parameters_is_case_insensitive_for_pay(client): def test_get_allowed_service_parameters_reserve_snapshot(client): - """Reserve action drives the Klarna MOR hosted-page flow. Same required - cart trio as Pay (billingCustomer, shippingCustomer, article) plus - optional Klarna-specific keys (operatingCountry, pno, gender, locale).""" + """Reserve drives the Klarna MOR hosted-page flow. Per BPS docs only the + article list and operatingCountry are mandatory; customer info is optional.""" assert KlarnaBuilder(client).get_allowed_service_parameters("Reserve") == { - "billingCustomer": { - "type": list, - "required": True, - "description": "Billing customer information", - }, - "shippingCustomer": { - "type": list, - "required": True, - "description": "Shipping customer information", - }, "article": { "type": list, "required": True, @@ -68,9 +83,19 @@ def test_get_allowed_service_parameters_reserve_snapshot(client): }, "operatingCountry": { "type": str, - "required": False, + "required": True, "description": "Operating country code", }, + "billingCustomer": { + "type": list, + "required": False, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": False, + "description": "Shipping customer information", + }, "pno": { "type": str, "required": False, @@ -96,8 +121,94 @@ def test_get_allowed_service_parameters_is_case_insensitive_for_reserve(client): ) == builder.get_allowed_service_parameters("Reserve") +def test_get_allowed_service_parameters_cancelreservation_requires_data_request_key(client): + """CancelReservation and ExtendReservation only need the DataRequestKey.""" + expected = { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Buckaroo data request key of the prior Reserve", + }, + } + builder = KlarnaBuilder(client) + assert builder.get_allowed_service_parameters("CancelReservation") == expected + assert builder.get_allowed_service_parameters("cancelreservation") == expected + + +def test_get_allowed_service_parameters_extendreservation_matches_cancel(client): + builder = KlarnaBuilder(client) + assert builder.get_allowed_service_parameters( + "ExtendReservation" + ) == builder.get_allowed_service_parameters("CancelReservation") + + +def test_get_allowed_service_parameters_updatereservation_snapshot(client): + assert KlarnaBuilder(client).get_allowed_service_parameters("UpdateReservation") == { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Buckaroo data request key of the prior Reserve", + }, + "article": { + "type": list, + "required": False, + "description": "Updated Klarna articles", + }, + "shippingCustomer": { + "type": list, + "required": False, + "description": "Updated shipping customer information", + }, + } + + def test_get_allowed_service_parameters_unsupported_action_returns_empty(client): + # Refund carries no service params; AddShippingInfo is not a klarna action + # (the gateway rejects it — shipping details ride on Pay). assert KlarnaBuilder(client).get_allowed_service_parameters("Refund") == {} + assert KlarnaBuilder(client).get_allowed_service_parameters("AddShippingInfo") == {} + + +# --------------------------------------------------------------------------- +# required_fields override + + +class TestRequiredFields: + def test_reserve_requires_currency_and_invoice(self, client): + builder = KlarnaBuilder(client).currency("EUR").invoice("INV-1") + assert builder.required_fields("Reserve") == {"currency": "EUR", "invoice": "INV-1"} + + def test_pay_requires_currency_amount_and_invoice(self, client): + """The gateway rejects Pay without an invoice number.""" + builder = KlarnaBuilder(client).currency("EUR").amount(12.34).invoice("INV-9") + assert builder.required_fields("Pay") == { + "currency": "EUR", + "amount_debit": 12.34, + "invoice": "INV-9", + } + + def test_data_request_actions_require_nothing(self, client): + builder = KlarnaBuilder(client) + assert builder.required_fields("CancelReservation") == {} + assert builder.required_fields("UpdateReservation") == {} + assert builder.required_fields("ExtendReservation") == {} + + def test_refund_falls_through_to_base(self, client): + """Refund keeps the full base required-field set (like every builder).""" + assert set(KlarnaBuilder(client).required_fields("Refund")) == { + "currency", + "amount_debit", + "description", + "invoice", + "return_url", + "return_url_cancel", + "return_url_error", + "return_url_reject", + } + + +# --------------------------------------------------------------------------- +# Pay / Refund dispatch through the transaction endpoint def test_pay_dispatches_klarna_service_through_mock_buckaroo(): @@ -120,45 +231,98 @@ def test_pay_dispatches_klarna_service_through_mock_buckaroo(): mock.assert_all_consumed() -def test_get_allowed_service_parameters_cancelreservation_is_empty(client): - """CancelReservation only needs OriginalTransactionKey at request level.""" - assert KlarnaBuilder(client).get_allowed_service_parameters("CancelReservation") == {} - assert KlarnaBuilder(client).get_allowed_service_parameters("cancelreservation") == {} - +# --------------------------------------------------------------------------- +# DataRequest follow-up actions — post to /json/DataRequest with the action name -def test_cancel_reservation_requires_original_transaction_key(client): - """Missing key raises ValueError, mirroring cancelAuthorize.""" - import pytest - builder = populate_required_fields(KlarnaBuilder(client), amount=10.0) - with pytest.raises(ValueError, match="Original transaction key is required"): - builder.cancelReservation(original_transaction_key="") +class TestReserve: + def test_posts_reserve_to_data_request_endpoint(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KL-RES-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaBuilder(client).currency("EUR").invoice("INV-1") + + response = builder.reserve(validate=False) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "Reserve" + assert recorded_request(mock)["Services"]["ServiceList"][0]["Name"] == "klarna" + assert response.key == "KL-RES-1" + mock.assert_all_consumed() + + +class TestCancelReservation: + def test_posts_cancel_reservation_to_data_request_without_original_key(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KL-CAN-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaBuilder(client) + builder.add_parameter("dataRequestKey", "RES-DRK-1") + + response = builder.cancelReservation(validate=False) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "CancelReservation" + sent = recorded_request(mock) + assert "OriginalTransactionKey" not in sent + names = [p["Name"] for p in sent["Services"]["ServiceList"][0]["Parameters"]] + assert "Datarequestkey" in names + assert response.key == "KL-CAN-1" + mock.assert_all_consumed() + + +class TestUpdateReservation: + def test_posts_update_reservation_to_data_request(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KL-UPD-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaBuilder(client) + response = builder.updateReservation(validate=False) -def test_cancel_reservation_dispatches_action_with_original_transaction_key(): - """cancelReservation builds action="CancelReservation" with the key in payload.""" - from unittest.mock import MagicMock + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "UpdateReservation" + assert response.key == "KL-UPD-1" + mock.assert_all_consumed() - class _StubResponse: - def to_dict(self): - return {"Status": {"Code": {"Code": 190}}} - captured = {} +class TestExtendReservation: + def test_posts_extend_reservation_to_data_request(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KL-EXT-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaBuilder(client) - class _StubHttp: - def post(self, path, data): - captured["path"] = path - captured["data"] = data - return _StubResponse() + response = builder.extendReservation(validate=False) - stub_client = MagicMock() - stub_client.http_client = _StubHttp() + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "ExtendReservation" + assert response.key == "KL-EXT-1" + mock.assert_all_consumed() - builder = populate_required_fields(KlarnaBuilder(stub_client), amount=49.95) - response = builder.cancelReservation(original_transaction_key="RES-KEY-9") - assert response.to_dict()["Status"]["Code"]["Code"] == 190 - assert captured["path"] == "/json/transaction" - sent = captured["data"] - assert sent["OriginalTransactionKey"] == "RES-KEY-9" - assert sent["Services"]["ServiceList"][0]["Action"] == "CancelReservation" +def test_klarna_has_no_add_shipping_info_action(): + """AddShippingInfo is not a klarna action — the gateway rejects it, so the + builder exposes no such method (shipping details ride on Pay).""" + _, client = wire_recording_http() + assert not hasattr(KlarnaBuilder(client), "addShippingInfo") diff --git a/tests/unit/services/test_service_parameter_validator.py b/tests/unit/services/test_service_parameter_validator.py index 44ec8a3..7a394b6 100644 --- a/tests/unit/services/test_service_parameter_validator.py +++ b/tests/unit/services/test_service_parameter_validator.py @@ -218,26 +218,24 @@ def test_validate_required_parameters_raises_required_missing_for_single_gap(): def test_validate_required_parameters_raises_validation_error_for_multiple_gaps(): validator = _validator_for(KlarnaBuilder) - # Klarna Reserve requires billingCustomer, shippingCustomer, article — all missing. + # Klarna Reserve requires article and operatingCountry — both missing. with pytest.raises(ParameterValidationError) as exc: validator.validate_required_parameters([], action="Reserve") # Multiple missing -> plain ParameterValidationError (not Required...), # and the message lists each missing name. assert not isinstance(exc.value, RequiredParameterMissingError) msg = str(exc.value) - assert "billingCustomer" in msg - assert "shippingCustomer" in msg assert "article" in msg + assert "operatingCountry" in msg def test_validate_required_parameters_accepts_grouped_group_type_as_satisfying_requirement(): validator = _validator_for(KlarnaBuilder) params = [ - Parameter(name="FirstName", value="Jane", group_type="billingCustomer"), - Parameter(name="FirstName", value="John", group_type="shippingCustomer"), Parameter(name="Identifier", value="A1", group_type="article"), + Parameter(name="OperatingCountry", value="NL"), ] - # All three required group_types are present via grouped parameters. + # The grouped ``article`` requirement plus the plain operatingCountry are met. validator.validate_required_parameters(params, action="Reserve") From 27ca4662bf54d36a74f39b016161d8c1fa81a097 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Mon, 13 Jul 2026 11:39:57 +0200 Subject: [PATCH 13/21] feat(BTI-17): add PayPerEmail example and feature test - add examples/payperemail.py demoing the PaymentInvitation flow, creds-gated like examples/in3.py - add feature test asserting the PaymentInvitation action and customer service params reach the wire --- examples/payperemail.py | 72 ++++++++++++++++++++++ tests/feature/payments/test_payperemail.py | 47 ++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 examples/payperemail.py create mode 100644 tests/feature/payments/test_payperemail.py diff --git a/examples/payperemail.py b/examples/payperemail.py new file mode 100644 index 0000000..b2293e9 --- /dev/null +++ b/examples/payperemail.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Demo of the Pay Per Email payment method. + +Pay Per Email emails the shopper a payment link instead of returning an inline +redirect. The demo drives the ``PaymentInvitation`` action, passing the customer +identity (email, name, gender) as service parameters. 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_payment_invitation() -> None: + """Send a Pay Per Email invitation via the PaymentInvitation action.""" + print("\n1. Pay Per Email invitation") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + + # ExpirationDate is optional; omit it to use the account default. Gender + # follows Buckaroo's codes: 1=Male, 2=Female, 0=Unknown, 9=N/A. + response = app.payments.create_payment( + "payperemail", + { + "currency": "EUR", + "amount": 42.00, + "description": "pay per email demo", + "invoice": "PPE-DEMO-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": { + "CustomerEmail": "john@example.com", + "CustomerFirstName": "John", + "CustomerLastName": "Doe", + "CustomerGender": "1", + "ExpirationDate": "2026-12-31", + }, + }, + ).execute_action("PaymentInvitation") + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — PAY PER EMAIL DEMO") + print("=" * 60) + demo_payment_invitation() + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tests/feature/payments/test_payperemail.py b/tests/feature/payments/test_payperemail.py new file mode 100644 index 0000000..1c89353 --- /dev/null +++ b/tests/feature/payments/test_payperemail.py @@ -0,0 +1,47 @@ +"""Feature test: PayPerEmail PaymentInvitation round-trip through the full stack. + +Pay Per Email emails the shopper a payment link instead of returning an inline +redirect. The ``PaymentInvitation`` action carries the customer identity as +service parameters; this pins that the action and those params reach the wire. +""" + +from tests.support.helpers import Helpers +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_service_parameters + + +class TestPayPerEmailFeature: + def test_payment_invitation_sends_action_and_customer_params(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + {"Name": "payperemail", "Action": "PaymentInvitation", "Parameters": []} + ], + "ServiceCode": "payperemail", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + + response = buckaroo.payments.create_payment( + "payperemail", + Helpers.standard_payload( + invoice="INV-PPE-001", + description="Pay per email invite", + service_parameters={ + "CustomerEmail": "jane@example.com", + "CustomerFirstName": "Jane", + "CustomerLastName": "Doe", + "CustomerGender": "1", + }, + ), + ).execute_action("PaymentInvitation") + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "PaymentInvitation" + + sent = {p["Name"].lower(): p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert sent["customeremail"] == "jane@example.com" + assert sent["customerfirstname"] == "Jane" + assert sent["customerlastname"] == "Doe" + assert sent["customergender"] == "1" From 6822ed2a284792f182b3e2bf2b56e23982a3c975 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Mon, 13 Jul 2026 14:18:20 +0200 Subject: [PATCH 14/21] feat(BTI-20): add iDIN verification method (identify, verify, login) - add IdinBuilder driving identify/verify/login via the DataRequest endpoint - register idin in the payment method factory - add unit and feature tests covering all three actions - add examples/idin.py demoing the actions with sandbox issuer BANKNL2Y - document iDIN usage in README --- README.md | 20 ++++ buckaroo/builders/payments/idin_builder.py | 78 ++++++++++++++ buckaroo/factories/payment_method_factory.py | 2 + examples/idin.py | 100 ++++++++++++++++++ tests/feature/payments/test_idin.py | 79 ++++++++++++++ .../builders/payments/test_idin_builder.py | 85 +++++++++++++++ 6 files changed, 364 insertions(+) create mode 100644 buckaroo/builders/payments/idin_builder.py create mode 100644 examples/idin.py create mode 100644 tests/feature/payments/test_idin.py create mode 100644 tests/unit/builders/payments/test_idin_builder.py 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) From a573373e1f02e6ee786aed46e629a515b494fef4 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Tue, 14 Jul 2026 09:55:47 +0200 Subject: [PATCH 15/21] feat(BTI-21): implement instant refunds for iDEAL and Payconiq - fix instantRefund() to send OriginalTransactionKey + AmountCredit (refund-shaped body) instead of a Pay-shaped request - extract shared _build_refund_request_data() helper reused by refund() and instantRefund() - support partial refunds via refund_amount; raise ValueError when the original transaction key is missing - strengthen assert_instant_refund_returns_success to assert OriginalTransactionKey + AmountCredit on the wire - add wire-level and failure-flow tests for iDEAL and Payconiq instant refunds - add examples/instant_refund.py demoing iDEAL + Payconiq instant refunds - document instant refunds in README --- README.md | 19 +++ buckaroo/builders/base_builder.py | 29 ++++- .../capabilities/instant_refund_capable.py | 37 +++++- examples/instant_refund.py | 94 ++++++++++++++ tests/feature/payments/test_ideal.py | 18 ++- tests/feature/payments/test_payconiq.py | 117 ++++++++++++++++++ tests/support/helpers.py | 4 + .../test_bank_transfer_capabilities.py | 29 ++++- .../test_instant_refund_capable.py | 53 +++++++- .../payments/test_payconiq_builder.py | 31 ++++- .../builders/payments/test_sofort_builder.py | 2 +- 11 files changed, 413 insertions(+), 20 deletions(-) create mode 100644 examples/instant_refund.py diff --git a/README.md b/README.md index b5b1a91..1398b99 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [Pip Installation](#pip-installation) - [Example](#example) - [iDIN](#idin) +- [Instant Refunds](#instant-refunds) - [Contribute](#contribute) - [Versioning](#versioning) - [Additional information](#additional-information) @@ -113,6 +114,24 @@ print("redirect:", response.get_redirect_url()) See [`examples/idin.py`](examples/idin.py) for a runnable demo of all three actions. +### Instant Refunds + +Instant refunds send money back to the shopper immediately instead of via the regular batch refund process. They are processed as an instant payment rather than a standard refund, and are supported for iDEAL and Payconiq via `instantRefund()`. Pass the `original_transaction_key` of a settled payment; `refund_amount` is optional — omit it for a full refund. + +```python +response = payments.create_payment("ideal", { + "currency": "EUR", + "description": "ideal instant refund demo", + "invoice": "IDEAL-REFUND-DEMO-001", + "original_transaction_key": "ORIGINAL-TRANSACTION-KEY", + "refund_amount": 12.34, # optional; omit for a full refund +}).instantRefund() + +print("key:", response.key) +``` + +See [`examples/instant_refund.py`](examples/instant_refund.py) for a runnable demo covering both iDEAL and Payconiq. + ### Contribute We really appreciate it when developers contribute to improve the Buckaroo plugins. diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index 704cb1f..d586d8f 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -398,15 +398,20 @@ def pay(self, validate: bool = True, strict_validation: bool = False) -> Payment return self._post_transaction(request_data) - def refund(self, validate: bool = True) -> PaymentResponse: + def _build_refund_request_data(self, action: str, validate: bool = True) -> Dict[str, Any]: """ - Execute a refund transaction. + Build the wire request body shared by refund-style actions. + + Reads ``original_transaction_key`` and ``refund_amount`` from the payload, + builds the request for ``action``, and swaps ``AmountDebit`` for + ``AmountCredit`` (partial or full). Args: + action (str): The Buckaroo action to build (e.g. "Refund", "instantRefund") validate (bool): Whether to validate service parameters before building Returns: - PaymentResponse: The refund response + Dict[str, Any]: The refund request body Raises: ValueError: If required fields are missing @@ -422,7 +427,7 @@ def refund(self, validate: bool = True) -> PaymentResponse: refund_amount = self._payload.get("refund_amount") # Build refund request with original transaction reference - payment_request = self.build("Refund", validate=validate) + payment_request = self.build(action, validate=validate) # Convert to dictionary and modify for refund request_data = payment_request.to_dict() @@ -438,6 +443,22 @@ def refund(self, validate: bool = True) -> PaymentResponse: request_data["AmountCredit"] = request_data["AmountDebit"] del request_data["AmountDebit"] + return request_data + + def refund(self, validate: bool = True) -> PaymentResponse: + """ + Execute a refund transaction. + + Args: + validate (bool): Whether to validate service parameters before building + + Returns: + PaymentResponse: The refund response + + Raises: + ValueError: If required fields are missing + """ + request_data = self._build_refund_request_data("Refund", validate) return self._post_transaction(request_data) def pay_remainder( diff --git a/buckaroo/builders/payments/capabilities/instant_refund_capable.py b/buckaroo/builders/payments/capabilities/instant_refund_capable.py index 019f78a..2a9a577 100644 --- a/buckaroo/builders/payments/capabilities/instant_refund_capable.py +++ b/buckaroo/builders/payments/capabilities/instant_refund_capable.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from ....models.payment_response import PaymentResponse @@ -18,19 +18,48 @@ class InstantRefundCapable: """Mixin for payment methods that support instant refunds (iDEAL, Sofort, PayConiq).""" - def instantRefund(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + def instantRefund( + self: "PaymentBuilder", + original_transaction_key: Optional[str] = None, + validate: bool = True, + ) -> PaymentResponse: """ Initiate an instant refund. + Mirrors :meth:`BaseBuilder.refund`: puts ``OriginalTransactionKey`` and + ``AmountCredit`` on the wire instead of ``AmountDebit``, but keeps the + ``instantRefund`` action name. + Available for: iDEAL, Sofort, PayConiq Not available for: Credit Card, PayPal (use regular refund instead) Args: + original_transaction_key (str, optional): The transaction key of the + original payment. If None, will try to get from payload. validate (bool): Whether to validate service parameters before building Returns: PaymentResponse: The instant refund response + + Raises: + ValueError: If no original transaction key is available """ - payment_request = self.build("instantRefund", validate=validate) - request_data = payment_request.to_dict() + txn_key = original_transaction_key or self._payload.get("original_transaction_key") + if not txn_key: + raise ValueError( + "Original transaction key is required for instant refunds " + "(provide as parameter or in payload)" + ) + + _MISSING = object() + prev_key = self._payload.get("original_transaction_key", _MISSING) + self._payload["original_transaction_key"] = txn_key + try: + request_data = self._build_refund_request_data("instantRefund", validate) + finally: + if prev_key is _MISSING: + self._payload.pop("original_transaction_key", None) + else: + self._payload["original_transaction_key"] = prev_key + return self._post_transaction(request_data) diff --git a/examples/instant_refund.py b/examples/instant_refund.py new file mode 100644 index 0000000..4827b69 --- /dev/null +++ b/examples/instant_refund.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Demo of instant refunds for iDEAL and Payconiq. + +Instant refunds send money back to the shopper immediately instead of via the +regular batch refund process. They are supported for iDEAL and Payconiq +through ``instantRefund()``, which reads ``original_transaction_key`` from the +payload (or an optional argument) and an optional ``refund_amount`` for a +partial refund (a full refund is issued when it's omitted). The refund is +processed as an instant payment on the wire: ``OriginalTransactionKey`` plus +``AmountCredit`` instead of ``AmountDebit``. 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 + +# Placeholder transaction keys. Replace with the key of a SETTLED iDEAL / +# Payconiq payment before running for real; a live green instant refund also +# requires a merchant account with instant refunds enabled. +_IDEAL_ORIGINAL_TRANSACTION_KEY = "REPLACE_WITH_SETTLED_IDEAL_TRANSACTION_KEY" +_PAYCONIQ_ORIGINAL_TRANSACTION_KEY = "REPLACE_WITH_SETTLED_PAYCONIQ_TRANSACTION_KEY" + + +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_ideal_instant_refund() -> None: + """Instantly refund part of a settled iDEAL payment.""" + print("\n1. iDEAL instant refund") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "description": "ideal instant refund demo", + "invoice": "IDEAL-REFUND-DEMO-001", + "original_transaction_key": _IDEAL_ORIGINAL_TRANSACTION_KEY, + "refund_amount": 12.34, # optional; omit for full refund + }, + ).instantRefund() + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_payconiq_instant_refund() -> None: + """Instantly refund part of a settled Payconiq payment.""" + print("\n2. Payconiq instant refund") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment( + "payconiq", + { + "currency": "EUR", + "description": "payconiq instant refund demo", + "invoice": "PAYCONIQ-REFUND-DEMO-001", + "original_transaction_key": _PAYCONIQ_ORIGINAL_TRANSACTION_KEY, + "refund_amount": 12.34, # optional; omit for full refund + }, + ).instantRefund() + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — INSTANT REFUND DEMO") + print("=" * 60) + demo_ideal_instant_refund() + demo_payconiq_instant_refund() + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tests/feature/payments/test_ideal.py b/tests/feature/payments/test_ideal.py index 01c1be9..73801d8 100644 --- a/tests/feature/payments/test_ideal.py +++ b/tests/feature/payments/test_ideal.py @@ -1,5 +1,7 @@ +import pytest + from tests.support.mock_request import BuckarooMockRequest -from tests.support.recording_mock import recorded_action +from tests.support.recording_mock import recorded_action, recorded_request from tests.support.helpers import Helpers @@ -86,6 +88,20 @@ def test_ideal_instant_refund_sends_action_instantrefund_on_the_wire( ).instantRefund() assert recorded_action(recording_mock) == "instantRefund" + request = recorded_request(recording_mock) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.00 + assert "AmountDebit" not in request + + def test_ideal_instant_refund_raises_without_original_transaction_key( + self, recording_buckaroo + ): + """A missing ``original_transaction_key`` must fail fast, matching ``refund()``.""" + with pytest.raises(ValueError, match="Original transaction key is required"): + recording_buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload(invoice="INV-IDEAL-WIRE-IREFUND-NOKEY"), + ).instantRefund() def test_ideal_fast_checkout_sends_action_payfastcheckout_on_the_wire( self, recording_buckaroo, recording_mock diff --git a/tests/feature/payments/test_payconiq.py b/tests/feature/payments/test_payconiq.py index e22ff81..5810f5f 100644 --- a/tests/feature/payments/test_payconiq.py +++ b/tests/feature/payments/test_payconiq.py @@ -1,5 +1,9 @@ """Feature test: payconiq pay() and capability methods through full stack with MockBuckaroo.""" +import pytest + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_request from tests.support.helpers import Helpers @@ -38,3 +42,116 @@ def test_payconiq_fast_checkout(self, buckaroo, mock_strategy): method="payconiq", invoice="INV-PCQ-FAST", ) + + # ------------------------------------------------------------------ + # Wire-level assertions — verify BankTransferCapabilities' InstantRefund + # mixin puts the right Action, OriginalTransactionKey and AmountCredit + # on the outgoing request for Payconiq (partial + full). + + def test_payconiq_instant_refund_full_sends_action_instantrefund_on_the_wire( + self, recording_buckaroo, recording_mock + ): + """Full refund (``refund_amount`` omitted): AmountDebit swaps to AmountCredit.""" + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.success_response( + { + "Services": [ + {"Name": "payconiq", "Action": "InstantRefund", "Parameters": []} + ], + "ServiceCode": "payconiq", + "AmountCredit": 10.00, + "AmountDebit": None, + } + ), + ) + ) + recording_buckaroo.payments.create_payment( + "payconiq", + Helpers.standard_payload( + invoice="INV-PCQ-WIRE-IREFUND-FULL", + original_transaction_key="ABC123", + ), + ).instantRefund() + + assert recorded_action(recording_mock) == "instantRefund" + request = recorded_request(recording_mock) + assert request["Services"]["ServiceList"][0]["Name"] == "payconiq" + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.00 + assert "AmountDebit" not in request + + def test_payconiq_instant_refund_partial_sends_refund_amount_as_credit( + self, recording_buckaroo, recording_mock + ): + """Partial refund (``refund_amount`` in payload): AmountCredit is that amount.""" + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.success_response( + { + "Services": [ + {"Name": "payconiq", "Action": "InstantRefund", "Parameters": []} + ], + "ServiceCode": "payconiq", + "AmountCredit": 4.00, + "AmountDebit": None, + } + ), + ) + ) + recording_buckaroo.payments.create_payment( + "payconiq", + Helpers.standard_payload( + invoice="INV-PCQ-WIRE-IREFUND-PARTIAL", + original_transaction_key="ABC123", + refund_amount=4.00, + ), + ).instantRefund() + + assert recorded_action(recording_mock) == "instantRefund" + request = recorded_request(recording_mock) + assert request["Services"]["ServiceList"][0]["Name"] == "payconiq" + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 4.00 + assert "AmountDebit" not in request + + def test_payconiq_instant_refund_raises_without_original_transaction_key( + self, recording_buckaroo + ): + """A missing ``original_transaction_key`` must fail fast, matching iDEAL.""" + with pytest.raises(ValueError, match="Original transaction key is required"): + recording_buckaroo.payments.create_payment( + "payconiq", + Helpers.standard_payload(invoice="INV-PCQ-WIRE-IREFUND-NOKEY"), + ).instantRefund() + + def test_payconiq_instant_refund_surfaces_api_rejection_as_failed_response( + self, buckaroo, mock_strategy + ): + """A rejected instant refund is surfaced as a standard failed response, + not an exception.""" + response_body = Helpers.failed_response( + "Refund rejected", + overrides={ + "Services": [ + {"Name": "payconiq", "Action": "InstantRefund", "Parameters": []} + ], + "ServiceCode": "payconiq", + "AmountCredit": None, + "AmountDebit": 10.00, + }, + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + payload = Helpers.standard_payload( + invoice="INV-PCQ-IREFUND-REJECTED", + original_transaction_key="ABC123", + ) + response = buckaroo.payments.create_payment("payconiq", payload).instantRefund() + + assert response.is_failed() + assert not response.is_successful() + assert response.key == response_body["Key"] diff --git a/tests/support/helpers.py b/tests/support/helpers.py index 2a5dc5f..2ba10d3 100644 --- a/tests/support/helpers.py +++ b/tests/support/helpers.py @@ -235,6 +235,10 @@ def assert_instant_refund_returns_success( assert response.status.code.code == STATUS_SUCCESS assert response.key == response_body["Key"] _assert_recorded_action(mock_strategy, "instantRefund") + sent = json.loads(mock_strategy.calls[-1]["data"]) + assert sent["OriginalTransactionKey"] == original_transaction_key + assert sent["AmountCredit"] == 10.00 + assert "AmountDebit" not in sent return response @staticmethod diff --git a/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py b/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py index 25107e6..265aca3 100644 --- a/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py +++ b/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py @@ -10,6 +10,8 @@ from __future__ import annotations +import pytest + from buckaroo.builders.payments.capabilities.bank_transfer_capabilities import ( BankTransferCapabilities, ) @@ -22,7 +24,7 @@ from buckaroo.models.payment_response import PaymentResponse from tests.support.builders import make_test_builder, populate_required_fields from tests.support.mock_request import BuckarooMockRequest -from tests.support.recording_mock import recorded_action, wire_recording_http +from tests.support.recording_mock import recorded_action, recorded_request, wire_recording_http # --------------------------------------------------------------------------- @@ -69,7 +71,7 @@ def test_posts_action_instantRefund(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) assert recorded_action(mock) == "instantRefund" @@ -78,7 +80,7 @@ def test_posts_to_transaction_endpoint(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) assert len(mock.calls) == 1 call = mock.calls[0] @@ -96,11 +98,30 @@ def test_returns_payment_response(self): ) builder = _ready_builder(client) - response = builder.instantRefund(validate=False) + response = builder.instantRefund("ABC123", validate=False) assert isinstance(response, PaymentResponse) assert response.key == "refund-123" + def test_sends_original_transaction_key_and_amount_credit_on_the_wire(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.instantRefund("ABC123", validate=False) + + request = recorded_request(mock) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.0 + assert "AmountDebit" not in request + + def test_raises_value_error_without_original_transaction_key(self): + _mock, client = wire_recording_http() + builder = _ready_builder(client) + + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.instantRefund(validate=False) + # --------------------------------------------------------------------------- # payFastCheckout() diff --git a/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py b/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py index b024c52..e27be32 100644 --- a/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py +++ b/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py @@ -52,7 +52,7 @@ def test_posts_action_instantRefund(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) assert recorded_action(mock) == "instantRefund" @@ -61,7 +61,7 @@ def test_posts_to_transaction_endpoint(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) assert len(mock.calls) == 1 call = mock.calls[0] @@ -73,7 +73,7 @@ def test_posts_expected_service_name(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) service = recorded_request(mock)["Services"]["ServiceList"][0] assert service["Name"] == "ideal" @@ -89,11 +89,54 @@ def test_returns_payment_response(self): ) builder = _ready_builder(client) - response = builder.instantRefund(validate=False) + response = builder.instantRefund("ABC123", validate=False) assert isinstance(response, PaymentResponse) assert response.key == "refund-123" + def test_raises_value_error_without_original_transaction_key(self): + _mock, client = wire_recording_http() + builder = _ready_builder(client) + + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.instantRefund(validate=False) + + def test_full_refund_sends_original_transaction_key_and_swaps_amount_to_credit(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.instantRefund("ABC123", validate=False) + + request = recorded_request(mock) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.0 + assert "AmountDebit" not in request + + def test_partial_refund_uses_refund_amount_from_payload(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + builder._payload["refund_amount"] = 3.25 + + builder.instantRefund("ABC123", validate=False) + + request = recorded_request(mock) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 3.25 + assert "AmountDebit" not in request + + def test_reads_original_transaction_key_from_payload_fallback(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + builder._payload["original_transaction_key"] = "FROM-PAYLOAD" + + builder.instantRefund(validate=False) + + request = recorded_request(mock) + assert request["OriginalTransactionKey"] == "FROM-PAYLOAD" + def test_forwards_validate_flag_true_triggers_validation(self): """``validate=True`` runs the real service-parameter validator against ``get_allowed_service_parameters("instantRefund")`` — so declaring a @@ -111,6 +154,6 @@ def test_forwards_validate_flag_true_triggers_validation(self): ) with pytest.raises(RequiredParameterMissingError) as exc: - builder.instantRefund(validate=True) + builder.instantRefund("ABC123", validate=True) assert exc.value.parameter_name == "refund_reason" diff --git a/tests/unit/builders/payments/test_payconiq_builder.py b/tests/unit/builders/payments/test_payconiq_builder.py index 53ceb8c..e0a2152 100644 --- a/tests/unit/builders/payments/test_payconiq_builder.py +++ b/tests/unit/builders/payments/test_payconiq_builder.py @@ -15,6 +15,7 @@ ) from tests.support.mock_request import BuckarooMockRequest from tests.support.builders import populate_required_fields +from tests.support.recording_mock import recorded_action, recorded_request def test_construction_with_client_succeeds(client): @@ -135,10 +136,38 @@ def test_payconiq_instantRefund_works(client, mock_strategy): ) ) builder = populate_required_fields(PayconiqBuilder(client)) - response = builder.instantRefund(validate=False) + response = builder.instantRefund("ABC123", validate=False) assert response is not None +def test_payconiq_instantRefund_sends_original_transaction_key_and_amount_credit_on_wire( + client, mock_strategy +): + """InstantRefundCapable (inherited via BankTransferCapabilities) must put + OriginalTransactionKey + AmountCredit on the wire for Payconiq, action instantRefund.""" + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/transaction*", {"Key": "pcq-ir-2", "Status": {"Code": {"Code": 190}}} + ) + ) + builder = populate_required_fields(PayconiqBuilder(client)) + + builder.instantRefund("ABC123", validate=False) + + assert recorded_action(mock_strategy) == "instantRefund" + request = recorded_request(mock_strategy) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.0 + assert "AmountDebit" not in request + + +def test_payconiq_instantRefund_raises_without_original_transaction_key(client, mock_strategy): + builder = populate_required_fields(PayconiqBuilder(client)) + + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.instantRefund(validate=False) + + def test_pay_end_to_end(client, mock_strategy): mock_strategy.queue( BuckarooMockRequest.json( diff --git a/tests/unit/builders/payments/test_sofort_builder.py b/tests/unit/builders/payments/test_sofort_builder.py index 51b7da5..962248f 100644 --- a/tests/unit/builders/payments/test_sofort_builder.py +++ b/tests/unit/builders/payments/test_sofort_builder.py @@ -144,7 +144,7 @@ def test_instant_refund_works(client, mock_strategy): ) ) builder = populate_required_fields(SofortBuilder(client)) - response = builder.instantRefund() + response = builder.instantRefund("ABC123") assert response is not None From f797b6574aec8a3afae586b55c2750ed0370dfe9 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Tue, 14 Jul 2026 10:01:40 +0200 Subject: [PATCH 16/21] style(BTI-21): apply ruff format to instant-refund tests --- tests/feature/payments/test_ideal.py | 4 +--- tests/feature/payments/test_payconiq.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/feature/payments/test_ideal.py b/tests/feature/payments/test_ideal.py index 73801d8..625424c 100644 --- a/tests/feature/payments/test_ideal.py +++ b/tests/feature/payments/test_ideal.py @@ -93,9 +93,7 @@ def test_ideal_instant_refund_sends_action_instantrefund_on_the_wire( assert request["AmountCredit"] == 10.00 assert "AmountDebit" not in request - def test_ideal_instant_refund_raises_without_original_transaction_key( - self, recording_buckaroo - ): + def test_ideal_instant_refund_raises_without_original_transaction_key(self, recording_buckaroo): """A missing ``original_transaction_key`` must fail fast, matching ``refund()``.""" with pytest.raises(ValueError, match="Original transaction key is required"): recording_buckaroo.payments.create_payment( diff --git a/tests/feature/payments/test_payconiq.py b/tests/feature/payments/test_payconiq.py index 5810f5f..77ef04b 100644 --- a/tests/feature/payments/test_payconiq.py +++ b/tests/feature/payments/test_payconiq.py @@ -137,9 +137,7 @@ def test_payconiq_instant_refund_surfaces_api_rejection_as_failed_response( response_body = Helpers.failed_response( "Refund rejected", overrides={ - "Services": [ - {"Name": "payconiq", "Action": "InstantRefund", "Parameters": []} - ], + "Services": [{"Name": "payconiq", "Action": "InstantRefund", "Parameters": []}], "ServiceCode": "payconiq", "AmountCredit": None, "AmountDebit": 10.00, From d78a91c1a63cdfb980843d1f16bd55d9b65b3c78 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Tue, 14 Jul 2026 12:58:24 +0200 Subject: [PATCH 17/21] feat(BTI-23): add eMandate solution (B2C + B2B) Add eMandate as a DataRequest-based solution with five actions: GetIssuerList, CreateMandate, GetStatus, ModifyMandate, CancelMandate. - EmandateBuilder (SolutionBuilder) + EmandateB2BBuilder subclass for the emandateb2b service code; both registered in SolutionMethodFactory. - Per-action allowed/required service-parameter validation (debtorReference for create; mandateId for status/modify/cancel). - Reuses PaymentResponse; no bespoke per-action response model. - Unit + feature tests for every action (success + validation paths), runnable examples/emandate.py, and a README eMandate section. Verified against Buckaroo Plaza test: GetIssuerList succeeds and CreateMandate is accepted (redirect returned) for both B2C and B2B. --- README.md | 56 ++++ .../builders/solutions/emandate_builder.py | 176 ++++++++++ buckaroo/factories/solution_method_factory.py | 3 + examples/emandate.py | 159 +++++++++ tests/feature/solutions/test_emandate.py | 246 ++++++++++++++ .../test_concrete_solutions_contract.py | 2 + .../solutions/test_emandate_builder.py | 301 ++++++++++++++++++ .../factories/test_solution_method_factory.py | 8 + 8 files changed, 951 insertions(+) create mode 100644 buckaroo/builders/solutions/emandate_builder.py create mode 100644 examples/emandate.py create mode 100644 tests/feature/solutions/test_emandate.py create mode 100644 tests/unit/builders/solutions/test_emandate_builder.py 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"]) From b1fc42053daa804fa89c9e6d767735ff83f93dfb Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Jul 2026 17:19:08 +0800 Subject: [PATCH 18/21] refactor: clean up imports, format code, and improve API robustness Ensures BaseBuilder gracefully handles null HTTP responses from the API client. --- buckaroo/_buckaroo_client.py | 3 +-- buckaroo/app.py | 6 ++--- buckaroo/builders/base_builder.py | 27 ++++++++++--------- .../solutions/subscription_builder.py | 2 +- buckaroo/exceptions/_buckaroo_error.py | 2 +- buckaroo/factories/payment_method_factory.py | 2 +- buckaroo/http/client.py | 2 +- buckaroo/http/strategies/curl_strategy.py | 4 +-- buckaroo/models/payment_response.py | 1 - buckaroo/observers/logging_observer.py | 2 +- .../services/service_parameter_validator.py | 14 +++++----- setup.py | 3 ++- .../solutions/test_default_solution.py | 1 - 13 files changed, 34 insertions(+), 35 deletions(-) diff --git a/buckaroo/_buckaroo_client.py b/buckaroo/_buckaroo_client.py index 2570a60..4118c46 100644 --- a/buckaroo/_buckaroo_client.py +++ b/buckaroo/_buckaroo_client.py @@ -1,8 +1,7 @@ import logging -from typing import Optional, Union +from typing import Optional from .exceptions._authentication_error import AuthenticationError -from .exceptions._buckaroo_error import BuckarooError from .config.buckaroo_config import BuckarooConfig, create_config_from_mode from .http.client import BuckarooHttpClient diff --git a/buckaroo/app.py b/buckaroo/app.py index b405fbb..1a40b94 100644 --- a/buckaroo/app.py +++ b/buckaroo/app.py @@ -15,8 +15,6 @@ from buckaroo._buckaroo_client import BuckarooClient from buckaroo.observers import ( BuckarooLoggingObserver, - create_logger, - create_logger_from_env, LogLevel, LogDestination, LogConfig, @@ -89,7 +87,7 @@ class Buckaroo: >>> payment = app.create_ideal_payment(amount=25.50, currency="EUR") >>> response = app.execute_payment(payment) """ - + def __init__(self, config: Optional[BuckarooConfig] = None): """ Initialize Buckaroo Application. @@ -109,7 +107,7 @@ def __init__(self, config: Optional[BuckarooConfig] = None): def from_env(cls) -> "Buckaroo": """Create Buckaroo app from environment variables.""" return cls(BuckarooConfig.from_env()) - + @classmethod def quick_setup( cls, store_key: str, secret_key: str, mode: str = "test", log_to_stdout: bool = True diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index 9eb4c76..0ff7f19 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -42,47 +42,47 @@ def __init__(self, client, executor: Optional[ITransactionExecutor] = None) -> N self._service_parameters: List[Parameter] = [] self._payload: Dict[str, Any] = {} # Store original payload self._validator = ServiceParameterValidator(self) - + def currency(self, currency: str) -> Self: """Set the currency for the payment.""" self._currency = currency return self - + def amount(self, amount: float) -> Self: """Set the amount for the payment.""" self._amount_debit = amount return self - + def description(self, description: str) -> Self: """Set the description for the payment.""" self._description = description return self - + def invoice(self, invoice: str) -> Self: """Set the invoice number for the payment.""" self._invoice = invoice return self - + def return_url(self, url: str) -> Self: """Set the return URL for successful payment.""" self._return_url = url return self - + def return_url_cancel(self, url: str) -> Self: """Set the return URL for cancelled payment.""" self._return_url_cancel = url return self - + def return_url_error(self, url: str) -> Self: """Set the return URL for payment error.""" self._return_url_error = url return self - + def return_url_reject(self, url: str) -> Self: """Set the return URL for rejected payment.""" self._return_url_reject = url return self - + def continue_on_incomplete(self, continue_incomplete: str) -> Self: """Set whether to continue on incomplete payment.""" self._continue_on_incomplete = continue_incomplete @@ -117,7 +117,7 @@ def client_ip(self, ip_address: str, ip_type: int = 0) -> Self: """Set the client IP information.""" self._client_ip = ClientIP(type=ip_type, address=ip_address) return self - + def add_parameter(self, key: str, value: Any, group_type: Optional[str] = None, group_id: Optional[str] = None) -> Self: """Add a custom parameter to the service. @@ -192,7 +192,7 @@ def _validate_and_filter_service_parameters( self._service_parameters = self._validator.validate_all_parameters( self._service_parameters, action, strict=strict ) - + def from_dict(self, data: Dict[str, Any]) -> Self: """ Populate the builder from a dictionary of parameters. @@ -333,7 +333,7 @@ def _validate_required_fields(self, action: str = "Pay") -> None: raise ValueError( f"Missing required fields: {', '.join(missing_fields)}" ) - + def build(self, action: str = "Pay", validate: bool = True, strict_validation: bool = False) -> PaymentRequest: """Build the payment request. @@ -644,3 +644,6 @@ def _post_transaction(self, request_data: Dict[str, Any]) -> PaymentResponse: # 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) + if response is None: + return PaymentResponse({}) + return PaymentResponse(response.to_dict()) diff --git a/buckaroo/builders/solutions/subscription_builder.py b/buckaroo/builders/solutions/subscription_builder.py index ab97481..6cb9651 100644 --- a/buckaroo/builders/solutions/subscription_builder.py +++ b/buckaroo/builders/solutions/subscription_builder.py @@ -19,4 +19,4 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: def createSubscription(self, validate: bool = True) -> Any: """Create a subscription.""" - return self._post_data_request(self.build("CreateSubscription", validate=validate).to_dict()) \ No newline at end of file + return self._post_data_request(self.build("CreateSubscription", validate=validate).to_dict()) diff --git a/buckaroo/exceptions/_buckaroo_error.py b/buckaroo/exceptions/_buckaroo_error.py index c6fcc7d..0055666 100644 --- a/buckaroo/exceptions/_buckaroo_error.py +++ b/buckaroo/exceptions/_buckaroo_error.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional +from typing import Dict, Optional class BuckarooError(Exception): diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index f1aa80d..da47aaa 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -1,4 +1,4 @@ -from typing import Dict, Type +from typing import Any, Dict, Type import logging from .builder_factory import BuilderFactory diff --git a/buckaroo/http/client.py b/buckaroo/http/client.py index f9dd27e..41d9684 100644 --- a/buckaroo/http/client.py +++ b/buckaroo/http/client.py @@ -71,7 +71,7 @@ def _generate_hmac_signature( timestamp = str(int(time.time())) nonce = str(uuid.uuid4()) - + # Process content following C# implementation pattern. # MD5 is mandated by the Buckaroo HMAC authentication specification; # the content digest is an input component to HMAC-SHA256 and is not diff --git a/buckaroo/http/strategies/curl_strategy.py b/buckaroo/http/strategies/curl_strategy.py index ec09009..b20c8f5 100644 --- a/buckaroo/http/strategies/curl_strategy.py +++ b/buckaroo/http/strategies/curl_strategy.py @@ -23,7 +23,7 @@ def __init__(self): def configure(self, **kwargs) -> None: self._apply_defaults(**kwargs) - + def request( self, method: str, @@ -90,7 +90,7 @@ def request( last_exception = Exception(f"Request failed: {str(e)}") if attempt == self._retry_attempts - 1: raise last_exception - + def _build_curl_command( self, method: str, diff --git a/buckaroo/models/payment_response.py b/buckaroo/models/payment_response.py index 7104f6e..57d9363 100644 --- a/buckaroo/models/payment_response.py +++ b/buckaroo/models/payment_response.py @@ -6,7 +6,6 @@ from typing import Any, Dict, Iterator, List, Optional from dataclasses import dataclass -from datetime import datetime from .payment_request import Parameter diff --git a/buckaroo/observers/logging_observer.py b/buckaroo/observers/logging_observer.py index 6e7cb0f..49ca64a 100644 --- a/buckaroo/observers/logging_observer.py +++ b/buckaroo/observers/logging_observer.py @@ -502,7 +502,7 @@ def create_logger_from_env() -> BuckarooLoggingObserver: dest_str = os.getenv("BUCKAROO_LOG_DESTINATION", "both").lower() destination = LogDestination(dest_str) if dest_str in [d.value for d in LogDestination] else LogDestination.BOTH - + log_file = os.getenv("BUCKAROO_LOG_FILE", "buckaroo_sdk.log") mask_sensitive = os.getenv("BUCKAROO_LOG_MASK_SENSITIVE", "true").lower() == "true" diff --git a/buckaroo/services/service_parameter_validator.py b/buckaroo/services/service_parameter_validator.py index be6de6f..7676331 100644 --- a/buckaroo/services/service_parameter_validator.py +++ b/buckaroo/services/service_parameter_validator.py @@ -22,7 +22,7 @@ def __init__(self, builder) -> None: """ self._get_allowed_params = builder.get_allowed_service_parameters self._get_service_name = builder.get_service_name - + def normalize_parameter_name(self, param_name: str) -> str: """Normalize parameter name to lowercase and remove underscores for matching. @@ -150,7 +150,7 @@ def validate_required_parameters( RequiredParameterMissingError: If any required parameter is missing """ allowed_params = self._get_allowed_params(action) - + # Create a normalized lookup for provided parameters # Include both regular parameters and group_types provided_params = {} @@ -207,7 +207,7 @@ def validate_and_filter_parameters( """ if not parameters: return [] - + allowed_params = self._get_allowed_params(action) # Create normalized lookup for allowed parameters @@ -261,10 +261,10 @@ def validate_and_filter_parameters( invalid_params.append(f"{param.name}: {str(e)}") else: invalid_params.append(f"{param.name}: not allowed for {self._get_service_name()} {action} action") - + if invalid_params: logging.warning("Filtered out invalid service parameters for %s action: %s", action, invalid_params) - + return valid_parameters def validate_all_parameters( @@ -294,7 +294,7 @@ def validate_all_parameters( normalized_param_name = self.normalize_parameter_name(param.name) allowed_params = self._get_allowed_params(action) normalized_allowed = {self.normalize_parameter_name(key): key for key in allowed_params.keys()} - + if normalized_param_name in normalized_allowed: allowed_param_name = normalized_allowed[normalized_param_name] param_value = self.normalize_parameter_value(param.value) @@ -325,7 +325,7 @@ def get_parameter_info(self, action: str = "Pay") -> Dict[str, Any]: Dict[str, Any]: Parameter information including types and requirements """ return self._get_allowed_params(action) - + def is_parameter_allowed(self, param_name: str, action: str = "Pay") -> bool: """ Check if a parameter is allowed for the given action. diff --git a/setup.py b/setup.py index eb4f2dc..0ba4d30 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ import os +import re from setuptools import setup, find_packages @@ -12,7 +13,7 @@ if not version_match: raise RuntimeError("Cannot find VERSION in buckaroo/_version.py") version_contents = {"VERSION": version_match.group(1)} - + setup( name="buckaroo-sdk", version=version_contents["VERSION"], diff --git a/tests/feature/solutions/test_default_solution.py b/tests/feature/solutions/test_default_solution.py index 24e35b1..d1333c7 100644 --- a/tests/feature/solutions/test_default_solution.py +++ b/tests/feature/solutions/test_default_solution.py @@ -1,4 +1,3 @@ -import pytest from tests.support.helpers import Helpers From b7e0d198e397a84e4f52b43dab4eb4aceefe650c Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Jul 2026 17:54:02 +0800 Subject: [PATCH 19/21] ``` refactor: Isolate payment lifecycle methods in PaymentBuilder ``` Moves payment-specific transaction methods (`pay`, `refund`, `partial_refund`, `pay_remainder`, `cancel`) from `BaseBuilder` to `PaymentBuilder`. This refactors the builder hierarchy to clearly separate generic builder functionality from operations specific to payment processing flows. `BaseBuilder` now exclusively houses common operations like `build` and `capture`, while `PaymentBuilder` encapsulates all payment lifecycle actions. ``` --- buckaroo/builders/base_builder.py | 156 ------------------ buckaroo/builders/payments/payment_builder.py | 44 ++--- .../builders/payments/test_riverty_builder.py | 2 +- 3 files changed, 24 insertions(+), 178 deletions(-) diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index 0ff7f19..7b0e5dc 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -384,32 +384,6 @@ def build(self, action: str = "Pay", validate: bool = True, strict_validation: b return payment_request - def pay(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: - """ - Execute the payment operation. - - Args: - validate (bool): Whether to validate service parameters before building - strict_validation (bool): If True, throws exceptions for missing required parameters - - Returns: - PaymentResponse: Structured payment response object - - Raises: - ValueError: If required fields are missing - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) - AuthenticationError: If authentication fails - BuckarooApiError: If API returns an error - """ - # Build the payment request - payment_request = self.build("Pay", validate=validate, strict_validation=strict_validation) - - # Convert to dictionary for API - request_data = payment_request.to_dict() - - return self._post_transaction(request_data) - def _build_refund_request_data(self, action: str, validate: bool = True) -> Dict[str, Any]: """ Build the wire request body shared by refund-style actions. @@ -457,56 +431,6 @@ def _build_refund_request_data(self, action: str, validate: bool = True) -> Dict return request_data - def refund(self, validate: bool = True) -> PaymentResponse: - """ - Execute a refund transaction. - - Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The refund response - - Raises: - ValueError: If required fields are missing - """ - request_data = self._build_refund_request_data("Refund", validate) - 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, @@ -553,86 +477,6 @@ def capture( return self._post_transaction(request_data) - def cancel(self, original_transaction_key: Optional[str] = None) -> PaymentResponse: - """ - Cancel a pending or authorized transaction. - - Args: - original_transaction_key (str, optional): The transaction key to cancel. - If None, will try to get from payload. - - Returns: - PaymentResponse: The cancellation response - """ - # Get transaction key from parameter or payload - txn_key = ( - original_transaction_key - or self._payload.get("cancel_key") - or self._payload.get("original_transaction_key") - ) - if not txn_key: - raise ValueError( - "Transaction key is required for cancellations (provide as parameter or in payload)" - ) - - # Build cancel request; validate=False because cancel only needs - # OriginalTransactionKey, not the full Pay required-field set. - payment_request = self.build("Cancel", validate=False) - request_data = payment_request.to_dict() - - # Set cancellation parameters - request_data["OriginalTransactionKey"] = txn_key - # Remove amounts for cancellation - request_data.pop("AmountDebit", None) - request_data.pop("AmountCredit", None) - - return self._post_transaction(request_data) - - def partial_refund( - self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None - ) -> PaymentResponse: - """ - Execute a partial refund transaction. - - Args: - original_transaction_key (str, optional): The transaction key of the original payment. - If None, will try to get from payload. - amount (float, optional): Amount to refund. If None, will try to get from payload. - - Returns: - PaymentResponse: The partial refund response - - Raises: - ValueError: If amount is not provided or invalid - """ - refund_amount = ( - amount - or self._payload.get("refund_amount") - or self._payload.get("partial_refund_amount") - ) - if not refund_amount or refund_amount <= 0: - raise ValueError( - "Partial refund amount must be greater than 0 (provide as parameter or in payload)" - ) - - _MISSING = object() - prev_key = self._payload.get("original_transaction_key", _MISSING) - prev_amount = self._payload.get("refund_amount", _MISSING) - try: - if original_transaction_key: - self._payload["original_transaction_key"] = original_transaction_key - self._payload["refund_amount"] = refund_amount - return self.refund() - finally: - if prev_key is _MISSING: - self._payload.pop("original_transaction_key", None) - else: - self._payload["original_transaction_key"] = prev_key - if prev_amount is _MISSING: - self._payload.pop("refund_amount", None) - else: - self._payload["refund_amount"] = prev_amount - def _post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse: """Post a data request to the Buckaroo API.""" return self._executor.post_data_request(request_data) diff --git a/buckaroo/builders/payments/payment_builder.py b/buckaroo/builders/payments/payment_builder.py index 97852c1..8b10502 100644 --- a/buckaroo/builders/payments/payment_builder.py +++ b/buckaroo/builders/payments/payment_builder.py @@ -9,9 +9,11 @@ class PaymentBuilder(BaseBuilder): """Base class for all payment method builders. - Adds payment lifecycle methods (pay, refund, capture, cancel, execute_action) - that are specific to payment flows. ``SolutionBuilder`` inherits from - ``BaseBuilder`` directly and does NOT get these methods. + Adds payment lifecycle methods (pay, refund, partial_refund, pay_remainder, + cancel, execute_action) that are specific to payment flows. + ``SolutionBuilder`` inherits from ``BaseBuilder`` directly and does NOT get + these methods. ``capture`` is shared by every builder and lives on + ``BaseBuilder``. """ def pay(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: @@ -71,29 +73,29 @@ def partial_refund( else: self._payload['refund_amount'] = saved_amount - def capture( - self, - original_transaction_key: Optional[str] = None, - amount: Optional[float] = None, - validate: bool = True, + def pay_remainder( + self, original_transaction_key: Optional[str] = None, validate: bool = True ) -> PaymentResponse: - """Capture a previously authorized payment.""" - auth_key = ( - original_transaction_key - or self._payload.get('authorization_key') - or self._payload.get('original_transaction_key') - ) - if not auth_key: - raise ValueError("Authorization key is required for capture") - - capture_amount = amount if amount is not None else self._payload.get('capture_amount') - request_data = self._build_keyed_request('Capture', auth_key, validate=validate) + """Pay the open remainder of a group transaction. - if capture_amount is not None: - request_data['AmountDebit'] = capture_amount + Uses the PayRemainder action (e.g. after a partial giftcard payment). + The original transaction key is the group transaction key that links + this payment into the group; read from the payload when not supplied. + """ + 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)" + ) + request_data = self._build_keyed_request('PayRemainder', txn_key, validate=validate) return self._post_transaction(request_data) + # ``capture`` is intentionally not defined here: it lives on + # :class:`BaseBuilder` with the full ``original_transaction_key`` / ``amount`` + # signature and is shared by every builder. + def cancel(self, original_transaction_key: Optional[str] = None) -> PaymentResponse: """Cancel a pending or authorized transaction.""" txn_key = ( diff --git a/tests/unit/builders/payments/test_riverty_builder.py b/tests/unit/builders/payments/test_riverty_builder.py index 4da69fa..adb68ff 100644 --- a/tests/unit/builders/payments/test_riverty_builder.py +++ b/tests/unit/builders/payments/test_riverty_builder.py @@ -173,5 +173,5 @@ def test_authorize_end_to_end_via_mock_buckaroo( def test_cancel_authorize_requires_original_transaction_key(client: BuckarooClient) -> None: """Missing key raises ValueError — same contract as creditcard.""" builder = populate_required_fields(RivertyBuilder(client), amount=10.0) - with pytest.raises(ValueError, match="Original transaction key is required"): + with pytest.raises(ValueError, match="original_transaction_key is required"): builder.cancelAuthorize(original_transaction_key="") From 0ed5bd433ea4fbb5600eeaf697b0eb9700328884 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Wed, 15 Jul 2026 12:51:23 +0200 Subject: [PATCH 20/21] feat(BTI-16): add Split Payments (Marketplaces) solution - add MarketplacesBuilder with split, transfer, refund_supplementary and manual_transfer - register "marketplaces" solution in SolutionMethodFactory - add combine() to BaseBuilder so a supplementary service rides in one ServiceList - add CombinableService model returned by split/refund_supplementary - split and refund_supplementary combine into a payment/refund; transfer and manual_transfer post standalone DataRequests - map grouped Marketplace/Seller split params from a single spec dict - add unit and feature tests asserting the exact wire body per request type - add examples/marketplaces.py demoing all six request types - document Split Payments usage in README --- README.md | 93 ++++++ buckaroo/builders/base_builder.py | 30 +- .../solutions/marketplaces_builder.py | 207 ++++++++++++ buckaroo/factories/solution_method_factory.py | 2 + buckaroo/models/payment_request.py | 13 + examples/marketplaces.py | 241 ++++++++++++++ tests/feature/solutions/test_marketplaces.py | 308 ++++++++++++++++++ .../test_concrete_solutions_contract.py | 1 + .../solutions/test_marketplaces_builder.py | 65 ++++ 9 files changed, 957 insertions(+), 3 deletions(-) create mode 100644 buckaroo/builders/solutions/marketplaces_builder.py create mode 100644 examples/marketplaces.py create mode 100644 tests/feature/solutions/test_marketplaces.py create mode 100644 tests/unit/builders/solutions/test_marketplaces_builder.py diff --git a/README.md b/README.md index a238e30..992dd55 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - [iDIN](#idin) - [Instant Refunds](#instant-refunds) - [eMandate](#emandate) +- [Split Payments](#split-payments) - [Contribute](#contribute) - [Versioning](#versioning) - [Additional information](#additional-information) @@ -188,6 +189,98 @@ The B2B variant exposes the same five methods — swap `"emandate"` for `"emanda See [`examples/emandate.py`](examples/emandate.py) for a runnable demo of all five actions against both the B2C and B2B services. +### Split Payments + +Split Payments (Buckaroo service `Marketplaces`) lets a platform divide one +customer payment across its own funds account and one or more seller accounts. +Following the other Buckaroo SDKs, `split` and `refund_supplementary` build a +supplementary service that is *combined* into a payment or refund; `transfer` and +`manual_transfer` are standalone. + +```python +from buckaroo import BuckarooClient +from buckaroo.services.payment_service import PaymentService +from buckaroo.services.solution_service import SolutionService + +client = BuckarooClient("STORE_KEY", "SECRET_KEY", mode="test") +payments = PaymentService(client) +marketplaces = SolutionService(client) +``` + +**Split** — build the split, then combine it into the funding payment (e.g. +iDEAL). `daysUntilTransfer` is `"0"` for immediate payout, or omit it to hold the +funds until a later Transfer. + +```python +split = marketplaces.create_solution("marketplaces").split({ + "daysUntilTransfer": "2", + "marketplace": {"Amount": "10.00", "Description": "INV0001 Commission Platform"}, + "sellers": [ + {"AccountId": "SELLER_ACCOUNT_1", "Amount": "50.00", "Description": "Payout 1"}, + {"AccountId": "SELLER_ACCOUNT_2", "Amount": "35.00", "Description": "Payout 2"}, + ], +}) + +response = payments.create_payment("ideal", { + "currency": "EUR", + "amount": 95.00, + "invoice": "INV0001", + "description": "Split order INV0001", + "service_parameters": {"issuer": "ABNANL2A"}, + "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", +}).combine(split).pay() +``` + +**Transfer** — release held funds of an existing split payment. With no split +data it transfers everything (Transfer I); pass `marketplace`/`sellers` to +transfer a partial or re-specified split (Transfer II). + +```python +marketplaces.create_solution("marketplaces").transfer({ + "originalTransactionKey": "SPLIT_TRANSACTION_KEY", +}) +``` + +**RefundSupplementary** — refund the consumer and pull the funds back from the +target accounts. Combine it into the refund. Without seller data it reverts all +transfers (I); pass `sellers` to retrieve specific amounts per account (II). + +```python +supplementary = marketplaces.create_solution("marketplaces").refund_supplementary() + +payments.create_payment("ideal", { + "currency": "EUR", + "amount": 50.00, + "invoice": "INV0001", + "description": "Split refund INV0001", + "original_transaction_key": "SPLIT_TRANSACTION_KEY", + "refund_amount": 50.00, + "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", +}).combine(supplementary).refund() +``` + +**ManualTransfer** — move funds directly between two accounts. + +```python +marketplaces.create_solution("marketplaces").manual_transfer({ + "fromAccountId": "ACCOUNT_A", + "toAccountId": "ACCOUNT_B", + "fromDescription": "Deduction monthly fee", + "toDescription": "Monthly fee third party ABC", + "amount": 10.00, + "currency": "EUR", +}) +``` + +A runnable demo of all six request types is in +[`examples/marketplaces.py`](examples/marketplaces.py). + ### Contribute We really appreciate it when developers contribute to improve the Buckaroo plugins. diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index d586d8f..566728c 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -1,6 +1,13 @@ from abc import ABC, abstractmethod from typing import Dict, Any, Optional, List -from ..models.payment_request import PaymentRequest, ClientIP, Service, ServiceList, Parameter +from ..models.payment_request import ( + PaymentRequest, + ClientIP, + Service, + ServiceList, + Parameter, + CombinableService, +) from ..models.payment_response import PaymentResponse from ..services.service_parameter_validator import ServiceParameterValidator @@ -26,9 +33,26 @@ def __init__(self, client): self._services_selectable_by_client: Optional[str] = None self._client_ip: Optional[ClientIP] = None self._service_parameters: List[Parameter] = [] + self._combined_services: List[Service] = [] # Extra services merged in via combine() self._payload: Dict[str, Any] = {} # Store original payload self._validator = ServiceParameterValidator(self) + def combine(self, combinable: "CombinableService") -> "BaseBuilder": + """Merge a supplementary service into this transaction. + + Mirrors the ``combine`` pattern of the other Buckaroo SDKs: a + supplementary service (e.g. Marketplaces ``Split``) is built on its own, + then combined into a payment or refund so both ride in one request's + ``ServiceList``. The combined services are appended after this builder's + own service when the request is built. + + Builders are single-use: create a fresh one per ``create_payment`` / + ``create_solution`` call. Calling ``combine`` more than once accumulates + services rather than replacing them. + """ + self._combined_services.extend(combinable.services) + return self + def currency(self, currency: str) -> "BaseBuilder": """Set the currency for the payment.""" self._currency = currency @@ -349,8 +373,8 @@ def build( parameters=self._service_parameters if self._service_parameters else None, ) - # Create service list - service_list = ServiceList(services=[service]) + # Create service list, appending any services merged in via combine() + service_list = ServiceList(services=[service, *self._combined_services]) # Build payment request payment_request = PaymentRequest( diff --git a/buckaroo/builders/solutions/marketplaces_builder.py b/buckaroo/builders/solutions/marketplaces_builder.py new file mode 100644 index 0000000..02b6114 --- /dev/null +++ b/buckaroo/builders/solutions/marketplaces_builder.py @@ -0,0 +1,207 @@ +from typing import Any, Dict, Optional + +from .solution_builder import SolutionBuilder +from ...models.payment_request import CombinableService +from ...models.payment_response import PaymentResponse + + +class MarketplacesBuilder(SolutionBuilder): + """Builder for the Split Payments (``Marketplaces``) solution. + + Marketplaces lets a platform divide one customer payment across its own + funds account and one or more seller accounts, move held funds later, refund + from sellers, and move funds between accounts manually. + + ``split`` and ``refund_supplementary`` build a supplementary service that is + *combined* into a payment or refund (``builder.combine(...).pay()`` / + ``.refund()``), mirroring the PHP/Node SDKs. ``transfer`` and + ``manual_transfer`` are standalone data requests. + + The split spec is a single dict: + ``{"daysUntilTransfer": ..., "marketplace": {...}, "sellers": [{...}, ...]}`` + — ``marketplace`` becomes the funds-account group, each ``sellers`` entry a + ``Seller`` group with a unique GroupID. + """ + + # Grouped-parameter buckets shared by the split-defining actions. Keyed by + # the wire group type ("Marketplace"/"Seller") — the validator matches + # grouped params by group type, not by individual field name. + _SPLIT_GROUPS: Dict[str, Any] = { + "Marketplace": { + "type": dict, + "required": False, + "description": "Split group reserved for the Split Payments funds account", + }, + "Seller": { + "type": dict, + "required": False, + "description": "Split group reserved for a third-party (seller) account", + }, + } + + def get_service_name(self) -> str: + """Get the service name for Split Payments.""" + return "Marketplaces" + + def get_allowed_service_parameters(self, action: str = "Split") -> Dict[str, Any]: + """Get the allowed service parameters for Marketplaces based on action. + + ``marketplace`` and ``sellers`` are grouped-parameter buckets; + ``DaysUntilTransfer`` is a plain parameter allowed only on ``Split`` (a + Transfer is always immediate). + """ + if action.lower() == "split": + return { + "DaysUntilTransfer": { + "type": str, + "required": False, + "description": "Days (0-93) funds are held before transfer; 0 = immediate", + }, + **self._SPLIT_GROUPS, + } + + if action.lower() in ("transfer", "refundsupplementary"): + return dict(self._SPLIT_GROUPS) + + if action.lower() == "manualtransfer": + return { + "FromAccountId": { + "type": str, + "required": True, + "description": "Merchant GUID of the debited account (A)", + }, + "ToAccountId": { + "type": str, + "required": True, + "description": "Merchant GUID of the credited account (B)", + }, + "FromDescription": { + "type": str, + "required": True, + "description": "Description of the debit transaction in account A", + }, + "ToDescription": { + "type": str, + "required": True, + "description": "Description of the credit transaction in account B", + }, + } + + return {} + + def split(self, split: Dict[str, Any], validate: bool = True) -> CombinableService: + """Build a Split service to combine into a payment. + + Combine the result into the funding payment, e.g. + ``payments.create_payment("ideal", {...}).combine(mp).pay()``. + + Args: + split: Spec dict with ``daysUntilTransfer`` (0-93; "0" = immediate, + omit to hold until a later Transfer), ``marketplace`` and + ``sellers`` groups. + validate: Whether to validate and filter service parameters. + """ + self._apply_split(split) + service = self.build("Split", validate=validate).services.services[0] + return CombinableService(services=[service]) + + def refund_supplementary( + self, split: Optional[Dict[str, Any]] = None, validate: bool = True + ) -> CombinableService: + """Build a RefundSupplementary service to combine into a refund. + + Combine the result into the consumer refund, e.g. + ``payments.create_payment("ideal", {...}).combine(mp).refund()``. With no + spec it reverts all transfers; pass ``sellers``/``marketplace`` to + retrieve specific amounts per account. + + Args: + split: Optional spec dict with ``marketplace`` and ``sellers`` groups. + validate: Whether to validate and filter service parameters. + """ + self._apply_split(split or {}) + service = self.build("RefundSupplementary", validate=validate).services.services[0] + return CombinableService(services=[service]) + + def transfer(self, transfer: Dict[str, Any], validate: bool = True) -> PaymentResponse: + """Transfer held funds of an existing split payment (standalone). + + Args: + transfer: Spec dict with ``originalTransactionKey`` (required) and, + optionally, ``marketplace``/``sellers`` to re-specify the split + (partial transfer). ``DaysUntilTransfer`` does not apply. + validate: Whether to validate and filter service parameters. + + Raises: + ValueError: If ``originalTransactionKey`` is missing. + """ + original_transaction_key = transfer.get("originalTransactionKey") + if not original_transaction_key: + raise ValueError("Transfer requires an originalTransactionKey") + + self._apply_split(transfer) + request_data = self.build("Transfer", validate=validate).to_dict() + request_data["OriginalTransactionKey"] = original_transaction_key + request_data.pop("AmountDebit", None) + + return self._post_data_request(request_data) + + def manual_transfer( + self, manual_transfer: Dict[str, Any], validate: bool = True + ) -> PaymentResponse: + """Move funds directly between two accounts (standalone). + + Args: + manual_transfer: Spec dict with ``fromAccountId``, ``toAccountId``, + ``fromDescription``, ``toDescription``, ``amount`` and + ``currency`` (all required), and optional ``invoice``. + validate: Whether to validate and filter service parameters. + + Raises: + ValueError: If a required field is missing. + """ + fields = { + "FromAccountId": manual_transfer.get("fromAccountId"), + "ToAccountId": manual_transfer.get("toAccountId"), + "FromDescription": manual_transfer.get("fromDescription"), + "ToDescription": manual_transfer.get("toDescription"), + } + missing = [name for name, value in fields.items() if not value] + if manual_transfer.get("amount") is None: + missing.append("amount") + currency = manual_transfer.get("currency") or self._currency + if not currency: + missing.append("currency") + if missing: + raise ValueError(f"ManualTransfer requires: {', '.join(missing)}") + + for name, value in fields.items(): + self.add_parameter(name, value) + + request_data = self.build("ManualTransfer", validate=validate).to_dict() + request_data["Currency"] = currency + request_data["Amount"] = manual_transfer["amount"] + request_data.pop("AmountDebit", None) + + invoice = manual_transfer.get("invoice") or self._invoice + if invoice: + request_data["Invoice"] = invoice + else: + request_data.pop("Invoice", None) + + return self._post_data_request(request_data) + + def _apply_split(self, split: Dict[str, Any]) -> None: + """Map a split spec dict into grouped Marketplaces service parameters.""" + days_until_transfer = split.get("daysUntilTransfer") + if days_until_transfer is not None: + self.add_parameter("DaysUntilTransfer", days_until_transfer) + + marketplace = split.get("marketplace") + if marketplace: + for name, value in marketplace.items(): + self.add_parameter(name, value, "Marketplace") + + for index, seller in enumerate(split.get("sellers") or [], start=1): + for name, value in seller.items(): + self.add_parameter(name, value, "Seller", str(index)) diff --git a/buckaroo/factories/solution_method_factory.py b/buckaroo/factories/solution_method_factory.py index 8001a13..5cf81d2 100644 --- a/buckaroo/factories/solution_method_factory.py +++ b/buckaroo/factories/solution_method_factory.py @@ -4,6 +4,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.marketplaces_builder import MarketplacesBuilder from buckaroo.builders.solutions.default_builder import DefaultBuilder from buckaroo.builders.solutions.solution_builder import SolutionBuilder @@ -16,6 +17,7 @@ class SolutionMethodFactory(BuilderFactory): "subscription": SubscriptionBuilder, "emandate": EmandateBuilder, "emandateb2b": EmandateB2BBuilder, + "marketplaces": MarketplacesBuilder, } @classmethod diff --git a/buckaroo/models/payment_request.py b/buckaroo/models/payment_request.py index c63e0ac..42459e1 100644 --- a/buckaroo/models/payment_request.py +++ b/buckaroo/models/payment_request.py @@ -74,6 +74,19 @@ def add_parameter(self, parameter: Union[Dict[str, Any], Parameter]) -> "Service return self +@dataclass +class CombinableService: + """A built supplementary service to merge into another transaction. + + Returned by builders that produce a service meant to ride along with a + payment or refund (e.g. Marketplaces ``Split``). Passed to + :meth:`BaseBuilder.combine`, which appends its ``services`` to the host + request's ``ServiceList``. + """ + + services: List["Service"] + + @dataclass class ServiceList: """Model for list of services.""" diff --git a/examples/marketplaces.py b/examples/marketplaces.py new file mode 100644 index 0000000..12c5ad3 --- /dev/null +++ b/examples/marketplaces.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Demo of the Split Payments (``Marketplaces``) solution. + +Marketplaces lets a platform divide one customer payment across its own funds +account and one or more seller accounts, move held funds later, refund from +sellers, and move funds manually between accounts. Following the other Buckaroo +SDKs, ``split`` and ``refund_supplementary`` build a supplementary service that +is *combined* into a payment or refund; ``transfer`` and ``manual_transfer`` are +standalone. Gated on ``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. + +The account IDs, issuer, and transaction keys below are placeholders — replace +them with values from your own Split Payments setup to run against the sandbox. +""" + +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 + +# Placeholder identifiers — replace with your own Split Payments values. +ISSUER = "ABNANL2A" +SELLER_1 = "789C60F316D24B088ACD471" +SELLER_2 = "369C60F316D24B088ACD238" +FUNDS_ACCOUNT = "AAAAAAAAAAAAAAAAAAAAAAA" +SPLIT_TXN_KEY = "REPLACE_WITH_SPLIT_TRANSACTION_KEY" + + +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_split() -> None: + """Split: build the split, combine it into an iDEAL payment.""" + print("\n1. Split") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + marketplaces = app.solutions.create_solution("marketplaces").split( + { + "daysUntilTransfer": "2", + "marketplace": { + "Amount": "10.00", + "InvoiceNumber": "INV0000123", + "Description": "INV0001 Commission Platform", + }, + "sellers": [ + {"AccountId": SELLER_1, "Amount": "50.00", "Description": "Payout 1"}, + {"AccountId": SELLER_2, "Amount": "35.00", "Description": "Payout 2"}, + ], + } + ) + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 95.00, + "invoice": "INV0001", + "description": "Split order INV0001", + "service_parameters": {"issuer": ISSUER}, + "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", + }, + ) + .combine(marketplaces) + .pay() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_transfer_all() -> None: + """Transfer (I): release every held split of an existing split payment.""" + print("\n2. Transfer (I) — transfer all") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution("marketplaces").transfer( + {"originalTransactionKey": SPLIT_TXN_KEY} + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_transfer_partial() -> None: + """Transfer (II): re-specify the split (partial/different distribution).""" + print("\n3. Transfer (II) — partial / re-specified") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution("marketplaces").transfer( + { + "originalTransactionKey": SPLIT_TXN_KEY, + "marketplace": {"Amount": "10.00", "Description": "INV0001 Commission Platform"}, + "sellers": [{"AccountId": SELLER_1, "Amount": "50.00"}], + } + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_refund_supplementary_all() -> None: + """RefundSupplementary (I): refund the consumer and revert all transfers.""" + print("\n4. RefundSupplementary (I) — revert all") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + marketplaces = app.solutions.create_solution("marketplaces").refund_supplementary() + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 50.00, + "invoice": "INV0001", + "description": "Split refund INV0001", + "original_transaction_key": SPLIT_TXN_KEY, + "refund_amount": 50.00, + "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", + }, + ) + .combine(marketplaces) + .refund() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_refund_supplementary_partial() -> None: + """RefundSupplementary (II): retrieve specific amounts per seller.""" + print("\n5. RefundSupplementary (II) — per-seller") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + marketplaces = app.solutions.create_solution("marketplaces").refund_supplementary( + { + "sellers": [ + { + "AccountId": SELLER_1, + "Amount": "30.00", + "Description": "INV0001 Refund Beauty Products BV", + } + ] + } + ) + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 30.00, + "invoice": "INV0001", + "description": "Split refund INV0001", + "original_transaction_key": SPLIT_TXN_KEY, + "refund_amount": 30.00, + "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", + }, + ) + .combine(marketplaces) + .refund() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_manual_transfer() -> None: + """ManualTransfer: move funds directly between two accounts.""" + print("\n6. ManualTransfer") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": SELLER_1, + "toAccountId": FUNDS_ACCOUNT, + "fromDescription": "Deduction monthly fee", + "toDescription": "Monthly fee third party ABC", + "amount": 10.00, + "currency": "EUR", + "invoice": "INV0001", + } + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — SPLIT PAYMENTS (MARKETPLACES) DEMO") + print("=" * 60) + + demo_split() + demo_transfer_all() + demo_transfer_partial() + demo_refund_supplementary_all() + demo_refund_supplementary_partial() + demo_manual_transfer() + + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tests/feature/solutions/test_marketplaces.py b/tests/feature/solutions/test_marketplaces.py new file mode 100644 index 0000000..b418bb0 --- /dev/null +++ b/tests/feature/solutions/test_marketplaces.py @@ -0,0 +1,308 @@ +import pytest + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers +from tests.support.recording_mock import recorded_action, recorded_request + + +def _ideal_payload(**overrides): + """Base iDEAL transaction fields used to fund a split.""" + payload = { + "currency": "EUR", + "amount": 95.00, + "description": "Split order INV0001", + "invoice": "INV0001", + "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": {"issuer": "ABNANL2A"}, + } + payload.update(overrides) + return payload + + +def _split_response(): + """Buckaroo-shaped Split response carrying the SplitGuid identifiers.""" + return Helpers.success_response( + { + "Services": [ + { + "Name": "Marketplaces", + "Action": None, + "Parameters": [ + {"Name": "SplitGuid_Marketplace", "Value": "GUID-MARKETPLACE"}, + {"Name": "SplitGuid_Seller_1", "Value": "GUID-SELLER-1"}, + ], + }, + {"Name": "ideal", "Action": None, "Parameters": []}, + ], + "ServiceCode": "ideal", + } + ) + + +class TestMarketplacesSplit: + """Split rides on an iDEAL payment via combine().""" + + def test_marketplaces_is_available(self, buckaroo): + assert buckaroo.solutions.is_method_supported("marketplaces") + + def test_service_name_is_marketplaces(self, buckaroo): + assert ( + buckaroo.solutions.create_solution("marketplaces").get_service_name() == "Marketplaces" + ) + + def test_split_combines_marketplaces_into_ideal_pay(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _split_response()) + ) + + mp = buckaroo.solutions.create_solution("marketplaces").split( + { + "daysUntilTransfer": "2", + "marketplace": {"Amount": "10.00", "Description": "INV0001 Commission Platform"}, + "sellers": [ + {"AccountId": "789C60F316D24B088ACD471", "Amount": "50.00"}, + {"AccountId": "369C60F316D24B088ACD238", "Amount": "35.00"}, + ], + } + ) + response = buckaroo.payments.create_payment("ideal", _ideal_payload()).combine(mp).pay() + + body = recorded_request(mock_strategy) + services = body["Services"]["ServiceList"] + # Payment method first, combined Marketplaces service second. + assert [s["Name"] for s in services] == ["ideal", "Marketplaces"] + assert services[0]["Action"] == "Pay" + assert services[1]["Action"] == "Split" + assert body["Currency"] == "EUR" + assert body["AmountDebit"] == 95.00 + + ideal_params = {p["Name"]: p["Value"] for p in services[0]["Parameters"]} + assert ideal_params["Issuer"] == "ABNANL2A" + + split = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in services[1]["Parameters"] + } + assert split[("Daysuntiltransfer", "", "")] == "2" + assert split[("Amount", "Marketplace", "")] == "10.00" + assert split[("Accountid", "Seller", "1")] == "789C60F316D24B088ACD471" + assert split[("Accountid", "Seller", "2")] == "369C60F316D24B088ACD238" + + assert response.get_service_parameter("SplitGuid_Marketplace") == "GUID-MARKETPLACE" + assert response.get_service_parameter("SplitGuid_Seller_1") == "GUID-SELLER-1" + + +def _datarequest_response(action): + return Helpers.success_response({"Services": None, "ServiceCode": "Marketplaces"}) + + +class TestMarketplacesTransfer: + """Standalone Transfer posts a DataRequest.""" + + def test_transfer_all_posts_datarequest(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("Transfer") + ) + ) + + response = buckaroo.solutions.create_solution("marketplaces").transfer( + {"originalTransactionKey": "D3732474ED0"} + ) + + body = recorded_request(mock_strategy) + services = body["Services"]["ServiceList"] + assert body["OriginalTransactionKey"] == "D3732474ED0" + assert "AmountDebit" not in body + assert len(services) == 1 + assert services[0]["Name"] == "Marketplaces" + assert recorded_action(mock_strategy) == "Transfer" + assert "Parameters" not in services[0] + assert response.status.code.code == 190 + + def test_transfer_with_splits_posts_grouped_params(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("Transfer") + ) + ) + + buckaroo.solutions.create_solution("marketplaces").transfer( + { + "originalTransactionKey": "D3732474ED0", + "marketplace": {"Amount": "10.00", "Description": "Commission"}, + "sellers": [{"AccountId": "789C60F316D24B088ACD471", "Amount": "50.00"}], + } + ) + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + assert service["Action"] == "Transfer" + params = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in service["Parameters"] + } + assert params[("Amount", "Marketplace", "")] == "10.00" + assert params[("Accountid", "Seller", "1")] == "789C60F316D24B088ACD471" + # DaysUntilTransfer never applies to a Transfer. + assert not any(name == "Daysuntiltransfer" for name, _, _ in params) + + def test_transfer_requires_original_key(self, buckaroo): + with pytest.raises(ValueError): + buckaroo.solutions.create_solution("marketplaces").transfer({}) + + +def _refund_supplementary_response(): + return Helpers.success_response( + { + "Services": [{"Name": "ideal", "Action": None, "Parameters": []}], + "ServiceCode": "ideal", + "AmountCredit": 50.00, + "AmountDebit": None, + } + ) + + +class TestMarketplacesRefundSupplementary: + """RefundSupplementary rides on an iDEAL refund via combine().""" + + def test_refund_supplementary_all_combines_into_refund(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _refund_supplementary_response()) + ) + + mp = buckaroo.solutions.create_solution("marketplaces").refund_supplementary() + payload = Helpers.standard_payload( + invoice="INV0001", + original_transaction_key="D3737EDA42474ED0", + refund_amount=50.00, + ) + response = buckaroo.payments.create_payment("ideal", payload).combine(mp).refund() + + body = recorded_request(mock_strategy) + services = body["Services"]["ServiceList"] + assert [(s["Name"], s["Action"]) for s in services] == [ + ("ideal", "Refund"), + ("Marketplaces", "RefundSupplementary"), + ] + assert body["OriginalTransactionKey"] == "D3737EDA42474ED0" + assert body["AmountCredit"] == 50.00 + assert "Parameters" not in services[1] + assert response.status.code.code == 190 + + def test_refund_supplementary_with_sellers(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _refund_supplementary_response()) + ) + + mp = buckaroo.solutions.create_solution("marketplaces").refund_supplementary( + {"sellers": [{"AccountId": "789C60F316D24B088ACD471", "Amount": "30.00"}]} + ) + payload = Helpers.standard_payload( + invoice="INV0001", + original_transaction_key="D3737EDA42474ED0", + refund_amount=30.00, + ) + buckaroo.payments.create_payment("ideal", payload).combine(mp).refund() + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][1] + assert service["Action"] == "RefundSupplementary" + params = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in service["Parameters"] + } + assert params[("Accountid", "Seller", "1")] == "789C60F316D24B088ACD471" + assert params[("Amount", "Seller", "1")] == "30.00" + + +class TestMarketplacesManualTransfer: + """Standalone ManualTransfer posts a DataRequest.""" + + def test_manual_transfer_posts_accounts_and_amount(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("ManualTransfer") + ) + ) + + response = buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAAAAAAAAAAAAAAAAAA", + "toAccountId": "BBBBBBBBBBBBBBBBBBB", + "fromDescription": "Deduction monthly fee", + "toDescription": "Monthly fee third party ABC", + "amount": 10.00, + "currency": "EUR", + "invoice": "INV0001", + } + ) + + body = recorded_request(mock_strategy) + service = body["Services"]["ServiceList"][0] + assert service["Name"] == "Marketplaces" + assert service["Action"] == "ManualTransfer" + assert body["Currency"] == "EUR" + assert body["Amount"] == 10.00 + assert "AmountDebit" not in body + + params = {p["Name"]: p["Value"] for p in service["Parameters"]} + assert params["Fromaccountid"] == "AAAAAAAAAAAAAAAAAAA" + assert params["Toaccountid"] == "BBBBBBBBBBBBBBBBBBB" + assert params["Fromdescription"] == "Deduction monthly fee" + assert params["Todescription"] == "Monthly fee third party ABC" + assert response.status.code.code == 190 + + def test_manual_transfer_requires_all_fields(self, buckaroo): + with pytest.raises(ValueError): + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "", + "fromDescription": "fee", + "toDescription": "fee", + "amount": 10.00, + } + ) + + def test_manual_transfer_omits_invoice_when_absent(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("ManualTransfer") + ) + ) + + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "BBB", + "fromDescription": "fee", + "toDescription": "fee", + "amount": 10.00, + "currency": "EUR", + } + ) + + assert "Invoice" not in recorded_request(mock_strategy) + + def test_manual_transfer_requires_amount(self, buckaroo): + with pytest.raises(ValueError, match="amount"): + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "BBB", + "fromDescription": "fee", + "toDescription": "fee", + "currency": "EUR", + } + ) + + def test_manual_transfer_requires_currency(self, buckaroo): + with pytest.raises(ValueError, match="currency"): + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "BBB", + "fromDescription": "fee", + "toDescription": "fee", + "amount": 10.00, + } + ) diff --git a/tests/unit/builders/solutions/test_concrete_solutions_contract.py b/tests/unit/builders/solutions/test_concrete_solutions_contract.py index e66135f..40a8341 100644 --- a/tests/unit/builders/solutions/test_concrete_solutions_contract.py +++ b/tests/unit/builders/solutions/test_concrete_solutions_contract.py @@ -30,6 +30,7 @@ "subscription": "CreateSubscription", "emandate": "GetIssuerList", "emandateb2b": "GetIssuerList", + "marketplaces": "Split", } diff --git a/tests/unit/builders/solutions/test_marketplaces_builder.py b/tests/unit/builders/solutions/test_marketplaces_builder.py new file mode 100644 index 0000000..1c01e60 --- /dev/null +++ b/tests/unit/builders/solutions/test_marketplaces_builder.py @@ -0,0 +1,65 @@ +"""Unit tests for :class:`MarketplacesBuilder`.""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.solutions.marketplaces_builder import MarketplacesBuilder +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from buckaroo.factories.solution_method_factory import SolutionMethodFactory +from buckaroo.models.payment_request import CombinableService + + +@pytest.fixture +def client(): + return object() + + +def test_is_a_solution_builder(client): + assert isinstance(MarketplacesBuilder(client), SolutionBuilder) + + +def test_service_name_is_marketplaces(client): + assert MarketplacesBuilder(client).get_service_name() == "Marketplaces" + + +def test_split_action_allows_grouped_and_days_parameters(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("Split") + assert set(allowed) == {"DaysUntilTransfer", "Marketplace", "Seller"} + + +def test_transfer_action_allows_only_split_groups(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("Transfer") + assert set(allowed) == {"Marketplace", "Seller"} + + +def test_refund_supplementary_action_allows_only_split_groups(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("RefundSupplementary") + assert set(allowed) == {"Marketplace", "Seller"} + + +def test_manual_transfer_action_allows_account_and_description_params(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("ManualTransfer") + assert set(allowed) == { + "FromAccountId", + "ToAccountId", + "FromDescription", + "ToDescription", + } + + +def test_unknown_action_allows_no_parameters(client): + assert MarketplacesBuilder(client).get_allowed_service_parameters("Bogus") == {} + + +def test_split_returns_combinable_service(client): + mp = MarketplacesBuilder(client).split( + {"marketplace": {"Amount": "10.00"}, "sellers": [{"AccountId": "S1", "Amount": "85.00"}]} + ) + assert isinstance(mp, CombinableService) + assert [s.name for s in mp.services] == ["Marketplaces"] + assert mp.services[0].action == "Split" + + +def test_registered_under_marketplaces_key(): + assert SolutionMethodFactory._solution_methods["marketplaces"] is MarketplacesBuilder From 3f3ab9ea888bf4d6ccd73a6ba3fac9749e68b8a6 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Thu, 16 Jul 2026 15:42:44 +0800 Subject: [PATCH 21/21] feat(BTI-977): add Point of Sale (POS) payment method Introduces support for in-store PIN-based payments processed via physical terminals. This change adds a `PosBuilder` for initiating POS transactions, including specific handling for its unique characteristics: no redirect flow, immediate pending response, and delivery of the final result (with printable receipt) via push notification. A new `Channel` field is added to `BaseBuilder` and `PaymentRequest` to accommodate the fixed "Web" channel required for POS payments, which is enforced internally by the `PosBuilder`. --- README.md | 37 +++ buckaroo/builders/base_builder.py | 16 ++ buckaroo/builders/payments/pos_builder.py | 81 ++++++ buckaroo/factories/payment_method_factory.py | 2 + buckaroo/models/payment_request.py | 3 + examples/pos_payment.py | 163 ++++++++++++ tests/feature/payments/test_pos.py | 119 +++++++++ .../builders/payments/test_pos_builder.py | 242 ++++++++++++++++++ 8 files changed, 663 insertions(+) create mode 100644 buckaroo/builders/payments/pos_builder.py create mode 100644 examples/pos_payment.py create mode 100644 tests/feature/payments/test_pos.py create mode 100644 tests/unit/builders/payments/test_pos_builder.py diff --git a/README.md b/README.md index a238e30..35c612e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - [iDIN](#idin) - [Instant Refunds](#instant-refunds) - [eMandate](#emandate) +- [Point of Sale (POS)](#point-of-sale-pos) - [Contribute](#contribute) - [Versioning](#versioning) - [Additional information](#additional-information) @@ -188,6 +189,42 @@ The B2B variant exposes the same five methods — swap `"emandate"` for `"emanda See [`examples/emandate.py`](examples/emandate.py) for a runnable demo of all five actions against both the B2C and B2B services. +### Point of Sale (POS) + +POS transactions are PIN-based in-store payments processed through a physical payment terminal. +You initiate the transaction via API with the terminal's unique `TerminalID`; Buckaroo routes the +request to that terminal, which prompts the customer to complete payment there. There's no +redirect flow, every request is sent with a fixed `Channel: "Web"`, set internally by the SDK. + +The immediate response carries a pending/awaiting status. The final result, plus the printable +`Ticket` receipt text for the customer, arrives later via push notification. + +```python +response = payments.create_payment("pospayment", { + "currency": "EUR", + "amount": 0.01, + "invoice": "TestFactuur01", +}).terminal_id("50000001").pay() + +print("key:", response.key) +print("pending:", response.is_pending()) +``` + +Parsing the push notification once the terminal completes the transaction push bodies wrap the +transaction under a `Transaction` key, so unwrap it before handing it to `PaymentResponse`: + +```python +from buckaroo.models.payment_response import PaymentResponse + +transaction = push_json["Transaction"] # raw body your webhook endpoint received +response = PaymentResponse({"data": transaction}) + +ticket = response.get_service_parameter("Ticket") # printable receipt text +``` + +See [`examples/pos_payment.py`](examples/pos_payment.py) for a runnable demo of both the `Pay` +action and push-notification parsing. + ### Contribute We really appreciate it when developers contribute to improve the Buckaroo plugins. diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index 7b0e5dc..96d383e 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -29,6 +29,7 @@ def __init__(self, client, executor: Optional[ITransactionExecutor] = None) -> N self._amount_debit: Optional[float] = None self._description: Optional[str] = None self._invoice: Optional[str] = None + self._channel: Optional[str] = None self._return_url: Optional[str] = None self._return_url_cancel: Optional[str] = None self._return_url_error: Optional[str] = None @@ -88,6 +89,16 @@ def continue_on_incomplete(self, continue_incomplete: str) -> Self: self._continue_on_incomplete = continue_incomplete return self + def channel(self, channel: str) -> Self: + """Set the Channel for the payment (e.g. ``"Web"``). + + Sent as the top-level ``Channel`` request field. Most payment + methods leave this unset; some (e.g. POS) require a fixed value and + set it internally. + """ + self._channel = channel + 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 @@ -208,6 +219,7 @@ def from_dict(self, data: Dict[str, Any]) -> Self: - amount: Payment amount (float) - description: Payment description (str) - invoice: Invoice number (str) + - channel: Channel for the payment (str, e.g. 'Web') - return_url: Success return URL (str) - return_url_cancel: Cancel return URL (str) - return_url_error: Error return URL (str) @@ -229,6 +241,9 @@ def from_dict(self, data: Dict[str, Any]) -> Self: if "invoice" in data: self.invoice(data["invoice"]) + if "channel" in data: + self.channel(data["channel"]) + if "return_url" in data: self.return_url(data["return_url"]) @@ -370,6 +385,7 @@ def build(self, action: str = "Pay", validate: bool = True, strict_validation: b amount_debit=self._amount_debit, description=self._description, invoice=self._invoice, + channel=self._channel, return_url=self._return_url, return_url_cancel=self._return_url_cancel, return_url_error=self._return_url_error, diff --git a/buckaroo/builders/payments/pos_builder.py b/buckaroo/builders/payments/pos_builder.py new file mode 100644 index 0000000..8d2dfdf --- /dev/null +++ b/buckaroo/builders/payments/pos_builder.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import Any, Dict + +try: + from typing import Self +except ImportError: # Python < 3.11 + from typing_extensions import Self + +from ...models.payment_request import Parameter, PaymentRequest +from .payment_builder import PaymentBuilder + + +class PosBuilder(PaymentBuilder): + """Builder for Point of Sale (POS) payments. + + POS transactions are PIN-based in-store payments processed through a + physical payment terminal. The merchant initiates the transaction via + API with the terminal's unique ``TerminalID``; Buckaroo routes the + request to that terminal, which prompts the customer to complete + payment there. + + The immediate response carries a pending/awaiting status — the final + result, plus the printable ``Ticket`` receipt data, arrives later via + push notification once the customer completes payment at the terminal. + + Every POS request is sent with ``Channel: "Web"`` per the Buckaroo POS + specification; this is fixed internally and not user-configurable. + Supports the ``Pay`` action only. + """ + + def required_fields(self, action: str = "Pay") -> Dict[str, Any]: + """POS has no redirect flow — only currency, amount and invoice are required.""" + return { + "currency": self._currency, + "amount_debit": self._amount_debit, + "invoice": self._invoice, + } + + def get_service_name(self) -> str: + """Get the service name for POS payments.""" + return "pospayment" + + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: + """Get the allowed service parameters for POS payments based on action.""" + if action.lower() == "pay": + return { + "TerminalID": { + "type": str, + "required": True, + "description": "Unique identifier of the physical POS terminal", + }, + } + + return {} + + def terminal_id(self, terminal_id: str) -> Self: + """Set the unique Terminal ID of the physical POS terminal. + + Buckaroo routes the transaction to this terminal, which then + prompts the customer to complete payment. + + Raises: + ValueError: If ``terminal_id`` is empty or blank. + """ + if not terminal_id or not str(terminal_id).strip(): + raise ValueError("terminal_id must be a non-empty value") + + self._service_parameters.append(Parameter(name="TerminalID", value=str(terminal_id))) + return self + + def build( + self, action: str = "Pay", validate: bool = True, strict_validation: bool = False + ) -> PaymentRequest: + """Build the request, forcing ``Channel`` to ``"Web"``. + + Every POS transaction must use the Web channel regardless of any + value set via the generic :meth:`channel` setter. + """ + self._channel = "Web" + return super().build(action, validate=validate, strict_validation=strict_validation) diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index da47aaa..23285a9 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -44,6 +44,7 @@ from buckaroo.builders.payments.paypal_builder import PaypalBuilder from buckaroo.builders.payments.paybybank_builder import PayByBankBuilder from buckaroo.builders.payments.payperemail_builder import PayPerEmailBuilder +from buckaroo.builders.payments.pos_builder import PosBuilder class PaymentMethodFactory(BuilderFactory): @@ -81,6 +82,7 @@ class PaymentMethodFactory(BuilderFactory): "paypal": PaypalBuilder, "paybybank": PayByBankBuilder, "payperemail": PayPerEmailBuilder, + "pospayment": PosBuilder, "przelewy24": Przelewy24Builder, "riverty": RivertyBuilder, "sepadirectdebit": SepaDirectDebitBuilder, diff --git a/buckaroo/models/payment_request.py b/buckaroo/models/payment_request.py index 5e21835..1ce69d7 100644 --- a/buckaroo/models/payment_request.py +++ b/buckaroo/models/payment_request.py @@ -109,6 +109,7 @@ class PaymentRequest: amount_debit: Optional[float] = None description: Optional[str] = None invoice: Optional[str] = None + channel: Optional[str] = None return_url: Optional[str] = None return_url_cancel: Optional[str] = None return_url_error: Optional[str] = None @@ -137,6 +138,8 @@ def to_dict(self) -> Dict[str, Any]: request_dict["Description"] = self.description if self.invoice is not None: request_dict["Invoice"] = self.invoice + if self.channel is not None: + request_dict["Channel"] = self.channel if self.return_url is not None: request_dict["ReturnURL"] = self.return_url if self.return_url_cancel is not None: diff --git a/examples/pos_payment.py b/examples/pos_payment.py new file mode 100644 index 0000000..73ad3db --- /dev/null +++ b/examples/pos_payment.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Point of Sale (POS) payment examples. + +POS transactions are PIN-based in-store payments processed through a +physical payment terminal. You initiate the transaction via API with the +terminal's unique ``TerminalID``; Buckaroo routes the request to that +terminal, which prompts the customer to complete payment there. Every POS +request is sent with a fixed ``Channel: "Web"`` — the SDK sets this +internally, it is not configurable. + +The immediate response carries a pending/awaiting status. The final result, +plus the printable ``Ticket`` receipt text for the customer, arrives later +via push notification — this demo also shows how to parse that push body. + +Covers: + - Pay (initiate a transaction at a terminal) + - Parsing a push notification, including the printable Ticket data + +Run: + BUCKAROO_STORE_KEY=... BUCKAROO_SECRET_KEY=... python examples/pos_payment.py +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo +from buckaroo.models.payment_response import BuckarooStatusCode, PaymentResponse + +app = Buckaroo.quick_setup( + store_key=os.environ["BUCKAROO_STORE_KEY"], + secret_key=os.environ["BUCKAROO_SECRET_KEY"], + mode="test", +) + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def print_response(label, response): + print(f"\n{'─' * 55}") + print(f" {label}") + print(f"{'─' * 55}") + print(f" HTTP status : {response.status_code}") + print(f" Success : {response.success}") + if response.status: + print(f" Buckaroo code : {response.status.code.code} — {response.status.code.description}") + print(f" Pending : {response.is_pending()}") + if response.transaction_key: + print(f" Txn key : {response.transaction_key}") + + +def base_builder(): + """Return a pre-filled POS builder ready to customise. + + No return URLs are needed — POS has no redirect flow, the customer pays + directly at the terminal. + """ + return ( + app.payments.create_payment("pospayment") + .currency("EUR") + .amount(0.01) + .invoice("TestFactuur01") + ) + + +# ── 1. Pay — initiate a transaction at a terminal ──────────────────────────── + +def example_pay(terminal_id: str = "50000001"): + """ + Initiate a POS transaction through the given terminal. + + Buckaroo routes the request to the terminal, which prompts the customer + for their PIN. The response here is only the initial acknowledgement — + the actual payment result arrives later via push (see + ``example_parse_push`` below). + """ + response = base_builder().terminal_id(terminal_id).pay() + print_response("Pay — POS terminal transaction", response) + return response + + +# ── 2. Parse a push notification, including the printable Ticket ──────────── + +# Buckaroo POSTs this JSON to your configured push URL once the terminal +# transaction completes. In a real application this is the raw body your +# webhook endpoint receives — this constant just stands in for that so the +# demo is runnable without a live terminal. +_EXAMPLE_PUSH_BODY = { + "Transaction": { + "Key": "8D2EAFC8F76D40FEBA204AFB53Fxxxxx", + "Invoice": "TestFactuur01", + "ServiceCode": "pospayment", + "Status": { + "Code": {"Code": 190, "Description": "Success"}, + "SubCode": {"Code": "S001", "Description": "Transaction successfully processed"}, + "DateTime": "2020-02-18T15:00:11", + }, + "Currency": "EUR", + "AmountDebit": 0.01, + "Services": [ + { + "Name": "PosPayment", + "Parameters": [ + { + "Name": "Ticket", + "Value": ( + "[0]POI: 50000001\r\n" + "[0]KLANTTICKET\r\n" + "[0]------------------------------------------------\r\n" + ), + } + ], + } + ], + } +} + + +def example_parse_push(push_body: dict = _EXAMPLE_PUSH_BODY): + """ + Parse an incoming POS push notification. + + Push bodies wrap the transaction under a top-level ``Transaction`` key — + unwrap it, then feed it to :class:`PaymentResponse` the same way the SDK + does for a direct API response (under a ``data`` key). + + Note: ``is_successful()`` relies on a convenience flag that only + ``BuckarooResponse`` (the HTTP-layer wrapper) sets, so it doesn't apply + to a ``PaymentResponse`` built directly from a push body — compare the + status code instead, as below. + """ + transaction = push_body["Transaction"] + response = PaymentResponse({"data": transaction}) + successful = bool(response.status) and response.status.code.code == BuckarooStatusCode.SUCCESS + + print(f"\n{'─' * 55}") + print(" Push notification — POS transaction complete") + print(f"{'─' * 55}") + print(f" Txn key : {response.key}") + print(f" Status : {response.status.code.code} — {response.status.code.description}") + print(f" Successful : {successful}") + + ticket = response.get_service_parameter("Ticket") + if ticket: + print(" Ticket (printable receipt):") + print(" " + "-" * 40) + for line in ticket.splitlines(): + print(f" {line}") + print(" " + "-" * 40) + + return response + + +# ── main ────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print("POS (POINT OF SALE) PAYMENT EXAMPLES") + print("=" * 55) + + example_pay() + example_parse_push() diff --git a/tests/feature/payments/test_pos.py b/tests/feature/payments/test_pos.py new file mode 100644 index 0000000..b677d6c --- /dev/null +++ b/tests/feature/payments/test_pos.py @@ -0,0 +1,119 @@ +"""Feature test: POS (Point of Sale) pay round-trip through the full stack. + +POS is a PIN-based in-store payment routed to a physical terminal via +``TerminalID``. The initial response is a pending/awaiting status — the +final result (plus the printable ``Ticket`` receipt) arrives later via push. +This pins the ``Pay`` action reaching the wire with the fixed ``Channel: +"Web"`` field and the ``TerminalID`` service parameter, and that the mock's +pending response round-trips through ``PaymentResponse``. + +``terminal_id()`` is used (not the generic ``service_parameters`` dict) so +the ``TerminalID`` name reaches the wire with the exact casing Buckaroo +documents — ``add_parameter``'s generic ``.capitalize()`` would otherwise +mangle it to ``Terminalid``. +""" + +import pytest + +from buckaroo.exceptions._parameter_validation_error import RequiredParameterMissingError +from tests.support.helpers import Helpers +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_service_parameters + + +_TERMINAL_ID = "50000001" + + +def _pos_payload(**overrides): + payload = { + "currency": "EUR", + "amount": 0.01, + "invoice": "TestFactuur01", + } + payload.update(overrides) + return payload + + +class TestPosFeature: + def test_pay_sends_channel_web_and_terminal_id(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "pospayment", + "Action": "Pay", + "Parameters": [{"Name": "TerminalID", "Value": _TERMINAL_ID}], + } + ], + "ServiceCode": "pospayment", + "Invoice": "TestFactuur01", + "TransactionType": "V735", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + + response = ( + buckaroo.payments.create_payment("pospayment", _pos_payload()) + .terminal_id(_TERMINAL_ID) + .pay() + ) + + assert response.key == response_body["Key"] + assert response.status.code.code == 190 + assert response.service_code == "pospayment" + + assert recorded_action(mock_strategy) == "Pay" + + sent = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert sent["TerminalID"] == _TERMINAL_ID + + def test_pay_returns_pending_until_terminal_confirms(self, buckaroo, mock_strategy): + """Initial response can carry an awaiting-consumer status; final result + and the printable Ticket only arrive later via push.""" + response_body = Helpers.success_response( + { + "Status": { + "Code": {"Code": 792, "Description": "Waiting on customer input"}, + }, + "Services": [], + "ServiceCode": "pospayment", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + + response = ( + buckaroo.payments.create_payment("pospayment", _pos_payload()) + .terminal_id(_TERMINAL_ID) + .pay() + ) + + assert response.is_pending() is True + + def test_pos_case_insensitive_lookup(self, buckaroo, mock_strategy): + response_body = Helpers.success_response({"ServiceCode": "pospayment"}) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + + response = ( + buckaroo.payments.create_payment( + "POSPAYMENT", _pos_payload(invoice="INV-POS-CASE") + ) + .terminal_id(_TERMINAL_ID) + .pay() + ) + + assert response.status.code.code == 190 + + def test_missing_terminal_id_raises(self, buckaroo, mock_strategy): + builder = buckaroo.payments.create_payment("pospayment", _pos_payload()) + + with pytest.raises(RequiredParameterMissingError): + builder.pay() + + def test_blank_terminal_id_raises(self, buckaroo): + builder = buckaroo.payments.create_payment("pospayment", _pos_payload()) + + with pytest.raises(ValueError, match="non-empty"): + builder.terminal_id("") + + def test_pospayment_is_available(self, buckaroo): + assert buckaroo.payments.is_method_supported("pospayment") diff --git a/tests/unit/builders/payments/test_pos_builder.py b/tests/unit/builders/payments/test_pos_builder.py new file mode 100644 index 0000000..540183e --- /dev/null +++ b/tests/unit/builders/payments/test_pos_builder.py @@ -0,0 +1,242 @@ +"""Unit coverage for :class:`PosBuilder`. + +Point of Sale (POS) transactions are PIN-based in-store payments routed to a +physical terminal via ``TerminalID``. POS carries no redirect flow (no +``return_url`` family) and every request must be sent with a fixed +``Channel: "Web"`` regardless of anything the caller does — these tests pin +both invariants plus the ``TerminalID`` required-parameter contract. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.pos_builder import PosBuilder +from buckaroo.exceptions._parameter_validation_error import RequiredParameterMissingError +from buckaroo.factories.payment_method_factory import PaymentMethodFactory +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 + + +@pytest.fixture +def builder(client: BuckarooClient) -> PosBuilder: + return PosBuilder(client) + + +def _pos_ready(builder: PosBuilder, **overrides) -> PosBuilder: + """Populate the fields POS actually requires — no return_url family.""" + fields = { + "currency": "EUR", + "amount": 0.01, + "invoice": "TestFactuur01", + "terminal_id": "50000001", + } + fields.update(overrides) + return ( + builder.currency(fields["currency"]) + .amount(fields["amount"]) + .invoice(fields["invoice"]) + .terminal_id(fields["terminal_id"]) + ) + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_wires_client(builder: PosBuilder, client: BuckarooClient) -> None: + assert isinstance(builder, PosBuilder) + assert isinstance(builder, PaymentBuilder) + assert builder._client is client + + +def test_create_builder_returns_pos_builder(client: BuckarooClient) -> None: + builder = PaymentMethodFactory.create_builder("pospayment", client) + assert isinstance(builder, PosBuilder) + + +def test_create_builder_is_case_insensitive(client: BuckarooClient) -> None: + builder = PaymentMethodFactory.create_builder("POSPAYMENT", client) + assert isinstance(builder, PosBuilder) + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_pospayment(builder: PosBuilder) -> None: + assert builder.get_service_name() == "pospayment" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — TerminalID required on Pay only + + +def test_get_allowed_service_parameters_pay_requires_terminal_id(builder: PosBuilder) -> None: + assert builder.get_allowed_service_parameters("Pay") == { + "TerminalID": { + "type": str, + "required": True, + "description": "Unique identifier of the physical POS terminal", + }, + } + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(builder: PosBuilder) -> None: + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty( + builder: PosBuilder, action: str +) -> None: + assert builder.get_allowed_service_parameters(action) == {} + + +# --------------------------------------------------------------------------- +# required_fields — no return_url family, POS is in-store only + + +def test_required_fields_has_no_return_url_family(builder: PosBuilder) -> None: + builder.currency("EUR").amount(0.01).invoice("TestFactuur01") + assert builder.required_fields("Pay") == { + "currency": "EUR", + "amount_debit": 0.01, + "invoice": "TestFactuur01", + } + + +def test_pay_without_currency_amount_or_invoice_raises(builder: PosBuilder) -> None: + with pytest.raises(ValueError, match="Missing required fields"): + builder.terminal_id("50000001").pay() + + +# --------------------------------------------------------------------------- +# terminal_id() — valid and invalid input + + +def test_terminal_id_appends_exact_case_parameter(builder: PosBuilder) -> None: + _pos_ready(builder) + request = builder.build("Pay", validate=False) + params = request.to_dict()["Services"]["ServiceList"][0]["Parameters"] + assert params == [{"Name": "TerminalID", "GroupType": "", "GroupID": "", "Value": "50000001"}] + + +@pytest.mark.parametrize("invalid_terminal_id", ["", " ", None]) +def test_terminal_id_rejects_blank_values(builder: PosBuilder, invalid_terminal_id) -> None: + with pytest.raises(ValueError, match="non-empty"): + builder.terminal_id(invalid_terminal_id) + + +def test_missing_terminal_id_raises_required_parameter_missing(builder: PosBuilder) -> None: + builder.currency("EUR").amount(0.01).invoice("TestFactuur01") + with pytest.raises(RequiredParameterMissingError): + builder.pay() + + +# --------------------------------------------------------------------------- +# Channel is always forced to "Web" + + +def test_build_forces_channel_to_web(builder: PosBuilder) -> None: + _pos_ready(builder) + request = builder.build("Pay", validate=False) + assert request.to_dict()["Channel"] == "Web" + + +def test_build_overrides_any_previously_set_channel(builder: PosBuilder) -> None: + _pos_ready(builder).channel("Backoffice") + request = builder.build("Pay", validate=False) + assert request.to_dict()["Channel"] == "Web" + + +def test_pay_sends_channel_web_on_the_wire() -> None: + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + + _pos_ready(PosBuilder(client)).pay(validate=False) + + assert recorded_request(mock)["Channel"] == "Web" + + +def test_other_payment_builders_do_not_send_channel(client: BuckarooClient) -> None: + """Channel is POS-specific; unrelated builders must not pick it up.""" + from buckaroo.builders.payments.swish_builder import SwishBuilder + + builder = ( + SwishBuilder(client) + .currency("EUR") + .amount(10.0) + .description("desc") + .invoice("INV-1") + .return_url("https://x/ok") + .return_url_cancel("https://x/cancel") + .return_url_error("https://x/error") + .return_url_reject("https://x/reject") + ) + request = builder.build("Pay", validate=False) + assert "Channel" not in request.to_dict() + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_transaction_and_parses_response( + builder: PosBuilder, mock_strategy: MockBuckaroo +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + { + "Key": "A5417E98043647D095B857E16C00000", + "Status": {"Code": {"Code": 190, "Description": "Success"}}, + "ServiceCode": "pospayment", + "Invoice": "TestFactuur01", + "Currency": "EUR", + "AmountDebit": 0.01, + "TransactionType": "V735", + }, + ) + ) + + response = _pos_ready(builder).pay() + + assert response.key == "A5417E98043647D095B857E16C00000" + assert response.status.code.code == 190 + assert response.service_code == "pospayment" + + +def test_pay_posts_service_name_and_action(client: BuckarooClient) -> None: + mock, wired_client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + + _pos_ready(PosBuilder(wired_client)).pay(validate=False) + + service = recorded_request(mock)["Services"]["ServiceList"][0] + assert service["Name"] == "pospayment" + assert service["Action"] == "Pay" + + +# --------------------------------------------------------------------------- +# Capability-only methods — POS mixes in nothing + + +def test_does_not_expose_capability_only_methods(builder: PosBuilder) -> None: + for method in ( + "authorize", + "authorizeEncrypted", + "cancelAuthorize", + "payEncrypted", + "instantRefund", + "payFastCheckout", + ): + assert not hasattr(builder, method), ( + f"PosBuilder unexpectedly exposes capability method {method!r}" + )