diff --git a/.gitignore b/.gitignore index 26648db..5aed803 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,16 @@ htmlcov/ .DS_Store __pycache__/ *.pyc +__pycache__/ +*.pyo + +# local environment .env +.venv/ +venv/ +# logs *.log + +# type checking +.mypy_cache/ diff --git a/README.md b/README.md index 992dd55..11438be 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - [Instant Refunds](#instant-refunds) - [eMandate](#emandate) - [Split Payments](#split-payments) +- [Point of Sale (POS)](#point-of-sale-pos) - [Contribute](#contribute) - [Versioning](#versioning) - [Additional information](#additional-information) @@ -281,6 +282,42 @@ marketplaces.create_solution("marketplaces").manual_transfer({ A runnable demo of all six request types is in [`examples/marketplaces.py`](examples/marketplaces.py). +### 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/_buckaroo_client.py b/buckaroo/_buckaroo_client.py index 4d41051..4118c46 100644 --- a/buckaroo/_buckaroo_client.py +++ b/buckaroo/_buckaroo_client.py @@ -1,3 +1,5 @@ + +import logging from typing import Optional from .exceptions._authentication_error import AuthenticationError from .config.buckaroo_config import BuckarooConfig, create_config_from_mode @@ -118,7 +120,13 @@ def confirm_credential(self) -> bool: try: response = self.http_client.get("/json/Transaction/Specification/ideal") return response.success - except Exception: + 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/app.py b/buckaroo/app.py index 3ab57c2..1a40b94 100644 --- a/buckaroo/app.py +++ b/buckaroo/app.py @@ -43,7 +43,7 @@ class BuckarooConfig: retry_attempts: int = 3 @classmethod - def from_env(cls) -> "BuckarooConfig": + 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() @@ -240,3 +240,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 566728c..d1d7c36 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -8,20 +8,35 @@ Parameter, CombinableService, ) +try: + from typing import Self +except ImportError: + from typing_extensions import Self 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 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) -> 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 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 @@ -37,67 +52,71 @@ def __init__(self, client): self._payload: Dict[str, Any] = {} # Store original payload self._validator = ServiceParameterValidator(self) - def combine(self, combinable: "CombinableService") -> "BaseBuilder": - """Merge a supplementary service into this transaction. - - Mirrors the ``combine`` pattern of the other Buckaroo SDKs: a - supplementary service (e.g. Marketplaces ``Split``) is built on its own, - then combined into a payment or refund so both ride in one request's - ``ServiceList``. The combined services are appended after this builder's - own service when the request is built. - - Builders are single-use: create a fresh one per ``create_payment`` / - ``create_solution`` call. Calling ``combine`` more than once accumulates - services rather than replacing them. - """ - self._combined_services.extend(combinable.services) - return self - def currency(self, currency: str) -> "BaseBuilder": """Set the currency for the payment.""" self._currency = currency 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 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 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 @@ -118,19 +137,17 @@ def push_url(self, url: str) -> "BaseBuilder": 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: @@ -166,7 +183,7 @@ def add_parameter( parameter = Parameter( name=key.capitalize(), value=str_value, - group_type=group_type.capitalize(), + group_type=group_type.capitalize() if group_type else None, group_id=group_id, ) @@ -205,7 +222,7 @@ def _validate_and_filter_service_parameters( 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. @@ -220,6 +237,7 @@ def from_dict(self, data: Dict[str, Any]) -> "BaseBuilder": - 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) @@ -241,6 +259,9 @@ def from_dict(self, data: Dict[str, Any]) -> "BaseBuilder": 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"]) @@ -338,15 +359,15 @@ def _validate_required_fields(self, action: str = "Pay") -> None: 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: + missing_fields = [field for field, value in self.required_fields(action).items() if value is None] + if len(missing_fields) == 1: + raise RequiredParameterMissingError(missing_fields[0], action=action) + elif 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: @@ -382,6 +403,7 @@ def build( 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, @@ -396,32 +418,6 @@ def build( 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. @@ -469,56 +465,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, @@ -565,98 +511,9 @@ 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: - """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.""" @@ -665,36 +522,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) - - # 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) diff --git a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py index 04fa9f3..262f4f5 100644 --- a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py +++ b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py @@ -50,7 +50,7 @@ def authorizeEncrypted(self: "PaymentBuilder", validate: bool = True) -> Payment return self._post_transaction(request_data) def cancelAuthorize( - self: "PaymentBuilder", + self: 'PaymentBuilder', original_transaction_key: Optional[str] = None, validate: bool = True, ) -> PaymentResponse: @@ -60,22 +60,17 @@ def cancelAuthorize( """ txn_key = ( original_transaction_key - or self._payload.get("original_transaction_key") - or self._payload.get("authorization_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 cancelAuthorize " - "(provide 'original_transaction_key' in payload)" + "original_transaction_key is required for cancelAuthorize" ) - 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) - # PaymentRequest.to_dict always writes AmountDebit; swap to AmountCredit - # since Buckaroo expects AmountCredit for cancel-authorize. - request_data["AmountCredit"] = request_data.pop("AmountDebit") + if 'AmountDebit' in request_data: + request_data['AmountCredit'] = request_data.pop('AmountDebit') return self._post_transaction(request_data) diff --git a/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py index d9c0d19..9163b95 100644 --- a/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py +++ b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py @@ -18,19 +18,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) + 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) diff --git a/buckaroo/builders/payments/capabilities/fast_checkout_capable.py b/buckaroo/builders/payments/capabilities/fast_checkout_capable.py index cd44cf3..d276a2d 100644 --- a/buckaroo/builders/payments/capabilities/fast_checkout_capable.py +++ b/buckaroo/builders/payments/capabilities/fast_checkout_capable.py @@ -18,21 +18,6 @@ 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 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/credit_card_builder.py b/buckaroo/builders/payments/credit_card_builder.py index 65885d5..111e410 100644 --- a/buckaroo/builders/payments/credit_card_builder.py +++ b/buckaroo/builders/payments/credit_card_builder.py @@ -18,102 +18,40 @@ def get_service_name(self) -> str: 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() == "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() == "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 == "payencrypted": + return {"encryptedcarddata": {"type": str, "required": True, "description": "Encrypted card data"}} - return {} - - def payWithSecurityCode(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: - """ - Process a payment with a security code. + if action_lower == "paywithsecuritycode": + return {"encryptedsecuritycode": {"type": str, "required": True, "description": "Encrypted security code"}} - Args: - validate (bool): Whether to validate service parameters before building + if action_lower in ("paywithtoken", "authorizewithtoken"): + return {"sessionid": {"type": str, "required": True, "description": "Session ID token from Hosted Fields submitSession()"}} - Returns: - PaymentResponse: The payment response - """ - payment_request = self.build("PayWithSecurityCode", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) + return {} - def payWithToken(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: - """ - Process a payment using a Hosted Fields session token. + def payWithSecurityCode(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 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. + 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 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. 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) - - 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 - """ + return self.execute_action("AuthorizeWithToken", validate=validate) - payment_request = self.build("PayRecurrent", validate=validate) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) + def payRecurrent(self, validate: bool = True) -> PaymentResponse: + """Execute a recurring payment against a previously stored token.""" + return self.execute_action("PayRecurrent", validate=validate) diff --git a/buckaroo/builders/payments/klarnakp_builder.py b/buckaroo/builders/payments/klarnakp_builder.py index cb9bfb4..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, @@ -85,37 +75,22 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: return {} - def reserve(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: - - payment_request = self.build("Reserve", validate=validate) - request_data = payment_request.to_dict() - - return self._post_data_request(request_data) - - def cancelReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: - - 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: - - 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: - - payment_request = self.build("ExtendReservation", validate=validate) - request_data = payment_request.to_dict() + def reserve(self, validate: bool = True) -> PaymentResponse: + """Create a Klarna KP reservation.""" + return self._post_data_request(self.build("Reserve", validate=validate).to_dict()) - return self._post_data_request(request_data) + def cancelReservation(self, validate: bool = True) -> PaymentResponse: + """Cancel a Klarna KP reservation.""" + return self._post_data_request(self.build("CancelReservation", validate=validate).to_dict()) - def addShippingInfo(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + def updateReservation(self, validate: bool = True) -> PaymentResponse: + """Update a Klarna KP reservation.""" + return self._post_data_request(self.build("UpdateReservation", validate=validate).to_dict()) - payment_request = self.build("AddShippingInfo", validate=validate) - request_data = payment_request.to_dict() + def extendReservation(self, validate: bool = True) -> PaymentResponse: + """Extend a Klarna KP reservation.""" + return self._post_data_request(self.build("ExtendReservation", validate=validate).to_dict()) - return self._post_data_request(request_data) + def addShippingInfo(self, validate: bool = True) -> PaymentResponse: + """Add shipping information to a Klarna KP order.""" + 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 1b287f2..8b10502 100644 --- a/buckaroo/builders/payments/payment_builder.py +++ b/buckaroo/builders/payments/payment_builder.py @@ -1,8 +1,124 @@ +from __future__ import annotations + +from typing import Dict, Any, Optional + from ..base_builder import BaseBuilder +from ...models.payment_response import PaymentResponse class PaymentBuilder(BaseBuilder): - """Payment-specific builder. All behavior inherited from BaseBuilder; - payment-specific overrides (when they arise) go here.""" + """Base class for all payment method builders. + + 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: + """Execute the payment.""" + request_data = self.build("Pay", validate=validate, strict_validation=strict_validation).to_dict() + return self._post_transaction(request_data) + + 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 ValueError("Original transaction key is required for refund") + + 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: + request_data['AmountCredit'] = refund_amount + request_data.pop('AmountDebit', None) + else: + if 'AmountDebit' in request_data: + request_data['AmountCredit'] = request_data.pop('AmountDebit') + + 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.""" + 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 pay_remainder( + self, original_transaction_key: Optional[str] = None, validate: bool = True + ) -> PaymentResponse: + """Pay the open remainder of a group transaction. + + 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 = ( + 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 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 execute_action(self, action: str, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: + """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) - pass + 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.""" + request_data = self.build(action, validate=validate).to_dict() + request_data['OriginalTransactionKey'] = txn_key + return request_data 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/builders/solutions/subscription_builder.py b/buckaroo/builders/solutions/subscription_builder.py index 2d86b6e..6cb9651 100644 --- a/buckaroo/builders/solutions/subscription_builder.py +++ b/buckaroo/builders/solutions/subscription_builder.py @@ -17,9 +17,6 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: return {} - def createSubscription(self: "SolutionBuilder", validate: bool = True) -> Any: + def createSubscription(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) + return self._post_data_request(self.build("CreateSubscription", validate=validate).to_dict()) diff --git a/buckaroo/config/buckaroo_config.py b/buckaroo/config/buckaroo_config.py index 0f03d20..844417f 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 @@ -324,6 +325,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.", + UserWarning, + stacklevel=2, + ) self._config_dict["verify_ssl"] = False return self diff --git a/buckaroo/exceptions/_buckaroo_error.py b/buckaroo/exceptions/_buckaroo_error.py index 89dbd36..0055666 100644 --- a/buckaroo/exceptions/_buckaroo_error.py +++ b/buckaroo/exceptions/_buckaroo_error.py @@ -1,12 +1,30 @@ -from typing import Any, Dict, Optional +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[Any] + 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, + ): + 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 + 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 8bc2af3..bf9b67b 100644 --- a/buckaroo/exceptions/_parameter_validation_error.py +++ b/buckaroo/exceptions/_parameter_validation_error.py @@ -2,6 +2,7 @@ Exception for parameter validation errors. """ +from typing import Optional from ._buckaroo_error import BuckarooError @@ -11,49 +12,42 @@ class ParameterValidationError(BuckarooError): def __init__( self, message: str, - parameter_name: str = None, - expected_type: str = None, - action: str = None, - service_name: str = None, + parameter_name: Optional[str] = None, + expected_type: Optional[str] = None, + action: Optional[str] = None, + service_name: Optional[str] = None, + **kwargs, ): - """ - 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) + 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 - """ - parts = [p for p in (service_name, f"{action} action" if action else None) if p] - qualifier = f" for {' '.join(parts)}" if parts else "" - message = f"Required parameter '{parameter_name}' is missing{qualifier}" - + def __init__( + self, + parameter_name: str, + action: Optional[str] = None, + service_name: Optional[str] = None, + **kwargs, + ): + 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, action=action, service_name=service_name + message=message, + parameter_name=parameter_name, + action=action, + service_name=service_name, + **kwargs, ) diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index 9f6890a..23285a9 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 @@ -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, @@ -159,7 +161,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 9722fa2..41d9684 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 @@ -71,12 +72,19 @@ 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() - content_b64 = base64.b64encode(md5_hash).decode("utf-8") + content_bytes = content.encode('utf-8') + 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 = "" @@ -342,7 +350,7 @@ def to_dict(self) -> Dict[str, Any]: } -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 6a113c4..b20c8f5 100644 --- a/buckaroo/http/strategies/curl_strategy.py +++ b/buckaroo/http/strategies/curl_strategy.py @@ -19,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", {}) + self._apply_defaults(**kwargs) def request( self, @@ -77,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: @@ -105,9 +91,6 @@ def request( 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, method: str, diff --git a/buckaroo/http/strategies/http_strategy.py b/buckaroo/http/strategies/http_strategy.py index e8de5c4..3da5487 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 @@ -24,11 +25,9 @@ class HttpResponse: 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} @@ -36,17 +35,29 @@ 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. """ + 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 _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: - """ - Configure the HTTP client with settings like timeout, retry, etc. - - Args: - **kwargs: Configuration parameters specific to the implementation - """ + """Configure this strategy. Subclasses must implement this.""" @abstractmethod def request( @@ -58,38 +69,12 @@ def request( timeout: Optional[int] = None, 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 - """ + """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 - """ + """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 - """ + """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 7aa579a..bd6c6f5 100644 --- a/buckaroo/http/strategies/requests_strategy.py +++ b/buckaroo/http/strategies/requests_strategy.py @@ -36,6 +36,7 @@ class RequestsStrategy(HttpStrategy): """ def __init__(self): + super().__init__() self.session = None self._retry_attempts = 3 self._retry_delay = 1.0 @@ -56,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/__init__.py b/buckaroo/models/__init__.py index 6778dee..3f5a37a 100644 --- a/buckaroo/models/__init__.py +++ b/buckaroo/models/__init__.py @@ -4,22 +4,26 @@ This package contains all data models and response objects. """ +from .payment_request import Parameter from .payment_response import ( BuckarooStatusCode, PaymentResponse, + Status, + StatusCode, RequiredAction, Service, ServiceParameter, - Status, - StatusCode, ) +from .transaction_context import TransactionContext __all__ = [ - "BuckarooStatusCode", - "PaymentResponse", - "RequiredAction", - "Service", - "ServiceParameter", - "Status", - "StatusCode", + 'Parameter', + 'BuckarooStatusCode', + 'PaymentResponse', + 'Status', + 'StatusCode', + 'RequiredAction', + 'Service', + 'ServiceParameter', + 'TransactionContext', ] diff --git a/buckaroo/models/payment_request.py b/buckaroo/models/payment_request.py index 42459e1..c656e9c 100644 --- a/buckaroo/models/payment_request.py +++ b/buckaroo/models/payment_request.py @@ -4,22 +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 = "" + value: Optional[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, - "Value": self.value, + "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 None, + group_type=data.get('GroupType') or None, + group_id=data.get('GroupID') or None, + ) + @dataclass class ClientIP: @@ -65,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 = [] @@ -106,15 +118,15 @@ def add(self, service: Service) -> "ServiceList": @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 + channel: 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 @@ -129,26 +141,32 @@ def __post_init__(self): def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API request.""" - request_dict = { + request_dict: Dict[str, Any] = { "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, "ContinueOnIncomplete": self.continue_on_incomplete, } + if self.description is not None: + 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: + 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() diff --git a/buckaroo/models/payment_response.py b/buckaroo/models/payment_response.py index fc5e7d5..57d9363 100644 --- a/buckaroo/models/payment_response.py +++ b/buckaroo/models/payment_response.py @@ -6,51 +6,36 @@ from typing import Any, Dict, Iterator, List, Optional from dataclasses import dataclass -from enum import IntEnum +from .payment_request import Parameter -class BuckarooStatusCode(IntEnum): - """Canonical Buckaroo transaction status codes.""" - +# Named constants for Buckaroo transaction status codes +class BuckarooStatusCode: + """Named constants for Buckaroo transaction status codes.""" + # Success SUCCESS = 190 - FAILED = 490 - VALIDATION_FAILURE = 491 - TECHNICAL_FAILURE = 492 - REJECTED = 690 - REJECTED_BY_USER = 691 - REJECTED_TECHNICAL = 692 + + # Pending PENDING_INPUT = 790 PENDING_PROCESSING = 791 - PENDING_CONSUMER = 792 - AWAITING_TRANSFER = 793 - CANCELLED_BY_USER = 890 - CANCELLED_BY_MERCHANT = 891 - - -_PENDING_CODES = frozenset( - { - BuckarooStatusCode.PENDING_INPUT, - BuckarooStatusCode.PENDING_PROCESSING, - BuckarooStatusCode.PENDING_CONSUMER, - BuckarooStatusCode.AWAITING_TRANSFER, - } -) -_CANCELLED_CODES = frozenset( - { - BuckarooStatusCode.CANCELLED_BY_USER, - BuckarooStatusCode.CANCELLED_BY_MERCHANT, - } -) -_FAILED_CODES = frozenset( - { - BuckarooStatusCode.FAILED, - BuckarooStatusCode.VALIDATION_FAILURE, - BuckarooStatusCode.TECHNICAL_FAILURE, - BuckarooStatusCode.REJECTED, - BuckarooStatusCode.REJECTED_BY_USER, - BuckarooStatusCode.REJECTED_TECHNICAL, - } -) + 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_CODES = {CANCELLED, CANCELLED_BY_CONSUMER_LATE, CANCELLED_BY_MERCHANT, CANCELLED_BY_CONSUMER} @dataclass @@ -65,18 +50,14 @@ 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: - 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="") + if isinstance(data, int): + return cls(code=data, description='') + if isinstance(data, dict): + return cls( + code=data.get('Code', 0), + description=data.get('Description', '') + ) + return cls(code=0, description='') @dataclass @@ -129,19 +110,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 @@ -150,7 +120,7 @@ class Service: name: str action: Optional[str] - parameters: List[ServiceParameter] + parameters: List[Parameter] @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Service": @@ -159,10 +129,14 @@ def from_dict(cls, data: Dict[str, Any]) -> "Service": data = {} parameters = [] - if "Parameters" in data and data["Parameters"]: - parameters = [ServiceParameter.from_dict(param) for param in data["Parameters"]] + if 'Parameters' in data and data['Parameters']: + parameters = [Parameter.from_dict(param) for param in data['Parameters']] - return cls(name=data.get("Name", ""), action=data.get("Action"), parameters=parameters) + return cls( + name=data.get('Name', ''), + action=data.get('Action'), + parameters=parameters + ) class PaymentResponse: @@ -248,7 +222,7 @@ def _parse_response(self): def is_pending(self) -> bool: """Check if the payment is pending.""" if self.status and self.status.code: - return self.status.code.code in _PENDING_CODES + return self.status.code.code in BuckarooStatusCode.PENDING_CODES return False def is_successful(self) -> bool: @@ -258,13 +232,13 @@ def is_successful(self) -> bool: def is_cancelled(self) -> bool: """Check if the payment was cancelled.""" if self.status and self.status.code: - 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: - 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 767ede9..49ca64a 100644 --- a/buckaroo/observers/logging_observer.py +++ b/buckaroo/observers/logging_observer.py @@ -68,20 +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", - "cardnumber", - "card_number", - "iban", - "account_number", - "cvc", - "bic", - "pan", - "expirydate", - "encryptedcarddata", + '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: @@ -511,11 +501,7 @@ def create_logger_from_env() -> BuckarooLoggingObserver: level = LogLevel(level_str) if level_str in [lv.value for lv in LogLevel] else LogLevel.INFO 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 - ) + 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/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/service_parameter_validator.py b/buckaroo/services/service_parameter_validator.py index c1cb431..7676331 100644 --- a/buckaroo/services/service_parameter_validator.py +++ b/buckaroo/services/service_parameter_validator.py @@ -2,7 +2,8 @@ Service parameter validation for payment builders. """ -from typing import Dict, Any, List +import logging +from typing import Any, Dict, List from buckaroo.models.payment_request import Parameter from buckaroo.exceptions._parameter_validation_error import ( ParameterValidationError, @@ -13,14 +14,14 @@ class ServiceParameterValidator: """Handles validation and filtering of service parameters for payment methods.""" - def __init__(self, payment_builder): + def __init__(self, builder) -> None: """ - Initialize validator with payment builder reference. - Args: - payment_builder: The payment builder instance to validate for + builder: A builder object that exposes ``get_allowed_service_parameters(action)`` + and ``get_service_name()`` methods. """ - self.payment_builder = payment_builder + 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. @@ -66,7 +67,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] @@ -74,7 +75,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): @@ -85,14 +86,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: @@ -107,15 +108,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] @@ -148,7 +149,7 @@ def validate_required_parameters( 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 @@ -181,14 +182,14 @@ def validate_required_parameters( 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( @@ -207,7 +208,7 @@ def validate_and_filter_parameters( 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 = { @@ -225,9 +226,7 @@ def validate_and_filter_parameters( # 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) @@ -261,14 +260,10 @@ def validate_and_filter_parameters( 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 @@ -297,10 +292,8 @@ def validate_all_parameters( # 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) - normalized_allowed = { - self.normalize_parameter_name(key): key for key in allowed_params.keys() - } + 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] @@ -308,10 +301,10 @@ def validate_all_parameters( 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 @@ -331,7 +324,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: """ @@ -344,10 +337,8 @@ 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) - normalized_allowed = { - self.normalize_parameter_name(key): key for key in allowed_params.keys() - } + 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) return normalized_param in normalized_allowed @@ -363,10 +354,8 @@ 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) - normalized_allowed = { - self.normalize_parameter_name(key): key for key in allowed_params.keys() - } + 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) return normalized_allowed.get(normalized_param, "") 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/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 new file mode 100644 index 0000000..503584e --- /dev/null +++ b/examples/credit_card_payment.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Credit card payment examples. + +Covers: + - 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 (cancelAuthorize) + - Recurring payment (payRecurrent) + - 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 + +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) + .payEncrypted() + ) + 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) + .payWithToken() + ) + 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) + capture_response = builder.capture(original_transaction_key=auth_key) + 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 + + cancel_response = builder.cancelAuthorize(original_transaction_key=auth_key) + 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) + .payRecurrent() + ) + print_response("Recurring payment", response) + return response + + +# ── 6. Refund ───────────────────────────────────────────────────────────────── + +def example_refund(original_transaction_key: str): + """Full refund of a captured or completed payment.""" + response = base_builder().refund(original_transaction_key=original_transaction_key) + 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..63096ee --- /dev/null +++ b/examples/ideal_payment.py @@ -0,0 +1,126 @@ +#!/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 + +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 (Buckaroo shows bank picker) ─────────────────────────── + +def example_pay(): + """ + 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 + + +# ── 2. 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. + """ + response = base_builder().refund(original_transaction_key=original_transaction_key) + print_response("Full refund", response) + return response + + +# ── 3. Partial refund ───────────────────────────────────────────────────────── + +def example_partial_refund(original_transaction_key: str): + """Refund only part of the original amount.""" + response = base_builder().partial_refund(original_transaction_key=original_transaction_key, amount=10.00) + print_response("Partial refund (€10.00)", response) + return response + + +# ── 4. 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().instantRefund() + 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() + 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 + # 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..184d549 --- /dev/null +++ b/examples/in3_payment.py @@ -0,0 +1,135 @@ +#!/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 + +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", + }) + + response = builder.refund(original_transaction_key=original_transaction_key) + 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..bf46ff9 --- /dev/null +++ b/examples/paypal_payment.py @@ -0,0 +1,105 @@ +#!/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 + +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.""" + response = base_builder().refund(original_transaction_key=original_transaction_key) + 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.""" + response = base_builder().partial_refund(original_transaction_key=original_transaction_key, amount=10.00) + 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) 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/requirements.txt b/requirements.txt index 3098218..30d0348 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,7 @@ requests>=2.20.0 -typing_extensions>=4.5.0 \ No newline at end of file +typing_extensions>=4.5.0 + +# Development / CI dependencies +flake8 +mypy +types-requests diff --git a/setup.py b/setup.py index 9f17667..0ba4d30 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ import os +import re from setuptools import setup, find_packages @@ -7,9 +8,11 @@ with open(os.path.join(ROOT_DIR, "README.md"), encoding="utf-8") as f: long_description = f.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", @@ -26,8 +29,8 @@ package_data={"buckaroo": ["py.typed"]}, zip_safe=False, install_requires=[ - "typing_extensions >= 4.5.0", - "requests >= 2.20", + 'typing_extensions >= 4.5.0', + 'requests >= 2.20', ], python_requires=">=3.9", project_urls={ 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/feature/solutions/test_default_solution.py b/tests/feature/solutions/test_default_solution.py index f2ae1a6..d1333c7 100644 --- a/tests/feature/solutions/test_default_solution.py +++ b/tests/feature/solutions/test_default_solution.py @@ -1,71 +1,60 @@ -from tests.support.mock_request import BuckarooMockRequest + 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 +62,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/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}" + ) 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="") diff --git a/tests/unit/builders/test_base_builder.py b/tests/unit/builders/test_base_builder.py index 91d39e7..1a66285 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 e76421c..10aa80b 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, )