From a56994f70139d3a500417f03a2f0573deada62fd Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Apr 2026 10:55:58 +0800 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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 b1fc42053daa804fa89c9e6d767735ff83f93dfb Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Jul 2026 17:19:08 +0800 Subject: [PATCH 06/10] 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 07/10] ``` 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 3f3ab9ea888bf4d6ccd73a6ba3fac9749e68b8a6 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Thu, 16 Jul 2026 15:42:44 +0800 Subject: [PATCH 08/10] 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}" + ) From 60900206a369e08d08252259d28bd1e2e1a9f69c Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Thu, 16 Jul 2026 16:15:31 +0800 Subject: [PATCH 09/10] refactor(BTI-977): Remove unused CombinableService import --- buckaroo/builders/base_builder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index 0d2133f..2fadece 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -6,7 +6,6 @@ Service, ServiceList, Parameter, - CombinableService, ) try: from typing import Self From cdbb2cb184178e08a848057361e82aab8d2991af Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Thu, 16 Jul 2026 16:18:34 +0800 Subject: [PATCH 10/10] feat(BTI-977): add method to merge supplementary services Introduces a 'combine' method to BaseBuilder, allowing CombinableServices (e.g., Marketplace Splits) to append their services to the primary payment request. This simplifies the creation of complex payment structures. --- buckaroo/builders/base_builder.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index 2fadece..d1d7c36 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -6,6 +6,7 @@ Service, ServiceList, Parameter, + CombinableService, ) try: from typing import Self @@ -106,6 +107,16 @@ def channel(self, channel: str) -> Self: self._channel = channel return self + def combine(self, combinable: CombinableService) -> Self: + """Merge a supplementary service (e.g. a Marketplaces Split) into this request. + + The combined service's ``services`` are appended to the host + request's ``ServiceList`` when ``pay()``/``refund()`` builds the + payload, e.g. ``payments.create_payment("ideal", {...}).combine(mp).pay()``. + """ + self._combined_services.extend(combinable.services) + 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