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 ee090a3..11438be 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ - [Requirements](#requirements) - [Pip Installation](#pip-installation) - [Example](#example) +- [iDIN](#idin) +- [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) @@ -93,6 +98,226 @@ response = ( Find our full documentation online on [docs.buckaroo.io](https://docs.buckaroo.io). +### iDIN + +iDIN lets Dutch banks confirm a consumer's identity on your behalf. It carries no amount or currency — only the return URLs plus the `issuerId` (BIC code of the consumer's bank) service parameter. Three actions are available: `identify()`, `verify()` (age 18+), and `login()`. + +```python +response = payments.create_payment("idin", { + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": {"issuerId": "BANKNL2Y"}, # sandbox issuer +}).identify() + +print("key:", response.key) +print("redirect:", response.get_redirect_url()) +``` + +See [`examples/idin.py`](examples/idin.py) for a runnable demo of all three actions. + +### Instant Refunds + +Instant refunds send money back to the shopper immediately instead of via the regular batch refund process. They are processed as an instant payment rather than a standard refund, and are supported for iDEAL and Payconiq via `instantRefund()`. Pass the `original_transaction_key` of a settled payment; `refund_amount` is optional — omit it for a full refund. + +```python +response = payments.create_payment("ideal", { + "currency": "EUR", + "description": "ideal instant refund demo", + "invoice": "IDEAL-REFUND-DEMO-001", + "original_transaction_key": "ORIGINAL-TRANSACTION-KEY", + "refund_amount": 12.34, # optional; omit for a full refund +}).instantRefund() + +print("key:", response.key) +``` + +See [`examples/instant_refund.py`](examples/instant_refund.py) for a runnable demo covering both iDEAL and Payconiq. + +### eMandate + +eMandate is a DataRequest-based solution for managing SEPA direct debit mandates, reached through +`app.solutions` rather than `app.payments`. It comes in two variants that share the same five +actions — only the service name differs: + +- `emandate` — retail (B2C) +- `emandateb2b` — business (B2B) + +```python +from buckaroo.app import Buckaroo + +app = Buckaroo.from_env() + +# List available issuers (GetIssuerList — no parameters) +response = app.solutions.create_solution("emandate").issuer_list() + +# Create a mandate (CreateMandate — debtorReference is required) +response = app.solutions.create_solution( + "emandate", + { + "service_parameters": { + "debtorReference": "DEBTOR-001", + "debtorBankId": "ABNANL2A", + "sequenceType": "1", + "purchaseId": "PUR-001", + "language": "nl", + } + }, +).create_mandate() +mandate_id = response.get_service_parameter("MandateId") + +# Look up a mandate's status (GetStatus — mandateId is required) +response = app.solutions.create_solution( + "emandate", {"service_parameters": {"mandateId": mandate_id}} +).status() + +# Modify a mandate (ModifyMandate — mandateId is required) +response = app.solutions.create_solution( + "emandate", + {"service_parameters": {"mandateId": mandate_id, "maxAmount": "1000.00"}}, +).modify_mandate() + +# Cancel a mandate (CancelMandate — mandateId is required) +response = app.solutions.create_solution( + "emandate", + {"service_parameters": {"mandateId": mandate_id, "purchaseId": "PUR-001"}}, +).cancel_mandate() +``` + +The B2B variant exposes the same five methods — swap `"emandate"` for `"emandateb2b"`. + +See [`examples/emandate.py`](examples/emandate.py) for a runnable demo of all five actions +against both the B2C and B2B services. + +### Split Payments + +Split Payments (Buckaroo service `Marketplaces`) lets a platform divide one +customer payment across its own funds account and one or more seller accounts. +Following the other Buckaroo SDKs, `split` and `refund_supplementary` build a +supplementary service that is *combined* into a payment or refund; `transfer` and +`manual_transfer` are standalone. + +```python +from buckaroo import BuckarooClient +from buckaroo.services.payment_service import PaymentService +from buckaroo.services.solution_service import SolutionService + +client = BuckarooClient("STORE_KEY", "SECRET_KEY", mode="test") +payments = PaymentService(client) +marketplaces = SolutionService(client) +``` + +**Split** — build the split, then combine it into the funding payment (e.g. +iDEAL). `daysUntilTransfer` is `"0"` for immediate payout, or omit it to hold the +funds until a later Transfer. + +```python +split = marketplaces.create_solution("marketplaces").split({ + "daysUntilTransfer": "2", + "marketplace": {"Amount": "10.00", "Description": "INV0001 Commission Platform"}, + "sellers": [ + {"AccountId": "SELLER_ACCOUNT_1", "Amount": "50.00", "Description": "Payout 1"}, + {"AccountId": "SELLER_ACCOUNT_2", "Amount": "35.00", "Description": "Payout 2"}, + ], +}) + +response = payments.create_payment("ideal", { + "currency": "EUR", + "amount": 95.00, + "invoice": "INV0001", + "description": "Split order INV0001", + "service_parameters": {"issuer": "ABNANL2A"}, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", +}).combine(split).pay() +``` + +**Transfer** — release held funds of an existing split payment. With no split +data it transfers everything (Transfer I); pass `marketplace`/`sellers` to +transfer a partial or re-specified split (Transfer II). + +```python +marketplaces.create_solution("marketplaces").transfer({ + "originalTransactionKey": "SPLIT_TRANSACTION_KEY", +}) +``` + +**RefundSupplementary** — refund the consumer and pull the funds back from the +target accounts. Combine it into the refund. Without seller data it reverts all +transfers (I); pass `sellers` to retrieve specific amounts per account (II). + +```python +supplementary = marketplaces.create_solution("marketplaces").refund_supplementary() + +payments.create_payment("ideal", { + "currency": "EUR", + "amount": 50.00, + "invoice": "INV0001", + "description": "Split refund INV0001", + "original_transaction_key": "SPLIT_TRANSACTION_KEY", + "refund_amount": 50.00, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", +}).combine(supplementary).refund() +``` + +**ManualTransfer** — move funds directly between two accounts. + +```python +marketplaces.create_solution("marketplaces").manual_transfer({ + "fromAccountId": "ACCOUNT_A", + "toAccountId": "ACCOUNT_B", + "fromDescription": "Deduction monthly fee", + "toDescription": "Monthly fee third party ABC", + "amount": 10.00, + "currency": "EUR", +}) +``` + +A runnable demo of all six request types is in +[`examples/marketplaces.py`](examples/marketplaces.py). + +### 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 704cb1f..0d2133f 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -1,20 +1,42 @@ from abc import ABC, abstractmethod from typing import Dict, Any, Optional, List -from ..models.payment_request import PaymentRequest, ClientIP, Service, ServiceList, Parameter +from ..models.payment_request import ( + PaymentRequest, + ClientIP, + Service, + ServiceList, + Parameter, + CombinableService, +) +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 @@ -26,6 +48,7 @@ def __init__(self, client): self._services_selectable_by_client: Optional[str] = None self._client_ip: Optional[ClientIP] = None self._service_parameters: List[Parameter] = [] + self._combined_services: List[Service] = [] # Extra services merged in via combine() self._payload: Dict[str, Any] = {} # Store original payload self._validator = ServiceParameterValidator(self) @@ -34,46 +57,56 @@ def currency(self, currency: str) -> "BaseBuilder": 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 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 @@ -94,19 +127,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: @@ -142,7 +173,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, ) @@ -181,7 +212,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. @@ -196,6 +227,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) @@ -217,6 +249,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"]) @@ -314,15 +349,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: @@ -349,8 +384,8 @@ def build( parameters=self._service_parameters if self._service_parameters else None, ) - # Create service list - service_list = ServiceList(services=[service]) + # Create service list, appending any services merged in via combine() + service_list = ServiceList(services=[service, *self._combined_services]) # Build payment request payment_request = PaymentRequest( @@ -358,6 +393,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, @@ -372,41 +408,20 @@ def build( return payment_request - def pay(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: + def _build_refund_request_data(self, action: str, validate: bool = True) -> Dict[str, Any]: """ - 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 + Build the wire request body shared by refund-style actions. - Returns: - PaymentResponse: Structured payment response object - - Raises: - ValueError: If required fields are missing - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) - AuthenticationError: If authentication fails - BuckarooApiError: If API returns an error - """ - # Build the payment request - payment_request = self.build("Pay", validate=validate, strict_validation=strict_validation) - - # Convert to dictionary for API - request_data = payment_request.to_dict() - - return self._post_transaction(request_data) - - def refund(self, validate: bool = True) -> PaymentResponse: - """ - Execute a refund transaction. + Reads ``original_transaction_key`` and ``refund_amount`` from the payload, + builds the request for ``action``, and swaps ``AmountDebit`` for + ``AmountCredit`` (partial or full). Args: + action (str): The Buckaroo action to build (e.g. "Refund", "instantRefund") validate (bool): Whether to validate service parameters before building Returns: - PaymentResponse: The refund response + Dict[str, Any]: The refund request body Raises: ValueError: If required fields are missing @@ -422,7 +437,7 @@ def refund(self, validate: bool = True) -> PaymentResponse: refund_amount = self._payload.get("refund_amount") # Build refund request with original transaction reference - payment_request = self.build("Refund", validate=validate) + payment_request = self.build(action, validate=validate) # Convert to dictionary and modify for refund request_data = payment_request.to_dict() @@ -438,41 +453,7 @@ def refund(self, validate: bool = True) -> PaymentResponse: request_data["AmountCredit"] = request_data["AmountDebit"] del request_data["AmountDebit"] - 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) + return request_data def capture( self, @@ -520,98 +501,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.""" @@ -620,36 +512,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/banking_builder.py b/buckaroo/builders/payments/banking_builder.py new file mode 100644 index 0000000..437e722 --- /dev/null +++ b/buckaroo/builders/payments/banking_builder.py @@ -0,0 +1,57 @@ +from typing import Dict, Any +from .payment_builder import PaymentBuilder +from ...models.payment_response import PaymentResponse + + +class BankingBuilder(PaymentBuilder): + """Builder for Banking payouts. + + Banking is a payout: Buckaroo sends money OUT to a bank account given an + IBAN and account holder name. Unlike the base Pay flow, it uses the + ``PaymentOrder`` action, sends ``AmountCredit`` (not ``AmountDebit``), and + has no return URLs since it's a server-to-server payout with no redirect. + """ + + def get_service_name(self) -> str: + """Get the service name for Banking payouts.""" + return "Banking" + + def get_allowed_service_parameters(self, action: str = "PaymentOrder") -> Dict[str, Any]: + """Get the allowed service parameters for Banking payouts based on action.""" + + if action.lower() in ["paymentorder"]: + return { + "accountholdername": { + "type": str, + "required": True, + "description": "Account holder name", + }, + "iban": {"type": str, "required": True, "description": "IBAN"}, + } + + return {} + + def required_fields(self, action: str = "PaymentOrder") -> Dict[str, Any]: + """Banking is a server-to-server payout with no redirect, so it drops the + ``return_url*`` requirements. Currency, amount, and invoice stay required.""" + if action.lower() == "paymentorder": + return { + "currency": self._currency, + "amount_debit": self._amount_debit, + "invoice": self._invoice, + } + return super().required_fields(action) + + def payment_order(self, validate: bool = True) -> PaymentResponse: + """Execute the Banking payout. + + Uses AmountCredit (not AmountDebit) per Buckaroo API requirements. + """ + payment_request = self.build("PaymentOrder", validate=validate) + request_data = payment_request.to_dict() + + # PaymentRequest.to_dict always writes AmountDebit; swap to AmountCredit + # since Buckaroo expects AmountCredit for payouts. + request_data["AmountCredit"] = request_data.pop("AmountDebit") + + return self._post_transaction(request_data) diff --git a/buckaroo/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/capabilities/instant_refund_capable.py b/buckaroo/builders/payments/capabilities/instant_refund_capable.py index 019f78a..2a9a577 100644 --- a/buckaroo/builders/payments/capabilities/instant_refund_capable.py +++ b/buckaroo/builders/payments/capabilities/instant_refund_capable.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from ....models.payment_response import PaymentResponse @@ -18,19 +18,48 @@ class InstantRefundCapable: """Mixin for payment methods that support instant refunds (iDEAL, Sofort, PayConiq).""" - def instantRefund(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + def instantRefund( + self: "PaymentBuilder", + original_transaction_key: Optional[str] = None, + validate: bool = True, + ) -> PaymentResponse: """ Initiate an instant refund. + Mirrors :meth:`BaseBuilder.refund`: puts ``OriginalTransactionKey`` and + ``AmountCredit`` on the wire instead of ``AmountDebit``, but keeps the + ``instantRefund`` action name. + Available for: iDEAL, Sofort, PayConiq Not available for: Credit Card, PayPal (use regular refund instead) Args: + original_transaction_key (str, optional): The transaction key of the + original payment. If None, will try to get from payload. validate (bool): Whether to validate service parameters before building Returns: PaymentResponse: The instant refund response + + Raises: + ValueError: If no original transaction key is available """ - payment_request = self.build("instantRefund", validate=validate) - request_data = payment_request.to_dict() + txn_key = original_transaction_key or self._payload.get("original_transaction_key") + if not txn_key: + raise ValueError( + "Original transaction key is required for instant refunds " + "(provide as parameter or in payload)" + ) + + _MISSING = object() + prev_key = self._payload.get("original_transaction_key", _MISSING) + self._payload["original_transaction_key"] = txn_key + try: + request_data = self._build_refund_request_data("instantRefund", validate) + finally: + if prev_key is _MISSING: + self._payload.pop("original_transaction_key", None) + else: + self._payload["original_transaction_key"] = prev_key + return self._post_transaction(request_data) diff --git a/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/idin_builder.py b/buckaroo/builders/payments/idin_builder.py new file mode 100644 index 0000000..7a3ec80 --- /dev/null +++ b/buckaroo/builders/payments/idin_builder.py @@ -0,0 +1,78 @@ +from __future__ import annotations +from typing import Dict, Any +from ...models.payment_response import PaymentResponse +from .payment_builder import PaymentBuilder + + +class IdinBuilder(PaymentBuilder): + """Builder for iDIN identification, age verification, and login. + + iDIN lets Dutch banks confirm a consumer's identity on the merchant's + behalf. Every action is a DataRequest keyed on a single ``issuerId`` + (BIC code of the consumer's bank) service parameter: + + - ``identify()``: full identification, returns the bank-registered + customer details on the async push. + - ``verify()``: age verification (18+). + - ``login()``: returns a unique consumer ID for login purposes. + + The immediate response only carries ``Status`` and the redirect + (``RequiredAction.RedirectURL``); the bank's personal data arrives later + via push and is already parseable by the existing :class:`PaymentResponse`. + """ + + def required_fields(self, action: str = "identify") -> Dict[str, Any]: + """ + Get the required fields for this payment method. + + iDIN carries no amount or currency; only the ReturnURL family is + required. + + Returns: + Dict[str, Any]: Dictionary mapping field names to their current values + """ + return { + "return_url": self._return_url, + "return_url_cancel": self._return_url_cancel, + "return_url_error": self._return_url_error, + "return_url_reject": self._return_url_reject, + } + + def get_service_name(self) -> str: + """Get the service name for iDIN requests.""" + return "Idin" + + def get_allowed_service_parameters(self, action: str = "identify") -> Dict[str, Any]: + """Get the allowed service parameters for iDIN requests based on action.""" + + if action.lower() in ["identify", "verify", "login"]: + return { + "issuerId": { + "type": str, + "required": True, + "description": "BIC code of the issuing bank of the consumer", + }, + } + + return {} + + def identify(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: + """Request full identification from the consumer's bank.""" + payment_request = self.build( + "identify", validate=validate, strict_validation=strict_validation + ) + return self._post_data_request(payment_request.to_dict()) + + def verify(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: + """Verify whether the consumer is 18 years or older.""" + payment_request = self.build( + "verify", validate=validate, strict_validation=strict_validation + ) + return self._post_data_request(payment_request.to_dict()) + + def login(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: + """Request a unique consumer ID for login purposes.""" + payment_request = self.build( + "login", validate=validate, strict_validation=strict_validation + ) + return self._post_data_request(payment_request.to_dict()) diff --git a/buckaroo/builders/payments/in3_builder.py b/buckaroo/builders/payments/in3_builder.py index 71e5dab..f151b6b 100644 --- a/buckaroo/builders/payments/in3_builder.py +++ b/buckaroo/builders/payments/in3_builder.py @@ -1,8 +1,9 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder +from .capabilities.authorize_capture_capable import AuthorizeCaptureCapable -class In3Builder(PaymentBuilder): +class In3Builder(PaymentBuilder, AuthorizeCaptureCapable): """Builder for IN3 payments with bank transfer capabilities.""" def get_service_name(self) -> str: @@ -12,8 +13,8 @@ def get_service_name(self) -> str: def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for IN3 payments based on action.""" - if action.lower() in ["pay"]: - return { + if action.lower() in ["pay", "authorize"]: + params = { "billingCustomer": { "type": list, "required": True, @@ -25,6 +26,25 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: "description": "Shipping customer information", }, "article": {"type": list, "required": True, "description": "IN3 articles"}, + "route": { + "type": str, + "required": False, + "description": ( + "In3 acquirer route, e.g. 'abn_b2b' for ABN-AMRO Achteraf Betalen" + ), + }, } + # Authorize is only used for the ABN AMRO "Zakelijk op rekening" + # (business-on-account) flow, which the gateway routes via the + # required Route parameter (e.g. "abn_b2b"). Pay does not use it. + if action.lower() == "authorize": + params["route"] = { + "type": str, + "required": True, + "description": 'Financing route, e.g. "abn_b2b" for ABN AMRO business', + } + + return params + return {} diff --git a/buckaroo/builders/payments/klarna_builder.py b/buckaroo/builders/payments/klarna_builder.py index a74e2cb..e19a816 100644 --- a/buckaroo/builders/payments/klarna_builder.py +++ b/buckaroo/builders/payments/klarna_builder.py @@ -1,12 +1,52 @@ from __future__ import annotations -from typing import Dict, Any, Optional +from typing import Dict, Any from buckaroo.models.payment_response import PaymentResponse from .payment_builder import PaymentBuilder class KlarnaBuilder(PaymentBuilder): - """Builder for Klarna MOR (Merchant of Record) payments.""" + """Builder for Klarna MOR (Merchant of Record) payments. + + Klarna no longer returns a reservation number. Every follow-up action + (CancelReservation, UpdateReservation, ExtendReservation, and Pay) + references the prior Reserve through the Buckaroo ``DataRequestKey`` carried + as a service parameter. The reservation actions post to ``/json/DataRequest``; + Pay and Refund are transaction requests. Shipping details are attached to the + Pay request (``shippingMethod`` / ``company`` / ``trackingNumber``); the + gateway has no standalone AddShippingInfo action for this service. + """ + + def required_fields(self, action: str = "Pay") -> Dict[str, Any]: + """Narrow the base required-field set per action. + + Reserve only needs currency + invoice; Pay needs currency + amount; the + DataRequest follow-up actions need none of the base transaction fields. + Refund and any other action fall through to the base requirements. + """ + action = action.lower() + + if action == "reserve": + return { + "currency": self._currency, + "invoice": self._invoice, + } + + if action == "pay": + return { + "currency": self._currency, + "amount_debit": self._amount_debit, + "invoice": self._invoice, + } + + if action in ( + "cancelreservation", + "updatereservation", + "extendreservation", + ): + return {} + + return super().required_fields(action) def get_service_name(self) -> str: """Get the service name for Klarna payments.""" @@ -23,20 +63,30 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: "required": True, "description": "Key of the prior Klarna Reserve", }, + "article": { + "type": list, + "required": False, + "description": "Articles to pay for on a partial delivery", + }, + "shippingMethod": { + "type": str, + "required": False, + "description": "Shipping method", + }, + "company": { + "type": str, + "required": False, + "description": "Shipping company name", + }, + "trackingNumber": { + "type": str, + "required": False, + "description": "Shipping tracking number", + }, } if action == "reserve": return { - "billingCustomer": { - "type": list, - "required": True, - "description": "Billing customer information", - }, - "shippingCustomer": { - "type": list, - "required": True, - "description": "Shipping customer information", - }, "article": { "type": list, "required": True, @@ -44,9 +94,19 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: }, "operatingCountry": { "type": str, - "required": False, + "required": True, "description": "Operating country code", }, + "billingCustomer": { + "type": list, + "required": False, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": False, + "description": "Shipping customer information", + }, "pno": { "type": str, "required": False, @@ -64,37 +124,48 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: }, } - if action == "cancelreservation": - return {} + if action in ("cancelreservation", "extendreservation"): + return { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Buckaroo data request key of the prior Reserve", + }, + } + + if action == "updatereservation": + return { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Buckaroo data request key of the prior Reserve", + }, + "article": { + "type": list, + "required": False, + "description": "Updated Klarna articles", + }, + "shippingCustomer": { + "type": list, + "required": False, + "description": "Updated shipping customer information", + }, + } return {} def reserve(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: - payment_request = self.build("Reserve", validate=validate) - request_data = payment_request.to_dict() - - return self._post_data_request(request_data) - - def cancelReservation( - self: "PaymentBuilder", - original_transaction_key: Optional[str] = None, - validate: bool = True, - ) -> PaymentResponse: - """Cancel a previously-reserved Klarna transaction. - - Mirrors :meth:`AuthorizeCaptureCapable.cancelAuthorize` but with the - ``CancelReservation`` action. - """ - txn_key = original_transaction_key or self._payload.get("original_transaction_key") - if not txn_key: - raise ValueError( - "Original transaction key is required for cancelReservation " - "(provide 'original_transaction_key' in payload)" - ) + return self._post_data_request(payment_request.to_dict()) + def cancelReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: payment_request = self.build("CancelReservation", validate=validate) - request_data = payment_request.to_dict() - request_data["OriginalTransactionKey"] = txn_key + return self._post_data_request(payment_request.to_dict()) + + def updateReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + payment_request = self.build("UpdateReservation", validate=validate) + return self._post_data_request(payment_request.to_dict()) - return self._post_transaction(request_data) + def extendReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + payment_request = self.build("ExtendReservation", validate=validate) + return self._post_data_request(payment_request.to_dict()) diff --git a/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/emandate_builder.py b/buckaroo/builders/solutions/emandate_builder.py new file mode 100644 index 0000000..fe199ae --- /dev/null +++ b/buckaroo/builders/solutions/emandate_builder.py @@ -0,0 +1,176 @@ +from typing import Dict, Any +from .solution_builder import SolutionBuilder + + +class EmandateBuilder(SolutionBuilder): + """Builder for eMandate solutions (DataRequest-based mandate management).""" + + def get_service_name(self) -> str: + """Get the service name for eMandate.""" + return "emandate" + + def get_allowed_service_parameters(self, action: str = "GetIssuerList") -> Dict[str, Any]: + """Get the allowed service parameters for eMandate based on action.""" + + if action.lower() in ["getissuerlist"]: + return {} + + if action.lower() in ["createmandate"]: + return { + "debtorBankId": { + "type": str, + "required": False, + "description": "BIC of the debtor's bank", + }, + "debtorReference": { + "type": str, + "required": True, + "description": "Debtor's reference for the mandate", + }, + "sequenceType": { + "type": str, + "required": False, + "description": "Sequence type of the mandate (0 = one-off, 1 = recurring)", + }, + "purchaseId": { + "type": str, + "required": False, + "description": "Unique purchase identifier", + }, + "language": { + "type": str, + "required": False, + "description": "Language for the mandate flow", + }, + "emandateReason": { + "type": str, + "required": False, + "description": "Reason for the mandate", + }, + "maxAmount": { + "type": str, + "required": False, + "description": "Maximum amount allowed under the mandate", + }, + } + + if action.lower() in ["getstatus"]: + return { + "mandateId": { + "type": str, + "required": True, + "description": "Identifier of the mandate to look up", + }, + } + + if action.lower() in ["modifymandate"]: + return { + "mandateId": { + "type": str, + "required": True, + "description": "Identifier of the mandate to modify", + }, + "maxAmount": { + "type": str, + "required": False, + "description": "Maximum amount allowed under the mandate", + }, + "language": { + "type": str, + "required": False, + "description": "Language for the mandate flow", + }, + "emandateReason": { + "type": str, + "required": False, + "description": "Reason for the mandate", + }, + } + + if action.lower() in ["cancelmandate"]: + return { + "mandateId": { + "type": str, + "required": True, + "description": "Identifier of the mandate to cancel", + }, + "purchaseId": { + "type": str, + "required": False, + "description": "Unique purchase identifier", + }, + } + + return {} + + def issuer_list(self, validate: bool = True) -> Any: + """Retrieve the list of available eMandate issuers via GetIssuerList.""" + payload = self.build("GetIssuerList", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def create_mandate(self, validate: bool = True) -> Any: + """Create a mandate via CreateMandate. + + Requires ``debtorReference`` to be set (via ``add_parameter`` or the + ``service_parameters`` payload key); the other Mandate parameters are + optional. Raises :class:`RequiredParameterMissingError` when + ``debtorReference`` is missing and ``validate`` is True. + """ + payload = self.build("CreateMandate", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def status(self, validate: bool = True) -> Any: + """Retrieve the status of a mandate via GetStatus. + + Requires ``mandateId`` to be set (via ``add_parameter`` or the + ``service_parameters`` payload key). Raises + :class:`RequiredParameterMissingError` when ``mandateId`` is missing + and ``validate`` is True. + """ + payload = self.build("GetStatus", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def modify_mandate(self, validate: bool = True) -> Any: + """Modify a mandate via ModifyMandate. + + Requires ``mandateId`` to be set (via ``add_parameter`` or the + ``service_parameters`` payload key); ``maxAmount``, ``language`` and + ``emandateReason`` are optional. Raises + :class:`RequiredParameterMissingError` when ``mandateId`` is missing + and ``validate`` is True. + """ + payload = self.build("ModifyMandate", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + def cancel_mandate(self, validate: bool = True) -> Any: + """Cancel a mandate via CancelMandate. + + Requires ``mandateId`` to be set (via ``add_parameter`` or the + ``service_parameters`` payload key); ``purchaseId`` is optional. + Raises :class:`RequiredParameterMissingError` when ``mandateId`` is + missing and ``validate`` is True. + """ + payload = self.build("CancelMandate", validate=validate) + request_data = payload.to_dict() + + return self._post_data_request(request_data) + + +class EmandateB2BBuilder(EmandateBuilder): + """Builder for the Business (B2B) eMandate variant. + + Inherits every action and parameter spec from :class:`EmandateBuilder` + unchanged; only the service name differs. + """ + + def get_service_name(self) -> str: + """Get the service name for the B2B eMandate variant.""" + return "emandateb2b" diff --git a/buckaroo/builders/solutions/marketplaces_builder.py b/buckaroo/builders/solutions/marketplaces_builder.py new file mode 100644 index 0000000..02b6114 --- /dev/null +++ b/buckaroo/builders/solutions/marketplaces_builder.py @@ -0,0 +1,207 @@ +from typing import Any, Dict, Optional + +from .solution_builder import SolutionBuilder +from ...models.payment_request import CombinableService +from ...models.payment_response import PaymentResponse + + +class MarketplacesBuilder(SolutionBuilder): + """Builder for the Split Payments (``Marketplaces``) solution. + + Marketplaces lets a platform divide one customer payment across its own + funds account and one or more seller accounts, move held funds later, refund + from sellers, and move funds between accounts manually. + + ``split`` and ``refund_supplementary`` build a supplementary service that is + *combined* into a payment or refund (``builder.combine(...).pay()`` / + ``.refund()``), mirroring the PHP/Node SDKs. ``transfer`` and + ``manual_transfer`` are standalone data requests. + + The split spec is a single dict: + ``{"daysUntilTransfer": ..., "marketplace": {...}, "sellers": [{...}, ...]}`` + — ``marketplace`` becomes the funds-account group, each ``sellers`` entry a + ``Seller`` group with a unique GroupID. + """ + + # Grouped-parameter buckets shared by the split-defining actions. Keyed by + # the wire group type ("Marketplace"/"Seller") — the validator matches + # grouped params by group type, not by individual field name. + _SPLIT_GROUPS: Dict[str, Any] = { + "Marketplace": { + "type": dict, + "required": False, + "description": "Split group reserved for the Split Payments funds account", + }, + "Seller": { + "type": dict, + "required": False, + "description": "Split group reserved for a third-party (seller) account", + }, + } + + def get_service_name(self) -> str: + """Get the service name for Split Payments.""" + return "Marketplaces" + + def get_allowed_service_parameters(self, action: str = "Split") -> Dict[str, Any]: + """Get the allowed service parameters for Marketplaces based on action. + + ``marketplace`` and ``sellers`` are grouped-parameter buckets; + ``DaysUntilTransfer`` is a plain parameter allowed only on ``Split`` (a + Transfer is always immediate). + """ + if action.lower() == "split": + return { + "DaysUntilTransfer": { + "type": str, + "required": False, + "description": "Days (0-93) funds are held before transfer; 0 = immediate", + }, + **self._SPLIT_GROUPS, + } + + if action.lower() in ("transfer", "refundsupplementary"): + return dict(self._SPLIT_GROUPS) + + if action.lower() == "manualtransfer": + return { + "FromAccountId": { + "type": str, + "required": True, + "description": "Merchant GUID of the debited account (A)", + }, + "ToAccountId": { + "type": str, + "required": True, + "description": "Merchant GUID of the credited account (B)", + }, + "FromDescription": { + "type": str, + "required": True, + "description": "Description of the debit transaction in account A", + }, + "ToDescription": { + "type": str, + "required": True, + "description": "Description of the credit transaction in account B", + }, + } + + return {} + + def split(self, split: Dict[str, Any], validate: bool = True) -> CombinableService: + """Build a Split service to combine into a payment. + + Combine the result into the funding payment, e.g. + ``payments.create_payment("ideal", {...}).combine(mp).pay()``. + + Args: + split: Spec dict with ``daysUntilTransfer`` (0-93; "0" = immediate, + omit to hold until a later Transfer), ``marketplace`` and + ``sellers`` groups. + validate: Whether to validate and filter service parameters. + """ + self._apply_split(split) + service = self.build("Split", validate=validate).services.services[0] + return CombinableService(services=[service]) + + def refund_supplementary( + self, split: Optional[Dict[str, Any]] = None, validate: bool = True + ) -> CombinableService: + """Build a RefundSupplementary service to combine into a refund. + + Combine the result into the consumer refund, e.g. + ``payments.create_payment("ideal", {...}).combine(mp).refund()``. With no + spec it reverts all transfers; pass ``sellers``/``marketplace`` to + retrieve specific amounts per account. + + Args: + split: Optional spec dict with ``marketplace`` and ``sellers`` groups. + validate: Whether to validate and filter service parameters. + """ + self._apply_split(split or {}) + service = self.build("RefundSupplementary", validate=validate).services.services[0] + return CombinableService(services=[service]) + + def transfer(self, transfer: Dict[str, Any], validate: bool = True) -> PaymentResponse: + """Transfer held funds of an existing split payment (standalone). + + Args: + transfer: Spec dict with ``originalTransactionKey`` (required) and, + optionally, ``marketplace``/``sellers`` to re-specify the split + (partial transfer). ``DaysUntilTransfer`` does not apply. + validate: Whether to validate and filter service parameters. + + Raises: + ValueError: If ``originalTransactionKey`` is missing. + """ + original_transaction_key = transfer.get("originalTransactionKey") + if not original_transaction_key: + raise ValueError("Transfer requires an originalTransactionKey") + + self._apply_split(transfer) + request_data = self.build("Transfer", validate=validate).to_dict() + request_data["OriginalTransactionKey"] = original_transaction_key + request_data.pop("AmountDebit", None) + + return self._post_data_request(request_data) + + def manual_transfer( + self, manual_transfer: Dict[str, Any], validate: bool = True + ) -> PaymentResponse: + """Move funds directly between two accounts (standalone). + + Args: + manual_transfer: Spec dict with ``fromAccountId``, ``toAccountId``, + ``fromDescription``, ``toDescription``, ``amount`` and + ``currency`` (all required), and optional ``invoice``. + validate: Whether to validate and filter service parameters. + + Raises: + ValueError: If a required field is missing. + """ + fields = { + "FromAccountId": manual_transfer.get("fromAccountId"), + "ToAccountId": manual_transfer.get("toAccountId"), + "FromDescription": manual_transfer.get("fromDescription"), + "ToDescription": manual_transfer.get("toDescription"), + } + missing = [name for name, value in fields.items() if not value] + if manual_transfer.get("amount") is None: + missing.append("amount") + currency = manual_transfer.get("currency") or self._currency + if not currency: + missing.append("currency") + if missing: + raise ValueError(f"ManualTransfer requires: {', '.join(missing)}") + + for name, value in fields.items(): + self.add_parameter(name, value) + + request_data = self.build("ManualTransfer", validate=validate).to_dict() + request_data["Currency"] = currency + request_data["Amount"] = manual_transfer["amount"] + request_data.pop("AmountDebit", None) + + invoice = manual_transfer.get("invoice") or self._invoice + if invoice: + request_data["Invoice"] = invoice + else: + request_data.pop("Invoice", None) + + return self._post_data_request(request_data) + + def _apply_split(self, split: Dict[str, Any]) -> None: + """Map a split spec dict into grouped Marketplaces service parameters.""" + days_until_transfer = split.get("daysUntilTransfer") + if days_until_transfer is not None: + self.add_parameter("DaysUntilTransfer", days_until_transfer) + + marketplace = split.get("marketplace") + if marketplace: + for name, value in marketplace.items(): + self.add_parameter(name, value, "Marketplace") + + for index, seller in enumerate(split.get("sellers") or [], start=1): + for name, value in seller.items(): + self.add_parameter(name, value, "Seller", str(index)) diff --git a/buckaroo/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 4a9a20c..23285a9 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -1,10 +1,11 @@ -from typing import Dict, Type +from typing import Any, Dict, Type import logging from .builder_factory import BuilderFactory from buckaroo.builders.payments.alipay_builder import AlipayBuilder from buckaroo.builders.payments.apple_pay_builder import ApplePayBuilder from buckaroo.builders.payments.bancontact_builder import BancontactBuilder +from buckaroo.builders.payments.banking_builder import BankingBuilder from buckaroo.builders.payments.belfius_builder import BelfiusBuilder from buckaroo.builders.payments.bizum_builder import BizumBuilder from buckaroo.builders.payments.blik_builder import BlikBuilder @@ -17,6 +18,7 @@ from buckaroo.builders.payments.giftcards_builder import GiftcardsBuilder from buckaroo.builders.payments.google_pay_builder import GooglePayBuilder from buckaroo.builders.payments.ideal_qr_builder import IdealQrBuilder +from buckaroo.builders.payments.idin_builder import IdinBuilder from buckaroo.builders.payments.in3_builder import In3Builder from buckaroo.builders.payments.kbc_builder import KBCBuilder from buckaroo.builders.payments.billink_builder import BillinkBuilder @@ -42,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): @@ -52,6 +55,7 @@ class PaymentMethodFactory(BuilderFactory): "alipay": AlipayBuilder, "applepay": ApplePayBuilder, "bancontact": BancontactBuilder, + "banking": BankingBuilder, "belfius": BelfiusBuilder, "bizum": BizumBuilder, "billink": BillinkBuilder, @@ -66,6 +70,7 @@ class PaymentMethodFactory(BuilderFactory): "googlepay": GooglePayBuilder, "ideal": IdealBuilder, "idealqr": IdealQrBuilder, + "idin": IdinBuilder, "in3": In3Builder, "kbc": KBCBuilder, "knaken": KnakenBuilder, @@ -77,6 +82,7 @@ class PaymentMethodFactory(BuilderFactory): "paypal": PaypalBuilder, "paybybank": PayByBankBuilder, "payperemail": PayPerEmailBuilder, + "pospayment": PosBuilder, "przelewy24": Przelewy24Builder, "riverty": RivertyBuilder, "sepadirectdebit": SepaDirectDebitBuilder, @@ -155,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/factories/solution_method_factory.py b/buckaroo/factories/solution_method_factory.py index afd0303..5cf81d2 100644 --- a/buckaroo/factories/solution_method_factory.py +++ b/buckaroo/factories/solution_method_factory.py @@ -3,6 +3,8 @@ from .builder_factory import BuilderFactory from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder +from buckaroo.builders.solutions.emandate_builder import EmandateB2BBuilder, EmandateBuilder +from buckaroo.builders.solutions.marketplaces_builder import MarketplacesBuilder from buckaroo.builders.solutions.default_builder import DefaultBuilder from buckaroo.builders.solutions.solution_builder import SolutionBuilder @@ -13,6 +15,9 @@ class SolutionMethodFactory(BuilderFactory): # Registry of available solution methods _solution_methods: Dict[str, Type[SolutionBuilder]] = { "subscription": SubscriptionBuilder, + "emandate": EmandateBuilder, + "emandateb2b": EmandateB2BBuilder, + "marketplaces": MarketplacesBuilder, } @classmethod 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 c63e0ac..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 = [] @@ -74,6 +86,19 @@ def add_parameter(self, parameter: Union[Dict[str, Any], Parameter]) -> "Service return self +@dataclass +class CombinableService: + """A built supplementary service to merge into another transaction. + + Returned by builders that produce a service meant to ride along with a + payment or refund (e.g. Marketplaces ``Split``). Passed to + :meth:`BaseBuilder.combine`, which appends its ``services`` to the host + request's ``ServiceList``. + """ + + services: List["Service"] + + @dataclass class ServiceList: """Model for list of services.""" @@ -93,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 @@ -116,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/abn_amro_achteraf_betalen.py b/examples/abn_amro_achteraf_betalen.py new file mode 100644 index 0000000..5f38bc0 --- /dev/null +++ b/examples/abn_amro_achteraf_betalen.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Demo of ABN-AMRO Achteraf Betalen via the In3 payment method. + +Same underlying service as In3 V3 — setting the optional ``Route`` service +parameter to ``abn_b2b`` selects ABN-AMRO Achteraf Betalen instead of the +default In3 acquirer. NL/B2B only. Gated on ``BUCKAROO_STORE_KEY`` / +``BUCKAROO_SECRET_KEY`` env vars. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def demo_abn_amro_achteraf_betalen() -> None: + """Pay via In3 with the ABN-AMRO Achteraf Betalen route.""" + print("\nABN-AMRO Achteraf Betalen (In3, Route=abn_b2b)") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + + # create_payment(method, params) dispatches through the PaymentMethodFactory + # and returns a ready-to-execute In3Builder. + builder = app.payments.create_payment( + "in3", + { + "currency": "EUR", + "amount": 121.00, + "description": "ABN-AMRO Achteraf Betalen demo", + "invoice": "ABN-B2B-DEMO-001", + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": { + "billingCustomer": [ + { + "firstName": "John", + "lastName": "Doe", + "chamberOfCommerce": "12345678", + "companyName": "Acme B.V.", + } + ], + "shippingCustomer": [ + { + "firstName": "John", + "lastName": "Doe", + "chamberOfCommerce": "12345678", + "companyName": "Acme B.V.", + } + ], + "article": [ + {"description": "Widget", "quantity": "1", "price": "100.00"}, + ], + }, + }, + ) + + # Route is optional and NL/B2B only - selects ABN-AMRO Achteraf Betalen + # instead of the default In3 acquirer. + builder.add_parameter("route", "abn_b2b") + + response = builder.pay() + + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — ABN-AMRO ACHTERAF BETALEN DEMO") + print("=" * 60) + print("Env vars read:") + print(" BUCKAROO_STORE_KEY, BUCKAROO_SECRET_KEY, BUCKAROO_MODE") + + demo_abn_amro_achteraf_betalen() + + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/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/emandate.py b/examples/emandate.py new file mode 100644 index 0000000..8fae829 --- /dev/null +++ b/examples/emandate.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Demo of the eMandate solution (B2C ``emandate`` + B2B ``emandateb2b``). + +eMandate is a DataRequest-based solution for managing SEPA direct debit +mandates: list issuers, create a mandate, check its status, modify it, and +cancel it. The retail (B2C, service ``emandate``) and business (B2B, service +``emandateb2b``) variants share the same five actions; only the service name +differs. Each demo below runs against both variants. Gated on +``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + +SERVICES = ("emandate", "emandateb2b") + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def demo_issuer_list() -> None: + """List available eMandate issuers via GetIssuerList (no parameters).""" + print("\n1. Issuer list") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution(service).issuer_list() + print(f" [{service}] status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def demo_create_mandate() -> None: + """Create a mandate via CreateMandate. Only ``debtorReference`` is required.""" + print("\n2. Create mandate") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution( + service, + { + "service_parameters": { + "debtorReference": "DEBTOR-DEMO-001", + "debtorBankId": "ABNANL2A", + "sequenceType": "1", + "purchaseId": "EMANDATE-DEMO-001", + "language": "nl", + } + }, + ).create_mandate() + mandate_id = response.get_service_parameter("MandateId") + print(f" [{service}] status.code={response.status.code.code} mandateId={mandate_id}") + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def demo_status() -> None: + """Look up a mandate's status via GetStatus. ``mandateId`` is required.""" + print("\n3. Mandate status") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution( + service, + {"service_parameters": {"mandateId": "MND-DEMO-001"}}, + ).status() + mandate_status = response.get_service_parameter("Status") + print( + f" [{service}] status.code={response.status.code.code} mandateStatus={mandate_status}" + ) + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def demo_modify_mandate() -> None: + """Modify a mandate via ModifyMandate. ``mandateId`` is required.""" + print("\n4. Modify mandate") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution( + service, + { + "service_parameters": { + "mandateId": "MND-DEMO-001", + "maxAmount": "1000.00", + } + }, + ).modify_mandate() + print(f" [{service}] status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def demo_cancel_mandate() -> None: + """Cancel a mandate via CancelMandate. ``mandateId`` is required.""" + print("\n5. Cancel mandate") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + for service in SERVICES: + try: + response = app.solutions.create_solution( + service, + { + "service_parameters": { + "mandateId": "MND-DEMO-001", + "purchaseId": "EMANDATE-DEMO-001", + } + }, + ).cancel_mandate() + print(f" [{service}] status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" [{service}] ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — EMANDATE DEMO") + print("=" * 60) + + demo_issuer_list() + demo_create_mandate() + demo_status() + demo_modify_mandate() + demo_cancel_mandate() + + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/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/idin.py b/examples/idin.py new file mode 100644 index 0000000..61b3930 --- /dev/null +++ b/examples/idin.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Demo of the iDIN payment method. + +iDIN lets Dutch banks confirm a consumer's identity on the merchant's behalf. +Every action is a DataRequest keyed on a single ``issuerId`` (BIC code of the +consumer's bank) service parameter; the sandbox issuer is ``BANKNL2Y``. The +demo drives all three actions: identify, verify (age 18+), and login. Gated +on ``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + +_ISSUER_ID = "BANKNL2Y" + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def _idin_payload() -> dict: + # iDIN carries no amount/currency; only the ReturnURL family plus the + # issuerId service parameter (BIC code of the consumer's bank). + return { + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": {"issuerId": _ISSUER_ID}, + } + + +def demo_identify() -> None: + """Request full identification from the consumer's bank.""" + print("\n1. iDIN identify") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment("idin", _idin_payload()).identify() + print(f" status.code={response.status.code.code} key={response.key}") + print(f" redirect={response.get_redirect_url()}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_verify() -> None: + """Verify whether the consumer is 18 years or older.""" + print("\n2. iDIN verify (age 18+)") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment("idin", _idin_payload()).verify() + print(f" status.code={response.status.code.code} key={response.key}") + print(f" redirect={response.get_redirect_url()}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_login() -> None: + """Request a unique consumer ID for login purposes.""" + print("\n3. iDIN login") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment("idin", _idin_payload()).login() + print(f" status.code={response.status.code.code} key={response.key}") + print(f" redirect={response.get_redirect_url()}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — IDIN DEMO") + print("=" * 60) + demo_identify() + demo_verify() + demo_login() + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/examples/in3.py b/examples/in3.py new file mode 100644 index 0000000..7cc6e47 --- /dev/null +++ b/examples/in3.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Demo of the In3 payment method. + +Shows the regular ``.pay()`` flow and the ABN-AMRO "Zakelijk op rekening" +(business-on-account) ``.authorize()`` / ``.capture()`` flow. All demos are gated +on ``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def demo_pay() -> None: + """Regular In3 flow: pay in three installments.""" + print("\n1. In3 pay") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + + # CompanyName and CocNumber are optional billingCustomer fields for B2B + # orders. They ride along in the billingCustomer group; omit them for + # regular consumer payments. + billing = _customer() + billing.update({"CompanyName": "Acme B.V.", "CocNumber": "12345678"}) + + response = app.payments.create_payment( + "in3", + { + "currency": "EUR", + "amount": 250.00, + "description": "in3 pay demo", + "invoice": "IN3-PAY-001", + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": { + "article": [ + {"Description": "Widget", "Quantity": "2", "GrossUnitPrice": "125.00"}, + ], + "billingCustomer": [billing], + "shippingCustomer": [_customer()], + }, + }, + ).pay() + print(f" status.code={response.status.code.code} redirect={response.get_redirect_url()}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_authorize_capture() -> None: + """ABN-AMRO "Zakelijk op rekening": authorize first, capture later. + + This business-on-account flow uses ``.authorize()`` instead of ``.pay()`` and + requires the ``route`` service parameter set to ``"AbnB2b"``. Note the exact + Buckaroo field names: ``GrossUnitPrice`` (not Price), ``StreetNumber`` (not + house number), ``CountryCode`` (not country). Capture needs no service params. + """ + print("\n2. In3 authorize / capture (ABN-AMRO Zakelijk op rekening)") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + + billing = _customer() + billing.update({"Category": "B2B", "CompanyName": "Acme B.V.", "CocNumber": "12345678"}) + + authorize_response = app.payments.create_payment( + "in3", + { + "currency": "EUR", + "amount": 250.00, + "description": "in3 authorize demo", + "invoice": "IN3-AUTH-001", + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": { + "route": "AbnB2b", + "article": [ + {"Description": "Widget", "Quantity": "2", "GrossUnitPrice": "125.00"}, + ], + "billingCustomer": [billing], + "shippingCustomer": [_customer()], + }, + }, + ).authorize() + print( + f" authorize: status.code={authorize_response.status.code.code} " + f"key={authorize_response.key}" + ) + + # Once the order is confirmed, capture the authorized amount using the + # transaction key from the authorize response. + capture_response = app.payments.create_payment( + "in3", + { + "currency": "EUR", + "amount": 250.00, + "description": "in3 capture demo", + "invoice": "IN3-AUTH-001", + }, + ).capture(original_transaction_key=authorize_response.key, amount=250.00) + print(f" capture: status.code={capture_response.status.code.code}") + except Exception as e: + print(f" ❌ {e}") + + +def _customer() -> dict: + """A minimal In3 customer using the exact Buckaroo field names.""" + return { + "FirstName": "John", + "LastName": "Doe", + "Phone": "0612345678", + "Email": "john@example.com", + "Street": "Hoofdstraat", + "StreetNumber": "1", + "PostalCode": "1000AA", + "City": "Amsterdam", + "CountryCode": "NL", + } + + +def main() -> None: + print("BUCKAROO SDK — IN3 DEMOS") + print("=" * 60) + demo_pay() + demo_authorize_capture() + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/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/instant_refund.py b/examples/instant_refund.py new file mode 100644 index 0000000..4827b69 --- /dev/null +++ b/examples/instant_refund.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Demo of instant refunds for iDEAL and Payconiq. + +Instant refunds send money back to the shopper immediately instead of via the +regular batch refund process. They are supported for iDEAL and Payconiq +through ``instantRefund()``, which reads ``original_transaction_key`` from the +payload (or an optional argument) and an optional ``refund_amount`` for a +partial refund (a full refund is issued when it's omitted). The refund is +processed as an instant payment on the wire: ``OriginalTransactionKey`` plus +``AmountCredit`` instead of ``AmountDebit``. Gated on ``BUCKAROO_STORE_KEY`` / +``BUCKAROO_SECRET_KEY`` env vars. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + +# Placeholder transaction keys. Replace with the key of a SETTLED iDEAL / +# Payconiq payment before running for real; a live green instant refund also +# requires a merchant account with instant refunds enabled. +_IDEAL_ORIGINAL_TRANSACTION_KEY = "REPLACE_WITH_SETTLED_IDEAL_TRANSACTION_KEY" +_PAYCONIQ_ORIGINAL_TRANSACTION_KEY = "REPLACE_WITH_SETTLED_PAYCONIQ_TRANSACTION_KEY" + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def demo_ideal_instant_refund() -> None: + """Instantly refund part of a settled iDEAL payment.""" + print("\n1. iDEAL instant refund") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "description": "ideal instant refund demo", + "invoice": "IDEAL-REFUND-DEMO-001", + "original_transaction_key": _IDEAL_ORIGINAL_TRANSACTION_KEY, + "refund_amount": 12.34, # optional; omit for full refund + }, + ).instantRefund() + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_payconiq_instant_refund() -> None: + """Instantly refund part of a settled Payconiq payment.""" + print("\n2. Payconiq instant refund") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + response = app.payments.create_payment( + "payconiq", + { + "currency": "EUR", + "description": "payconiq instant refund demo", + "invoice": "PAYCONIQ-REFUND-DEMO-001", + "original_transaction_key": _PAYCONIQ_ORIGINAL_TRANSACTION_KEY, + "refund_amount": 12.34, # optional; omit for full refund + }, + ).instantRefund() + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — INSTANT REFUND DEMO") + print("=" * 60) + demo_ideal_instant_refund() + demo_payconiq_instant_refund() + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/examples/marketplaces.py b/examples/marketplaces.py new file mode 100644 index 0000000..12c5ad3 --- /dev/null +++ b/examples/marketplaces.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Demo of the Split Payments (``Marketplaces``) solution. + +Marketplaces lets a platform divide one customer payment across its own funds +account and one or more seller accounts, move held funds later, refund from +sellers, and move funds manually between accounts. Following the other Buckaroo +SDKs, ``split`` and ``refund_supplementary`` build a supplementary service that +is *combined* into a payment or refund; ``transfer`` and ``manual_transfer`` are +standalone. Gated on ``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. + +The account IDs, issuer, and transaction keys below are placeholders — replace +them with values from your own Split Payments setup to run against the sandbox. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + +# Placeholder identifiers — replace with your own Split Payments values. +ISSUER = "ABNANL2A" +SELLER_1 = "789C60F316D24B088ACD471" +SELLER_2 = "369C60F316D24B088ACD238" +FUNDS_ACCOUNT = "AAAAAAAAAAAAAAAAAAAAAAA" +SPLIT_TXN_KEY = "REPLACE_WITH_SPLIT_TRANSACTION_KEY" + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def demo_split() -> None: + """Split: build the split, combine it into an iDEAL payment.""" + print("\n1. Split") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + marketplaces = app.solutions.create_solution("marketplaces").split( + { + "daysUntilTransfer": "2", + "marketplace": { + "Amount": "10.00", + "InvoiceNumber": "INV0000123", + "Description": "INV0001 Commission Platform", + }, + "sellers": [ + {"AccountId": SELLER_1, "Amount": "50.00", "Description": "Payout 1"}, + {"AccountId": SELLER_2, "Amount": "35.00", "Description": "Payout 2"}, + ], + } + ) + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 95.00, + "invoice": "INV0001", + "description": "Split order INV0001", + "service_parameters": {"issuer": ISSUER}, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + }, + ) + .combine(marketplaces) + .pay() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_transfer_all() -> None: + """Transfer (I): release every held split of an existing split payment.""" + print("\n2. Transfer (I) — transfer all") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution("marketplaces").transfer( + {"originalTransactionKey": SPLIT_TXN_KEY} + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_transfer_partial() -> None: + """Transfer (II): re-specify the split (partial/different distribution).""" + print("\n3. Transfer (II) — partial / re-specified") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution("marketplaces").transfer( + { + "originalTransactionKey": SPLIT_TXN_KEY, + "marketplace": {"Amount": "10.00", "Description": "INV0001 Commission Platform"}, + "sellers": [{"AccountId": SELLER_1, "Amount": "50.00"}], + } + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_refund_supplementary_all() -> None: + """RefundSupplementary (I): refund the consumer and revert all transfers.""" + print("\n4. RefundSupplementary (I) — revert all") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + marketplaces = app.solutions.create_solution("marketplaces").refund_supplementary() + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 50.00, + "invoice": "INV0001", + "description": "Split refund INV0001", + "original_transaction_key": SPLIT_TXN_KEY, + "refund_amount": 50.00, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + }, + ) + .combine(marketplaces) + .refund() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_refund_supplementary_partial() -> None: + """RefundSupplementary (II): retrieve specific amounts per seller.""" + print("\n5. RefundSupplementary (II) — per-seller") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + marketplaces = app.solutions.create_solution("marketplaces").refund_supplementary( + { + "sellers": [ + { + "AccountId": SELLER_1, + "Amount": "30.00", + "Description": "INV0001 Refund Beauty Products BV", + } + ] + } + ) + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 30.00, + "invoice": "INV0001", + "description": "Split refund INV0001", + "original_transaction_key": SPLIT_TXN_KEY, + "refund_amount": 30.00, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + }, + ) + .combine(marketplaces) + .refund() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_manual_transfer() -> None: + """ManualTransfer: move funds directly between two accounts.""" + print("\n6. ManualTransfer") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": SELLER_1, + "toAccountId": FUNDS_ACCOUNT, + "fromDescription": "Deduction monthly fee", + "toDescription": "Monthly fee third party ABC", + "amount": 10.00, + "currency": "EUR", + "invoice": "INV0001", + } + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — SPLIT PAYMENTS (MARKETPLACES) DEMO") + print("=" * 60) + + demo_split() + demo_transfer_all() + demo_transfer_partial() + demo_refund_supplementary_all() + demo_refund_supplementary_partial() + demo_manual_transfer() + + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/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/payperemail.py b/examples/payperemail.py new file mode 100644 index 0000000..b2293e9 --- /dev/null +++ b/examples/payperemail.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Demo of the Pay Per Email payment method. + +Pay Per Email emails the shopper a payment link instead of returning an inline +redirect. The demo drives the ``PaymentInvitation`` action, passing the customer +identity (email, name, gender) as service parameters. Gated on +``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def demo_payment_invitation() -> None: + """Send a Pay Per Email invitation via the PaymentInvitation action.""" + print("\n1. Pay Per Email invitation") + print("-" * 40) + if not _have_credentials(): + return + + try: + app = Buckaroo.from_env() + + # ExpirationDate is optional; omit it to use the account default. Gender + # follows Buckaroo's codes: 1=Male, 2=Female, 0=Unknown, 9=N/A. + response = app.payments.create_payment( + "payperemail", + { + "currency": "EUR", + "amount": 42.00, + "description": "pay per email demo", + "invoice": "PPE-DEMO-001", + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": { + "CustomerEmail": "john@example.com", + "CustomerFirstName": "John", + "CustomerLastName": "Doe", + "CustomerGender": "1", + "ExpirationDate": "2026-12-31", + }, + }, + ).execute_action("PaymentInvitation") + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — PAY PER EMAIL DEMO") + print("=" * 60) + demo_payment_invitation() + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/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_banking.py b/tests/feature/payments/test_banking.py new file mode 100644 index 0000000..b3b6ee8 --- /dev/null +++ b/tests/feature/payments/test_banking.py @@ -0,0 +1,59 @@ +import json + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestBankingFeature: + """Feature tests for Banking payouts (PaymentOrder action). + + Banking is a payout with no return URLs, so the payload is built + directly here rather than via ``Helpers.standard_payload`` (which + injects return_url* fields that don't apply to a server-to-server + payout). + """ + + def test_banking_payment_order_returns_success(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "Banking", "Action": "PaymentOrder", "Parameters": []}], + "ServiceCode": "banking", + "AmountCredit": 150.0, + "AmountDebit": None, + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + + response = buckaroo.payments.create_payment( + "banking", + { + "currency": "EUR", + "amount": 150.0, + "invoice": "Banking_Test_1", + "description": "Test", + "service_parameters": { + "AccountHolderName": "Arensman", + "IBAN": "NL44RABO0123456789", + }, + }, + ).payment_order() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + + sent = json.loads(mock_strategy.calls[-1]["data"]) + assert sent["Currency"] == "EUR" + assert sent["AmountCredit"] == 150.0 + assert "AmountDebit" not in sent + assert sent["Invoice"] == "Banking_Test_1" + assert sent["Description"] == "Test" + + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "Banking" + assert service["Action"] == "PaymentOrder" + assert {"Name": "Accountholdername", "Value": "Arensman"} in [ + {"Name": p["Name"], "Value": p["Value"]} for p in service["Parameters"] + ] + assert {"Name": "Iban", "Value": "NL44RABO0123456789"} in [ + {"Name": p["Name"], "Value": p["Value"]} for p in service["Parameters"] + ] diff --git a/tests/feature/payments/test_ideal.py b/tests/feature/payments/test_ideal.py index 01c1be9..625424c 100644 --- a/tests/feature/payments/test_ideal.py +++ b/tests/feature/payments/test_ideal.py @@ -1,5 +1,7 @@ +import pytest + from tests.support.mock_request import BuckarooMockRequest -from tests.support.recording_mock import recorded_action +from tests.support.recording_mock import recorded_action, recorded_request from tests.support.helpers import Helpers @@ -86,6 +88,18 @@ def test_ideal_instant_refund_sends_action_instantrefund_on_the_wire( ).instantRefund() assert recorded_action(recording_mock) == "instantRefund" + request = recorded_request(recording_mock) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.00 + assert "AmountDebit" not in request + + def test_ideal_instant_refund_raises_without_original_transaction_key(self, recording_buckaroo): + """A missing ``original_transaction_key`` must fail fast, matching ``refund()``.""" + with pytest.raises(ValueError, match="Original transaction key is required"): + recording_buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload(invoice="INV-IDEAL-WIRE-IREFUND-NOKEY"), + ).instantRefund() def test_ideal_fast_checkout_sends_action_payfastcheckout_on_the_wire( self, recording_buckaroo, recording_mock diff --git a/tests/feature/payments/test_idin.py b/tests/feature/payments/test_idin.py new file mode 100644 index 0000000..d447562 --- /dev/null +++ b/tests/feature/payments/test_idin.py @@ -0,0 +1,79 @@ +"""Feature test: iDIN identify/verify/login round-trip through the full stack. + +Every iDIN action is a DataRequest carrying a single ``issuerId`` service +parameter (BIC code of the consumer's bank); the sandbox issuer is +``BANKNL2Y``. The immediate response only carries the redirect — bank +personal data arrives later via push — so this pins the action name, the +``issuerId`` reaching the wire, and ``response.key`` parsing from the mock. +""" + +from tests.support.helpers import Helpers +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_service_parameters + +_ISSUER_ID = "BANKNL2Y" + + +def _idin_payload(**overrides): + payload = { + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + "service_parameters": {"issuerId": _ISSUER_ID}, + } + payload.update(overrides) + return payload + + +class TestIdinFeature: + def test_identify_sends_action_and_issuer_id(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "Idin", "Action": "identify", "Parameters": []}], + "ServiceCode": "IDIN", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.payments.create_payment("idin", _idin_payload()).identify() + + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "identify" + + sent = {p["Name"].lower(): p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert sent["issuerid"] == _ISSUER_ID + + def test_verify_sends_action_and_issuer_id(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "Idin", "Action": "verify", "Parameters": []}], + "ServiceCode": "IDIN", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.payments.create_payment("idin", _idin_payload()).verify() + + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "verify" + + sent = {p["Name"].lower(): p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert sent["issuerid"] == _ISSUER_ID + + def test_login_sends_action_and_issuer_id(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "Idin", "Action": "login", "Parameters": []}], + "ServiceCode": "IDIN", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.payments.create_payment("idin", _idin_payload()).login() + + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "login" + + sent = {p["Name"].lower(): p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert sent["issuerid"] == _ISSUER_ID diff --git a/tests/feature/payments/test_in3.py b/tests/feature/payments/test_in3.py index 96bfd7f..a89a8cf 100644 --- a/tests/feature/payments/test_in3.py +++ b/tests/feature/payments/test_in3.py @@ -1,6 +1,8 @@ """Feature test: in3 pay() round-trip through full stack with MockBuckaroo.""" from tests.support.helpers import Helpers +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_service_parameters class TestIn3Feature: @@ -13,7 +15,7 @@ def test_in3_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): payload_overrides={"amount": 25.00, "description": "Test in3"}, service_params={ "article": [ - {"description": "Widget", "quantity": "2", "price": "12.50"}, + {"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}, ], "billingCustomer": [ {"firstName": "John", "lastName": "Doe"}, @@ -23,3 +25,149 @@ def test_in3_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): ], }, ) + + def test_in3_pay_with_company_and_coc_flows_through_billing_customer( + self, buckaroo, mock_strategy + ): + """Optional B2B fields CompanyName and CocNumber (BTI-715) ride along in + the billingCustomer group on the regular In3 Pay flow, reaching the wire + as GroupType=Billingcustomer, GroupID=1. + """ + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="in3", + invoice="INV-IN3-005", + payload_overrides={"amount": 25.00, "description": "Test in3 B2B pay"}, + service_params={ + "article": [ + {"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}, + ], + "billingCustomer": [ + { + "firstName": "John", + "lastName": "Doe", + "CompanyName": "Acme B.V.", + "CocNumber": "12345678", + }, + ], + "shippingCustomer": [ + {"firstName": "John", "lastName": "Doe"}, + ], + }, + ) + + billing = { + param["Name"]: param + for param in recorded_service_parameters(mock_strategy) + if param["GroupType"] == "Billingcustomer" + } + assert billing["Companyname"]["Value"] == "Acme B.V." + assert billing["Cocnumber"]["Value"] == "12345678" + assert billing["Companyname"]["GroupID"] == "1" + assert billing["Cocnumber"]["GroupID"] == "1" + + def test_in3_authorize_returns_pending_with_redirect(self, buckaroo, mock_strategy): + def add_service_params(builder): + builder.add_parameter("route", "AbnB2b") + builder.add_parameter( + "article", [{"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}] + ) + builder.add_parameter("billingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + builder.add_parameter("shippingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + + Helpers.assert_action_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="in3", + invoice="INV-IN3-002", + action_name="Authorize", + call_method="authorize", + payload_overrides={"amount": 25.00, "description": "Test in3 authorize"}, + extra_builder_setup=add_service_params, + ) + + def test_in3_authorize_then_capture_round_trip(self, buckaroo, mock_strategy): + auth_response_body = Helpers.pending_redirect_response("in3", "Authorize") + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", auth_response_body) + ) + + builder = buckaroo.payments.create_payment( + "in3", + Helpers.standard_payload( + invoice="INV-IN3-003", amount=25.00, description="Test in3 authorize" + ), + ) + builder.add_parameter("route", "AbnB2b") + builder.add_parameter( + "article", [{"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}] + ) + builder.add_parameter("billingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + builder.add_parameter("shippingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + authorize_response = builder.authorize() + assert authorize_response.is_pending() + + capture_response_body = Helpers.success_response( + { + "Services": [{"Name": "in3", "Action": "Capture", "Parameters": []}], + "ServiceCode": "in3", + } + ) + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", capture_response_body) + ) + capture_response = builder.capture( + original_transaction_key=authorize_response.key, amount=25.00 + ) + + assert capture_response.status.code.code == 190 + assert capture_response.key == capture_response_body["Key"] + + def test_in3_authorize_with_business_customer_returns_pending_with_redirect( + self, buckaroo, mock_strategy + ): + """ABN-AMRO "Zakelijk op rekening" (business-on-account) runs through the + In3 Authorize action with route="AbnB2b". The business customer is marked + by Category=B2B/CompanyName/CocNumber inside billingCustomer; those + sub-fields flow through the existing billingCustomer list group. + """ + + def add_service_params(builder): + builder.add_parameter("route", "AbnB2b") + builder.add_parameter( + "article", [{"description": "Widget", "quantity": "2", "GrossUnitPrice": "12.50"}] + ) + builder.add_parameter( + "billingCustomer", + [ + { + "Category": "B2B", + "CompanyName": "Acme B.V.", + "CocNumber": "12345678", + "firstName": "John", + "lastName": "Doe", + } + ], + ) + builder.add_parameter("shippingCustomer", [{"firstName": "John", "lastName": "Doe"}]) + + Helpers.assert_action_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="in3", + invoice="INV-IN3-004", + action_name="Authorize", + call_method="authorize", + payload_overrides={"amount": 25.00, "description": "Test in3 authorize B2B"}, + extra_builder_setup=add_service_params, + ) + + billing_params = { + param["Name"]: param["Value"] + for param in recorded_service_parameters(mock_strategy) + if param["GroupType"] == "Billingcustomer" + } + assert billing_params["Category"] == "B2B" + assert billing_params["Companyname"] == "Acme B.V." + assert billing_params["Cocnumber"] == "12345678" diff --git a/tests/feature/payments/test_klarna.py b/tests/feature/payments/test_klarna.py index 7d8eb90..faac666 100644 --- a/tests/feature/payments/test_klarna.py +++ b/tests/feature/payments/test_klarna.py @@ -59,3 +59,82 @@ def test_klarna_pay_as_capture_attaches_data_request_key(self, buckaroo, mock_st services = body["Services"]["ServiceList"] names = [p["Name"] for p in services[0]["Parameters"]] assert "Datarequestkey" in names + + def test_klarna_cancel_reservation_posts_data_request_key(self, buckaroo, mock_strategy): + """Follow-up actions carry the Buckaroo DataRequestKey (no reservation + number) and post to /json/DataRequest.""" + response_body = Helpers.pending_redirect_response("klarna", action="CancelReservation") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload( + invoice="INV-KLARNA-CAN", + service_parameters={"dataRequestKey": "RES-DRK-2"}, + ), + ).cancelReservation() + + assert response.key == response_body["Key"] + body = json.loads(mock_strategy.calls[-1]["data"]) + service = body["Services"]["ServiceList"][0] + assert service["Action"] == "CancelReservation" + assert "OriginalTransactionKey" not in body + names = [p["Name"] for p in service["Parameters"]] + assert "Datarequestkey" in names + + def test_klarna_update_reservation_dispatches_to_data_request(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("klarna", action="UpdateReservation") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload( + invoice="INV-KLARNA-UPD", + service_parameters={ + "dataRequestKey": "RES-DRK-3", + "article": [{"description": "Widget", "quantity": "1", "price": "10.00"}], + }, + ), + ).updateReservation() + + assert response.key == response_body["Key"] + assert ( + json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0]["Action"] + == "UpdateReservation" + ) + + def test_klarna_extend_reservation_dispatches_to_data_request(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("klarna", action="ExtendReservation") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload( + invoice="INV-KLARNA-EXT", + service_parameters={"dataRequestKey": "RES-DRK-4"}, + ), + ).extendReservation() + + assert response.key == response_body["Key"] + assert ( + json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0]["Action"] + == "ExtendReservation" + ) + + def test_klarna_pay_carries_shipping_details_on_transaction(self, buckaroo, mock_strategy): + """Shipping details ride on the Pay transaction (no AddShippingInfo action).""" + capture_body = Helpers.success_response( + overrides={"Key": "PAY-K-SHP", "AmountDebit": 22.45, "ServiceCode": "klarna"} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", capture_body)) + builder = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload(invoice="INV-KLARNA-SHP", amount=22.45), + ) + builder.add_parameter("dataRequestKey", "RES-DRK-5") + builder.add_parameter("trackingNumber", "AAAA1234567890") + response = builder.pay() + + assert response.key == "PAY-K-SHP" + service = json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0] + assert service["Action"] == "Pay" + names = [p["Name"] for p in service["Parameters"]] + assert "Datarequestkey" in names + assert "Trackingnumber" in names diff --git a/tests/feature/payments/test_payconiq.py b/tests/feature/payments/test_payconiq.py index e22ff81..77ef04b 100644 --- a/tests/feature/payments/test_payconiq.py +++ b/tests/feature/payments/test_payconiq.py @@ -1,5 +1,9 @@ """Feature test: payconiq pay() and capability methods through full stack with MockBuckaroo.""" +import pytest + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_request from tests.support.helpers import Helpers @@ -38,3 +42,114 @@ def test_payconiq_fast_checkout(self, buckaroo, mock_strategy): method="payconiq", invoice="INV-PCQ-FAST", ) + + # ------------------------------------------------------------------ + # Wire-level assertions — verify BankTransferCapabilities' InstantRefund + # mixin puts the right Action, OriginalTransactionKey and AmountCredit + # on the outgoing request for Payconiq (partial + full). + + def test_payconiq_instant_refund_full_sends_action_instantrefund_on_the_wire( + self, recording_buckaroo, recording_mock + ): + """Full refund (``refund_amount`` omitted): AmountDebit swaps to AmountCredit.""" + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.success_response( + { + "Services": [ + {"Name": "payconiq", "Action": "InstantRefund", "Parameters": []} + ], + "ServiceCode": "payconiq", + "AmountCredit": 10.00, + "AmountDebit": None, + } + ), + ) + ) + recording_buckaroo.payments.create_payment( + "payconiq", + Helpers.standard_payload( + invoice="INV-PCQ-WIRE-IREFUND-FULL", + original_transaction_key="ABC123", + ), + ).instantRefund() + + assert recorded_action(recording_mock) == "instantRefund" + request = recorded_request(recording_mock) + assert request["Services"]["ServiceList"][0]["Name"] == "payconiq" + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.00 + assert "AmountDebit" not in request + + def test_payconiq_instant_refund_partial_sends_refund_amount_as_credit( + self, recording_buckaroo, recording_mock + ): + """Partial refund (``refund_amount`` in payload): AmountCredit is that amount.""" + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.success_response( + { + "Services": [ + {"Name": "payconiq", "Action": "InstantRefund", "Parameters": []} + ], + "ServiceCode": "payconiq", + "AmountCredit": 4.00, + "AmountDebit": None, + } + ), + ) + ) + recording_buckaroo.payments.create_payment( + "payconiq", + Helpers.standard_payload( + invoice="INV-PCQ-WIRE-IREFUND-PARTIAL", + original_transaction_key="ABC123", + refund_amount=4.00, + ), + ).instantRefund() + + assert recorded_action(recording_mock) == "instantRefund" + request = recorded_request(recording_mock) + assert request["Services"]["ServiceList"][0]["Name"] == "payconiq" + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 4.00 + assert "AmountDebit" not in request + + def test_payconiq_instant_refund_raises_without_original_transaction_key( + self, recording_buckaroo + ): + """A missing ``original_transaction_key`` must fail fast, matching iDEAL.""" + with pytest.raises(ValueError, match="Original transaction key is required"): + recording_buckaroo.payments.create_payment( + "payconiq", + Helpers.standard_payload(invoice="INV-PCQ-WIRE-IREFUND-NOKEY"), + ).instantRefund() + + def test_payconiq_instant_refund_surfaces_api_rejection_as_failed_response( + self, buckaroo, mock_strategy + ): + """A rejected instant refund is surfaced as a standard failed response, + not an exception.""" + response_body = Helpers.failed_response( + "Refund rejected", + overrides={ + "Services": [{"Name": "payconiq", "Action": "InstantRefund", "Parameters": []}], + "ServiceCode": "payconiq", + "AmountCredit": None, + "AmountDebit": 10.00, + }, + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + payload = Helpers.standard_payload( + invoice="INV-PCQ-IREFUND-REJECTED", + original_transaction_key="ABC123", + ) + response = buckaroo.payments.create_payment("payconiq", payload).instantRefund() + + assert response.is_failed() + assert not response.is_successful() + assert response.key == response_body["Key"] diff --git a/tests/feature/payments/test_payperemail.py b/tests/feature/payments/test_payperemail.py new file mode 100644 index 0000000..1c89353 --- /dev/null +++ b/tests/feature/payments/test_payperemail.py @@ -0,0 +1,47 @@ +"""Feature test: PayPerEmail PaymentInvitation round-trip through the full stack. + +Pay Per Email emails the shopper a payment link instead of returning an inline +redirect. The ``PaymentInvitation`` action carries the customer identity as +service parameters; this pins that the action and those params reach the wire. +""" + +from tests.support.helpers import Helpers +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_service_parameters + + +class TestPayPerEmailFeature: + def test_payment_invitation_sends_action_and_customer_params(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + {"Name": "payperemail", "Action": "PaymentInvitation", "Parameters": []} + ], + "ServiceCode": "payperemail", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + + response = buckaroo.payments.create_payment( + "payperemail", + Helpers.standard_payload( + invoice="INV-PPE-001", + description="Pay per email invite", + service_parameters={ + "CustomerEmail": "jane@example.com", + "CustomerFirstName": "Jane", + "CustomerLastName": "Doe", + "CustomerGender": "1", + }, + ), + ).execute_action("PaymentInvitation") + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "PaymentInvitation" + + sent = {p["Name"].lower(): p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert sent["customeremail"] == "jane@example.com" + assert sent["customerfirstname"] == "Jane" + assert sent["customerlastname"] == "Doe" + assert sent["customergender"] == "1" 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/feature/solutions/test_emandate.py b/tests/feature/solutions/test_emandate.py new file mode 100644 index 0000000..e43750b --- /dev/null +++ b/tests/feature/solutions/test_emandate.py @@ -0,0 +1,246 @@ +import pytest + +from buckaroo.exceptions._parameter_validation_error import RequiredParameterMissingError +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers +from tests.support.recording_mock import ( + recorded_action, + recorded_request, + recorded_service_parameters, +) + + +class TestEmandateFeature: + """Feature tests for the eMandate solution.""" + + def test_issuer_list_returns_issuers(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "GetIssuerList", + "Parameters": [ + {"Name": "Issuer", "Value": "ABNANL2A"}, + {"Name": "IssuerName", "Value": "ABN AMRO"}, + ], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution("emandate").issuer_list() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert response.get_service_parameter("Issuer") == "ABNANL2A" + assert response.get_service_parameter("IssuerName") == "ABN AMRO" + assert recorded_action(mock_strategy) == "GetIssuerList" + + def test_emandate_is_available(self, buckaroo): + assert buckaroo.solutions.is_method_supported("emandate") + + def test_create_mandate_posts_service_parameters_and_returns_mandate_id( + self, buckaroo, mock_strategy + ): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "CreateMandate", + "Parameters": [{"Name": "MandateId", "Value": "MND-999"}], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandate", + { + "service_parameters": { + "debtorReference": "DEBTOR-999", + "sequenceType": "1", + "purchaseId": "PUR-1", + } + }, + ).create_mandate() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert response.get_service_parameter("MandateId") == "MND-999" + assert recorded_action(mock_strategy) == "CreateMandate" + + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Debtorreference"] == "DEBTOR-999" + assert params["Sequencetype"] == "1" + assert params["Purchaseid"] == "PUR-1" + + def test_create_mandate_without_debtor_reference_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("emandate") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_mandate() + + assert exc.value.parameter_name == "debtorReference" + + def test_status_posts_mandate_id_and_returns_status(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "GetStatus", + "Parameters": [{"Name": "Status", "Value": "Active"}], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandate", + {"service_parameters": {"mandateId": "MND-777"}}, + ).status() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert response.get_service_parameter("Status") == "Active" + assert recorded_action(mock_strategy) == "GetStatus" + + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Mandateid"] == "MND-777" + + def test_status_without_mandate_id_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("emandate") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.status() + + assert exc.value.parameter_name == "mandateId" + + def test_modify_mandate_posts_service_parameters_and_returns_mandate_id( + self, buckaroo, mock_strategy + ): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "ModifyMandate", + "Parameters": [{"Name": "MandateId", "Value": "MND-555"}], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandate", + { + "service_parameters": { + "mandateId": "MND-555", + "maxAmount": "1000.00", + } + }, + ).modify_mandate() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert response.get_service_parameter("MandateId") == "MND-555" + assert recorded_action(mock_strategy) == "ModifyMandate" + + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Mandateid"] == "MND-555" + assert params["Maxamount"] == "1000.00" + + def test_modify_mandate_without_mandate_id_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("emandate") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.modify_mandate() + + assert exc.value.parameter_name == "mandateId" + + def test_cancel_mandate_posts_service_parameters_and_returns_response( + self, buckaroo, mock_strategy + ): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandate", + "Action": "CancelMandate", + "Parameters": [], + } + ], + "ServiceCode": "emandate", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandate", + { + "service_parameters": { + "mandateId": "MND-321", + "purchaseId": "PUR-2", + } + }, + ).cancel_mandate() + + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + assert recorded_action(mock_strategy) == "CancelMandate" + + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(mock_strategy)} + assert params["Mandateid"] == "MND-321" + assert params["Purchaseid"] == "PUR-2" + + def test_cancel_mandate_without_mandate_id_raises_before_wire(self, buckaroo): + builder = buckaroo.solutions.create_solution("emandate") + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.cancel_mandate() + + assert exc.value.parameter_name == "mandateId" + + +class TestEmandateB2BFeature: + """Feature tests for the Business (B2B) eMandate variant.""" + + def test_b2b_is_available(self, buckaroo): + assert buckaroo.solutions.is_method_supported("emandateb2b") + + def test_create_mandate_posts_under_emandateb2b_service_name(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + { + "Name": "emandateb2b", + "Action": "CreateMandate", + "Parameters": [{"Name": "MandateId", "Value": "MND-B2B-1"}], + } + ], + "ServiceCode": "emandateb2b", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + + response = buckaroo.solutions.create_solution( + "emandateb2b", + {"service_parameters": {"debtorReference": "DEBTOR-B2B-1"}}, + ).create_mandate() + + assert response.status.code.code == 190 + assert response.get_service_parameter("MandateId") == "MND-B2B-1" + assert recorded_action(mock_strategy) == "CreateMandate" + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + assert service["Name"].lower() == "emandateb2b" diff --git a/tests/feature/solutions/test_marketplaces.py b/tests/feature/solutions/test_marketplaces.py new file mode 100644 index 0000000..b418bb0 --- /dev/null +++ b/tests/feature/solutions/test_marketplaces.py @@ -0,0 +1,308 @@ +import pytest + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers +from tests.support.recording_mock import recorded_action, recorded_request + + +def _ideal_payload(**overrides): + """Base iDEAL transaction fields used to fund a split.""" + payload = { + "currency": "EUR", + "amount": 95.00, + "description": "Split order INV0001", + "invoice": "INV0001", + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + "service_parameters": {"issuer": "ABNANL2A"}, + } + payload.update(overrides) + return payload + + +def _split_response(): + """Buckaroo-shaped Split response carrying the SplitGuid identifiers.""" + return Helpers.success_response( + { + "Services": [ + { + "Name": "Marketplaces", + "Action": None, + "Parameters": [ + {"Name": "SplitGuid_Marketplace", "Value": "GUID-MARKETPLACE"}, + {"Name": "SplitGuid_Seller_1", "Value": "GUID-SELLER-1"}, + ], + }, + {"Name": "ideal", "Action": None, "Parameters": []}, + ], + "ServiceCode": "ideal", + } + ) + + +class TestMarketplacesSplit: + """Split rides on an iDEAL payment via combine().""" + + def test_marketplaces_is_available(self, buckaroo): + assert buckaroo.solutions.is_method_supported("marketplaces") + + def test_service_name_is_marketplaces(self, buckaroo): + assert ( + buckaroo.solutions.create_solution("marketplaces").get_service_name() == "Marketplaces" + ) + + def test_split_combines_marketplaces_into_ideal_pay(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _split_response()) + ) + + mp = buckaroo.solutions.create_solution("marketplaces").split( + { + "daysUntilTransfer": "2", + "marketplace": {"Amount": "10.00", "Description": "INV0001 Commission Platform"}, + "sellers": [ + {"AccountId": "789C60F316D24B088ACD471", "Amount": "50.00"}, + {"AccountId": "369C60F316D24B088ACD238", "Amount": "35.00"}, + ], + } + ) + response = buckaroo.payments.create_payment("ideal", _ideal_payload()).combine(mp).pay() + + body = recorded_request(mock_strategy) + services = body["Services"]["ServiceList"] + # Payment method first, combined Marketplaces service second. + assert [s["Name"] for s in services] == ["ideal", "Marketplaces"] + assert services[0]["Action"] == "Pay" + assert services[1]["Action"] == "Split" + assert body["Currency"] == "EUR" + assert body["AmountDebit"] == 95.00 + + ideal_params = {p["Name"]: p["Value"] for p in services[0]["Parameters"]} + assert ideal_params["Issuer"] == "ABNANL2A" + + split = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in services[1]["Parameters"] + } + assert split[("Daysuntiltransfer", "", "")] == "2" + assert split[("Amount", "Marketplace", "")] == "10.00" + assert split[("Accountid", "Seller", "1")] == "789C60F316D24B088ACD471" + assert split[("Accountid", "Seller", "2")] == "369C60F316D24B088ACD238" + + assert response.get_service_parameter("SplitGuid_Marketplace") == "GUID-MARKETPLACE" + assert response.get_service_parameter("SplitGuid_Seller_1") == "GUID-SELLER-1" + + +def _datarequest_response(action): + return Helpers.success_response({"Services": None, "ServiceCode": "Marketplaces"}) + + +class TestMarketplacesTransfer: + """Standalone Transfer posts a DataRequest.""" + + def test_transfer_all_posts_datarequest(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("Transfer") + ) + ) + + response = buckaroo.solutions.create_solution("marketplaces").transfer( + {"originalTransactionKey": "D3732474ED0"} + ) + + body = recorded_request(mock_strategy) + services = body["Services"]["ServiceList"] + assert body["OriginalTransactionKey"] == "D3732474ED0" + assert "AmountDebit" not in body + assert len(services) == 1 + assert services[0]["Name"] == "Marketplaces" + assert recorded_action(mock_strategy) == "Transfer" + assert "Parameters" not in services[0] + assert response.status.code.code == 190 + + def test_transfer_with_splits_posts_grouped_params(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("Transfer") + ) + ) + + buckaroo.solutions.create_solution("marketplaces").transfer( + { + "originalTransactionKey": "D3732474ED0", + "marketplace": {"Amount": "10.00", "Description": "Commission"}, + "sellers": [{"AccountId": "789C60F316D24B088ACD471", "Amount": "50.00"}], + } + ) + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + assert service["Action"] == "Transfer" + params = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in service["Parameters"] + } + assert params[("Amount", "Marketplace", "")] == "10.00" + assert params[("Accountid", "Seller", "1")] == "789C60F316D24B088ACD471" + # DaysUntilTransfer never applies to a Transfer. + assert not any(name == "Daysuntiltransfer" for name, _, _ in params) + + def test_transfer_requires_original_key(self, buckaroo): + with pytest.raises(ValueError): + buckaroo.solutions.create_solution("marketplaces").transfer({}) + + +def _refund_supplementary_response(): + return Helpers.success_response( + { + "Services": [{"Name": "ideal", "Action": None, "Parameters": []}], + "ServiceCode": "ideal", + "AmountCredit": 50.00, + "AmountDebit": None, + } + ) + + +class TestMarketplacesRefundSupplementary: + """RefundSupplementary rides on an iDEAL refund via combine().""" + + def test_refund_supplementary_all_combines_into_refund(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _refund_supplementary_response()) + ) + + mp = buckaroo.solutions.create_solution("marketplaces").refund_supplementary() + payload = Helpers.standard_payload( + invoice="INV0001", + original_transaction_key="D3737EDA42474ED0", + refund_amount=50.00, + ) + response = buckaroo.payments.create_payment("ideal", payload).combine(mp).refund() + + body = recorded_request(mock_strategy) + services = body["Services"]["ServiceList"] + assert [(s["Name"], s["Action"]) for s in services] == [ + ("ideal", "Refund"), + ("Marketplaces", "RefundSupplementary"), + ] + assert body["OriginalTransactionKey"] == "D3737EDA42474ED0" + assert body["AmountCredit"] == 50.00 + assert "Parameters" not in services[1] + assert response.status.code.code == 190 + + def test_refund_supplementary_with_sellers(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _refund_supplementary_response()) + ) + + mp = buckaroo.solutions.create_solution("marketplaces").refund_supplementary( + {"sellers": [{"AccountId": "789C60F316D24B088ACD471", "Amount": "30.00"}]} + ) + payload = Helpers.standard_payload( + invoice="INV0001", + original_transaction_key="D3737EDA42474ED0", + refund_amount=30.00, + ) + buckaroo.payments.create_payment("ideal", payload).combine(mp).refund() + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][1] + assert service["Action"] == "RefundSupplementary" + params = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in service["Parameters"] + } + assert params[("Accountid", "Seller", "1")] == "789C60F316D24B088ACD471" + assert params[("Amount", "Seller", "1")] == "30.00" + + +class TestMarketplacesManualTransfer: + """Standalone ManualTransfer posts a DataRequest.""" + + def test_manual_transfer_posts_accounts_and_amount(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("ManualTransfer") + ) + ) + + response = buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAAAAAAAAAAAAAAAAAA", + "toAccountId": "BBBBBBBBBBBBBBBBBBB", + "fromDescription": "Deduction monthly fee", + "toDescription": "Monthly fee third party ABC", + "amount": 10.00, + "currency": "EUR", + "invoice": "INV0001", + } + ) + + body = recorded_request(mock_strategy) + service = body["Services"]["ServiceList"][0] + assert service["Name"] == "Marketplaces" + assert service["Action"] == "ManualTransfer" + assert body["Currency"] == "EUR" + assert body["Amount"] == 10.00 + assert "AmountDebit" not in body + + params = {p["Name"]: p["Value"] for p in service["Parameters"]} + assert params["Fromaccountid"] == "AAAAAAAAAAAAAAAAAAA" + assert params["Toaccountid"] == "BBBBBBBBBBBBBBBBBBB" + assert params["Fromdescription"] == "Deduction monthly fee" + assert params["Todescription"] == "Monthly fee third party ABC" + assert response.status.code.code == 190 + + def test_manual_transfer_requires_all_fields(self, buckaroo): + with pytest.raises(ValueError): + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "", + "fromDescription": "fee", + "toDescription": "fee", + "amount": 10.00, + } + ) + + def test_manual_transfer_omits_invoice_when_absent(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("ManualTransfer") + ) + ) + + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "BBB", + "fromDescription": "fee", + "toDescription": "fee", + "amount": 10.00, + "currency": "EUR", + } + ) + + assert "Invoice" not in recorded_request(mock_strategy) + + def test_manual_transfer_requires_amount(self, buckaroo): + with pytest.raises(ValueError, match="amount"): + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "BBB", + "fromDescription": "fee", + "toDescription": "fee", + "currency": "EUR", + } + ) + + def test_manual_transfer_requires_currency(self, buckaroo): + with pytest.raises(ValueError, match="currency"): + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "BBB", + "fromDescription": "fee", + "toDescription": "fee", + "amount": 10.00, + } + ) diff --git a/tests/support/helpers.py b/tests/support/helpers.py index 2a5dc5f..2ba10d3 100644 --- a/tests/support/helpers.py +++ b/tests/support/helpers.py @@ -235,6 +235,10 @@ def assert_instant_refund_returns_success( assert response.status.code.code == STATUS_SUCCESS assert response.key == response_body["Key"] _assert_recorded_action(mock_strategy, "instantRefund") + sent = json.loads(mock_strategy.calls[-1]["data"]) + assert sent["OriginalTransactionKey"] == original_transaction_key + assert sent["AmountCredit"] == 10.00 + assert "AmountDebit" not in sent return response @staticmethod diff --git a/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py b/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py index 25107e6..265aca3 100644 --- a/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py +++ b/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py @@ -10,6 +10,8 @@ from __future__ import annotations +import pytest + from buckaroo.builders.payments.capabilities.bank_transfer_capabilities import ( BankTransferCapabilities, ) @@ -22,7 +24,7 @@ from buckaroo.models.payment_response import PaymentResponse from tests.support.builders import make_test_builder, populate_required_fields from tests.support.mock_request import BuckarooMockRequest -from tests.support.recording_mock import recorded_action, wire_recording_http +from tests.support.recording_mock import recorded_action, recorded_request, wire_recording_http # --------------------------------------------------------------------------- @@ -69,7 +71,7 @@ def test_posts_action_instantRefund(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) assert recorded_action(mock) == "instantRefund" @@ -78,7 +80,7 @@ def test_posts_to_transaction_endpoint(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) assert len(mock.calls) == 1 call = mock.calls[0] @@ -96,11 +98,30 @@ def test_returns_payment_response(self): ) builder = _ready_builder(client) - response = builder.instantRefund(validate=False) + response = builder.instantRefund("ABC123", validate=False) assert isinstance(response, PaymentResponse) assert response.key == "refund-123" + def test_sends_original_transaction_key_and_amount_credit_on_the_wire(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.instantRefund("ABC123", validate=False) + + request = recorded_request(mock) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.0 + assert "AmountDebit" not in request + + def test_raises_value_error_without_original_transaction_key(self): + _mock, client = wire_recording_http() + builder = _ready_builder(client) + + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.instantRefund(validate=False) + # --------------------------------------------------------------------------- # payFastCheckout() diff --git a/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py b/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py index b024c52..e27be32 100644 --- a/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py +++ b/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py @@ -52,7 +52,7 @@ def test_posts_action_instantRefund(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) assert recorded_action(mock) == "instantRefund" @@ -61,7 +61,7 @@ def test_posts_to_transaction_endpoint(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) assert len(mock.calls) == 1 call = mock.calls[0] @@ -73,7 +73,7 @@ def test_posts_expected_service_name(self): mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) builder = _ready_builder(client) - builder.instantRefund(validate=False) + builder.instantRefund("ABC123", validate=False) service = recorded_request(mock)["Services"]["ServiceList"][0] assert service["Name"] == "ideal" @@ -89,11 +89,54 @@ def test_returns_payment_response(self): ) builder = _ready_builder(client) - response = builder.instantRefund(validate=False) + response = builder.instantRefund("ABC123", validate=False) assert isinstance(response, PaymentResponse) assert response.key == "refund-123" + def test_raises_value_error_without_original_transaction_key(self): + _mock, client = wire_recording_http() + builder = _ready_builder(client) + + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.instantRefund(validate=False) + + def test_full_refund_sends_original_transaction_key_and_swaps_amount_to_credit(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.instantRefund("ABC123", validate=False) + + request = recorded_request(mock) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.0 + assert "AmountDebit" not in request + + def test_partial_refund_uses_refund_amount_from_payload(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + builder._payload["refund_amount"] = 3.25 + + builder.instantRefund("ABC123", validate=False) + + request = recorded_request(mock) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 3.25 + assert "AmountDebit" not in request + + def test_reads_original_transaction_key_from_payload_fallback(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + builder._payload["original_transaction_key"] = "FROM-PAYLOAD" + + builder.instantRefund(validate=False) + + request = recorded_request(mock) + assert request["OriginalTransactionKey"] == "FROM-PAYLOAD" + def test_forwards_validate_flag_true_triggers_validation(self): """``validate=True`` runs the real service-parameter validator against ``get_allowed_service_parameters("instantRefund")`` — so declaring a @@ -111,6 +154,6 @@ def test_forwards_validate_flag_true_triggers_validation(self): ) with pytest.raises(RequiredParameterMissingError) as exc: - builder.instantRefund(validate=True) + builder.instantRefund("ABC123", validate=True) assert exc.value.parameter_name == "refund_reason" diff --git a/tests/unit/builders/payments/test_banking_builder.py b/tests/unit/builders/payments/test_banking_builder.py new file mode 100644 index 0000000..6b6a0ca --- /dev/null +++ b/tests/unit/builders/payments/test_banking_builder.py @@ -0,0 +1,131 @@ +"""Unit coverage for :class:`BankingBuilder`. + +Banking is a payout (PaymentOrder action): Buckaroo sends money OUT to a +bank account given an IBAN and account holder name. Unlike the base Pay +flow it has no redirect, so it overrides ``required_fields`` to drop the +``return_url*`` requirements, and it swaps ``AmountDebit`` for +``AmountCredit`` on the wire (same precedent as ``refund()`` and +``cancelAuthorize()``). +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.banking_builder import BankingBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +@pytest.fixture +def builder(client: BuckarooClient) -> BankingBuilder: + return BankingBuilder(client) + + +# --------------------------------------------------------------------------- +# Construction + + +def test_instantiates_as_payment_builder(builder: BankingBuilder) -> None: + assert isinstance(builder, BankingBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_construction_binds_client(builder: BankingBuilder, client: BuckarooClient) -> None: + assert builder._client is client + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_banking(builder: BankingBuilder) -> None: + assert builder.get_service_name() == "Banking" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — PaymentOrder action snapshot + + +def test_get_allowed_service_parameters_paymentorder_snapshot( + builder: BankingBuilder, +) -> None: + assert builder.get_allowed_service_parameters("PaymentOrder") == { + "accountholdername": { + "type": str, + "required": True, + "description": "Account holder name", + }, + "iban": { + "type": str, + "required": True, + "description": "IBAN", + }, + } + + +def test_get_allowed_service_parameters_paymentorder_case_insensitive( + builder: BankingBuilder, +) -> None: + assert builder.get_allowed_service_parameters( + "paymentorder" + ) == builder.get_allowed_service_parameters("PaymentOrder") + + +@pytest.mark.parametrize("action", ["Pay", "Refund", "Authorize", "Capture", "Unknown"]) +def test_get_allowed_service_parameters_non_paymentorder_actions_return_empty( + builder: BankingBuilder, action: str +) -> None: + assert builder.get_allowed_service_parameters(action) == {} + + +# --------------------------------------------------------------------------- +# required_fields — no return URLs required + + +def test_required_fields_currency_amount_and_invoice(builder: BankingBuilder) -> None: + assert builder.required_fields("PaymentOrder") == { + "currency": None, + "amount_debit": None, + "invoice": None, + } + + +def test_build_succeeds_without_return_urls(builder: BankingBuilder) -> None: + builder.currency("EUR").amount(150.0).invoice("Banking_Test_1") + # Should not raise even though no return_url* fields were set. + payment_request = builder.build("PaymentOrder", validate=False) + assert payment_request.currency == "EUR" + + +# --------------------------------------------------------------------------- +# End-to-end payment_order via MockBuckaroo + + +def test_payment_order_posts_banking_service_with_amount_credit(): + mock, stub_client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "BNK-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = BankingBuilder(stub_client) + builder.currency("EUR").amount(150.0).invoice("Banking_Test_1").description("Test") + builder.add_parameter("AccountHolderName", "Arensman") + builder.add_parameter("IBAN", "NL44RABO0123456789") + + response = builder.payment_order(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "Banking" + assert service["Action"] == "PaymentOrder" + assert sent["AmountCredit"] == 150.0 + assert "AmountDebit" not in sent + assert response.key == "BNK-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_concrete_builders_contract.py b/tests/unit/builders/payments/test_concrete_builders_contract.py index a66dbe8..181974a 100644 --- a/tests/unit/builders/payments/test_concrete_builders_contract.py +++ b/tests/unit/builders/payments/test_concrete_builders_contract.py @@ -144,6 +144,7 @@ def test_capability_method_present_and_callable( EXPECTED_CAPABILITIES: Dict[str, set] = { "creditcard": {EncryptedPayCapable, AuthorizeCaptureCapable}, "riverty": {AuthorizeCaptureCapable}, + "in3": {AuthorizeCaptureCapable}, "ideal": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, "paybybank": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, "payconiq": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, diff --git a/tests/unit/builders/payments/test_idin_builder.py b/tests/unit/builders/payments/test_idin_builder.py new file mode 100644 index 0000000..8683e6b --- /dev/null +++ b/tests/unit/builders/payments/test_idin_builder.py @@ -0,0 +1,85 @@ +"""Unit coverage for :class:`IdinBuilder`. + +iDIN drives three DataRequest actions — ``identify`` (full identification), +``verify`` (age verification), and ``login`` (unique consumer ID) — each +carrying a single ``issuerId`` (BIC code of the consumer's bank) service +parameter. Bank personal data arrives later via push; the immediate response +only carries the redirect, so no custom response model is needed. +""" + +from __future__ import annotations + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.idin_builder import IdinBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.factories.payment_method_factory import PaymentMethodFactory + + +def test_builder_instantiates_as_payment_builder(client: BuckarooClient) -> None: + builder = IdinBuilder(client) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_idin(client: BuckarooClient) -> None: + assert IdinBuilder(client).get_service_name() == "Idin" + + +_ISSUER_ID_SPEC = { + "issuerId": { + "type": str, + "required": True, + "description": "BIC code of the issuing bank of the consumer", + }, +} + + +def test_get_allowed_service_parameters_identify_requires_issuer_id( + client: BuckarooClient, +) -> None: + assert IdinBuilder(client).get_allowed_service_parameters("identify") == _ISSUER_ID_SPEC + + +def test_get_allowed_service_parameters_verify_requires_issuer_id( + client: BuckarooClient, +) -> None: + assert IdinBuilder(client).get_allowed_service_parameters("verify") == _ISSUER_ID_SPEC + + +def test_get_allowed_service_parameters_login_requires_issuer_id( + client: BuckarooClient, +) -> None: + assert IdinBuilder(client).get_allowed_service_parameters("login") == _ISSUER_ID_SPEC + + +def test_get_allowed_service_parameters_is_case_insensitive(client: BuckarooClient) -> None: + builder = IdinBuilder(client) + assert builder.get_allowed_service_parameters( + "Identify" + ) == builder.get_allowed_service_parameters("identify") + + +def test_get_allowed_service_parameters_unknown_action_returns_empty( + client: BuckarooClient, +) -> None: + assert IdinBuilder(client).get_allowed_service_parameters("Pay") == {} + + +def test_required_fields_only_return_url_family(client: BuckarooClient) -> None: + builder = ( + IdinBuilder(client) + .return_url("https://example.com/return") + .return_url_cancel("https://example.com/cancel") + .return_url_error("https://example.com/error") + .return_url_reject("https://example.com/reject") + ) + assert builder.required_fields("identify") == { + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + } + + +def test_create_builder_returns_idin_builder(client: BuckarooClient) -> None: + builder = PaymentMethodFactory.create_builder("idin", client) + assert isinstance(builder, IdinBuilder) diff --git a/tests/unit/builders/payments/test_in3_builder.py b/tests/unit/builders/payments/test_in3_builder.py index fef7206..ad85748 100644 --- a/tests/unit/builders/payments/test_in3_builder.py +++ b/tests/unit/builders/payments/test_in3_builder.py @@ -43,9 +43,21 @@ def test_get_allowed_service_parameters_pay_snapshot(client): "required": True, "description": "IN3 articles", }, + "route": { + "type": str, + "required": False, + "description": ("In3 acquirer route, e.g. 'abn_b2b' for ABN-AMRO Achteraf Betalen"), + }, } +def test_get_allowed_service_parameters_pay_allows_optional_route(client): + allowed = In3Builder(client).get_allowed_service_parameters("Pay") + + assert "route" in allowed + assert allowed["route"]["required"] is False + + def test_get_allowed_service_parameters_pay_is_case_insensitive(client): builder = In3Builder(client) @@ -54,11 +66,59 @@ def test_get_allowed_service_parameters_pay_is_case_insensitive(client): ) -@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "UnknownAction"]) +def test_get_allowed_service_parameters_authorize_snapshot(client): + builder = In3Builder(client) + + authorize = builder.get_allowed_service_parameters("Authorize") + + # Authorize allows every Pay param plus the required Route parameter + # (In3 Authorize only exists for the ABN AMRO "Zakelijk op rekening" flow). + assert authorize == { + **builder.get_allowed_service_parameters("Pay"), + "route": { + "type": str, + "required": True, + "description": 'Financing route, e.g. "abn_b2b" for ABN AMRO business', + }, + } + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "UnknownAction"]) def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): assert In3Builder(client).get_allowed_service_parameters(action) == {} +def test_build_pay_with_route_keeps_route_parameter(client): + request = ( + populate_required_fields(In3Builder(client), amount=99.95) + .add_parameter("billingCustomer", [{"Name": "John"}]) + .add_parameter("shippingCustomer", [{"Name": "John"}]) + .add_parameter("article", [{"Description": "Widget", "Quantity": 1}]) + .add_parameter("route", "abn_b2b") + .build("Pay") + ) + + service = request.services.services[0] + route_params = [p for p in service.parameters if p.name == "Route"] + + assert len(route_params) == 1 + assert route_params[0].value == "abn_b2b" + + +def test_build_pay_without_route_is_unchanged(client): + request = ( + populate_required_fields(In3Builder(client), amount=99.95) + .add_parameter("billingCustomer", [{"Name": "John"}]) + .add_parameter("shippingCustomer", [{"Name": "John"}]) + .add_parameter("article", [{"Description": "Widget", "Quantity": 1}]) + .build("Pay") + ) + + service = request.services.services[0] + + assert all(p.name != "Route" for p in service.parameters) + + def test_pay_posts_transaction_and_parses_response(client, mock_strategy): mock_strategy.queue( BuckarooMockRequest.json( diff --git a/tests/unit/builders/payments/test_klarna_builder.py b/tests/unit/builders/payments/test_klarna_builder.py index cf92b4e..739e46c 100644 --- a/tests/unit/builders/payments/test_klarna_builder.py +++ b/tests/unit/builders/payments/test_klarna_builder.py @@ -1,10 +1,11 @@ """Per-builder unit tests for :class:`KlarnaBuilder`. -Covers construction, service-name shape, allowed-parameter snapshots for every -supported action including the grouped article / line-item structure, the -mixin-free baseline (KlarnaBuilder composes no capability mixins despite the -docstring mention of "bank transfer capabilities"), and an end-to-end -``pay()`` dispatch through ``MockBuckaroo``. Phase 7.20. +Klarna MOR no longer returns a reservation number: every follow-up action +(``CancelReservation``, ``UpdateReservation``, ``ExtendReservation``, +``AddShippingInfo``) is a DataRequest keyed on the Buckaroo ``DataRequestKey`` +service parameter and posts to ``/json/DataRequest``. ``Pay``/``Refund`` remain +transaction requests. The source also narrows :meth:`required_fields` per +action so the DataRequest methods pass ``_validate_required_fields``. """ from __future__ import annotations @@ -15,6 +16,7 @@ from tests.support.builders import populate_required_fields from tests.support.mock_buckaroo import MockBuckaroo from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_request, wire_recording_http def test_construct_with_buckaroo_client_returns_payment_builder(client): @@ -26,15 +28,39 @@ def test_get_service_name_returns_klarna(client): assert KlarnaBuilder(client).get_service_name() == "klarna" +# --------------------------------------------------------------------------- +# get_allowed_service_parameters + + def test_get_allowed_service_parameters_pay_snapshot(client): - """Pay-as-capture references the prior Reserve via ``dataRequestKey`` as a - service parameter. Cart contents are reused server-side from the Reserve.""" + """Pay-as-capture references the prior Reserve via ``dataRequestKey`` and + optionally carries partial-delivery articles and shipping details.""" assert KlarnaBuilder(client).get_allowed_service_parameters("Pay") == { "dataRequestKey": { "type": str, "required": True, "description": "Key of the prior Klarna Reserve", }, + "article": { + "type": list, + "required": False, + "description": "Articles to pay for on a partial delivery", + }, + "shippingMethod": { + "type": str, + "required": False, + "description": "Shipping method", + }, + "company": { + "type": str, + "required": False, + "description": "Shipping company name", + }, + "trackingNumber": { + "type": str, + "required": False, + "description": "Shipping tracking number", + }, } @@ -47,20 +73,9 @@ def test_get_allowed_service_parameters_is_case_insensitive_for_pay(client): def test_get_allowed_service_parameters_reserve_snapshot(client): - """Reserve action drives the Klarna MOR hosted-page flow. Same required - cart trio as Pay (billingCustomer, shippingCustomer, article) plus - optional Klarna-specific keys (operatingCountry, pno, gender, locale).""" + """Reserve drives the Klarna MOR hosted-page flow. Per BPS docs only the + article list and operatingCountry are mandatory; customer info is optional.""" assert KlarnaBuilder(client).get_allowed_service_parameters("Reserve") == { - "billingCustomer": { - "type": list, - "required": True, - "description": "Billing customer information", - }, - "shippingCustomer": { - "type": list, - "required": True, - "description": "Shipping customer information", - }, "article": { "type": list, "required": True, @@ -68,9 +83,19 @@ def test_get_allowed_service_parameters_reserve_snapshot(client): }, "operatingCountry": { "type": str, - "required": False, + "required": True, "description": "Operating country code", }, + "billingCustomer": { + "type": list, + "required": False, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": False, + "description": "Shipping customer information", + }, "pno": { "type": str, "required": False, @@ -96,8 +121,94 @@ def test_get_allowed_service_parameters_is_case_insensitive_for_reserve(client): ) == builder.get_allowed_service_parameters("Reserve") +def test_get_allowed_service_parameters_cancelreservation_requires_data_request_key(client): + """CancelReservation and ExtendReservation only need the DataRequestKey.""" + expected = { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Buckaroo data request key of the prior Reserve", + }, + } + builder = KlarnaBuilder(client) + assert builder.get_allowed_service_parameters("CancelReservation") == expected + assert builder.get_allowed_service_parameters("cancelreservation") == expected + + +def test_get_allowed_service_parameters_extendreservation_matches_cancel(client): + builder = KlarnaBuilder(client) + assert builder.get_allowed_service_parameters( + "ExtendReservation" + ) == builder.get_allowed_service_parameters("CancelReservation") + + +def test_get_allowed_service_parameters_updatereservation_snapshot(client): + assert KlarnaBuilder(client).get_allowed_service_parameters("UpdateReservation") == { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Buckaroo data request key of the prior Reserve", + }, + "article": { + "type": list, + "required": False, + "description": "Updated Klarna articles", + }, + "shippingCustomer": { + "type": list, + "required": False, + "description": "Updated shipping customer information", + }, + } + + def test_get_allowed_service_parameters_unsupported_action_returns_empty(client): + # Refund carries no service params; AddShippingInfo is not a klarna action + # (the gateway rejects it — shipping details ride on Pay). assert KlarnaBuilder(client).get_allowed_service_parameters("Refund") == {} + assert KlarnaBuilder(client).get_allowed_service_parameters("AddShippingInfo") == {} + + +# --------------------------------------------------------------------------- +# required_fields override + + +class TestRequiredFields: + def test_reserve_requires_currency_and_invoice(self, client): + builder = KlarnaBuilder(client).currency("EUR").invoice("INV-1") + assert builder.required_fields("Reserve") == {"currency": "EUR", "invoice": "INV-1"} + + def test_pay_requires_currency_amount_and_invoice(self, client): + """The gateway rejects Pay without an invoice number.""" + builder = KlarnaBuilder(client).currency("EUR").amount(12.34).invoice("INV-9") + assert builder.required_fields("Pay") == { + "currency": "EUR", + "amount_debit": 12.34, + "invoice": "INV-9", + } + + def test_data_request_actions_require_nothing(self, client): + builder = KlarnaBuilder(client) + assert builder.required_fields("CancelReservation") == {} + assert builder.required_fields("UpdateReservation") == {} + assert builder.required_fields("ExtendReservation") == {} + + def test_refund_falls_through_to_base(self, client): + """Refund keeps the full base required-field set (like every builder).""" + assert set(KlarnaBuilder(client).required_fields("Refund")) == { + "currency", + "amount_debit", + "description", + "invoice", + "return_url", + "return_url_cancel", + "return_url_error", + "return_url_reject", + } + + +# --------------------------------------------------------------------------- +# Pay / Refund dispatch through the transaction endpoint def test_pay_dispatches_klarna_service_through_mock_buckaroo(): @@ -120,45 +231,98 @@ def test_pay_dispatches_klarna_service_through_mock_buckaroo(): mock.assert_all_consumed() -def test_get_allowed_service_parameters_cancelreservation_is_empty(client): - """CancelReservation only needs OriginalTransactionKey at request level.""" - assert KlarnaBuilder(client).get_allowed_service_parameters("CancelReservation") == {} - assert KlarnaBuilder(client).get_allowed_service_parameters("cancelreservation") == {} - +# --------------------------------------------------------------------------- +# DataRequest follow-up actions — post to /json/DataRequest with the action name -def test_cancel_reservation_requires_original_transaction_key(client): - """Missing key raises ValueError, mirroring cancelAuthorize.""" - import pytest - builder = populate_required_fields(KlarnaBuilder(client), amount=10.0) - with pytest.raises(ValueError, match="Original transaction key is required"): - builder.cancelReservation(original_transaction_key="") +class TestReserve: + def test_posts_reserve_to_data_request_endpoint(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KL-RES-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaBuilder(client).currency("EUR").invoice("INV-1") + + response = builder.reserve(validate=False) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "Reserve" + assert recorded_request(mock)["Services"]["ServiceList"][0]["Name"] == "klarna" + assert response.key == "KL-RES-1" + mock.assert_all_consumed() + + +class TestCancelReservation: + def test_posts_cancel_reservation_to_data_request_without_original_key(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KL-CAN-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaBuilder(client) + builder.add_parameter("dataRequestKey", "RES-DRK-1") + + response = builder.cancelReservation(validate=False) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "CancelReservation" + sent = recorded_request(mock) + assert "OriginalTransactionKey" not in sent + names = [p["Name"] for p in sent["Services"]["ServiceList"][0]["Parameters"]] + assert "Datarequestkey" in names + assert response.key == "KL-CAN-1" + mock.assert_all_consumed() + + +class TestUpdateReservation: + def test_posts_update_reservation_to_data_request(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KL-UPD-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaBuilder(client) + response = builder.updateReservation(validate=False) -def test_cancel_reservation_dispatches_action_with_original_transaction_key(): - """cancelReservation builds action="CancelReservation" with the key in payload.""" - from unittest.mock import MagicMock + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "UpdateReservation" + assert response.key == "KL-UPD-1" + mock.assert_all_consumed() - class _StubResponse: - def to_dict(self): - return {"Status": {"Code": {"Code": 190}}} - captured = {} +class TestExtendReservation: + def test_posts_extend_reservation_to_data_request(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KL-EXT-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaBuilder(client) - class _StubHttp: - def post(self, path, data): - captured["path"] = path - captured["data"] = data - return _StubResponse() + response = builder.extendReservation(validate=False) - stub_client = MagicMock() - stub_client.http_client = _StubHttp() + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "ExtendReservation" + assert response.key == "KL-EXT-1" + mock.assert_all_consumed() - builder = populate_required_fields(KlarnaBuilder(stub_client), amount=49.95) - response = builder.cancelReservation(original_transaction_key="RES-KEY-9") - assert response.to_dict()["Status"]["Code"]["Code"] == 190 - assert captured["path"] == "/json/transaction" - sent = captured["data"] - assert sent["OriginalTransactionKey"] == "RES-KEY-9" - assert sent["Services"]["ServiceList"][0]["Action"] == "CancelReservation" +def test_klarna_has_no_add_shipping_info_action(): + """AddShippingInfo is not a klarna action — the gateway rejects it, so the + builder exposes no such method (shipping details ride on Pay).""" + _, client = wire_recording_http() + assert not hasattr(KlarnaBuilder(client), "addShippingInfo") diff --git a/tests/unit/builders/payments/test_payconiq_builder.py b/tests/unit/builders/payments/test_payconiq_builder.py index 53ceb8c..e0a2152 100644 --- a/tests/unit/builders/payments/test_payconiq_builder.py +++ b/tests/unit/builders/payments/test_payconiq_builder.py @@ -15,6 +15,7 @@ ) from tests.support.mock_request import BuckarooMockRequest from tests.support.builders import populate_required_fields +from tests.support.recording_mock import recorded_action, recorded_request def test_construction_with_client_succeeds(client): @@ -135,10 +136,38 @@ def test_payconiq_instantRefund_works(client, mock_strategy): ) ) builder = populate_required_fields(PayconiqBuilder(client)) - response = builder.instantRefund(validate=False) + response = builder.instantRefund("ABC123", validate=False) assert response is not None +def test_payconiq_instantRefund_sends_original_transaction_key_and_amount_credit_on_wire( + client, mock_strategy +): + """InstantRefundCapable (inherited via BankTransferCapabilities) must put + OriginalTransactionKey + AmountCredit on the wire for Payconiq, action instantRefund.""" + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/transaction*", {"Key": "pcq-ir-2", "Status": {"Code": {"Code": 190}}} + ) + ) + builder = populate_required_fields(PayconiqBuilder(client)) + + builder.instantRefund("ABC123", validate=False) + + assert recorded_action(mock_strategy) == "instantRefund" + request = recorded_request(mock_strategy) + assert request["OriginalTransactionKey"] == "ABC123" + assert request["AmountCredit"] == 10.0 + assert "AmountDebit" not in request + + +def test_payconiq_instantRefund_raises_without_original_transaction_key(client, mock_strategy): + builder = populate_required_fields(PayconiqBuilder(client)) + + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.instantRefund(validate=False) + + def test_pay_end_to_end(client, mock_strategy): mock_strategy.queue( BuckarooMockRequest.json( diff --git a/tests/unit/builders/payments/test_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/payments/test_sofort_builder.py b/tests/unit/builders/payments/test_sofort_builder.py index 51b7da5..962248f 100644 --- a/tests/unit/builders/payments/test_sofort_builder.py +++ b/tests/unit/builders/payments/test_sofort_builder.py @@ -144,7 +144,7 @@ def test_instant_refund_works(client, mock_strategy): ) ) builder = populate_required_fields(SofortBuilder(client)) - response = builder.instantRefund() + response = builder.instantRefund("ABC123") assert response is not None diff --git a/tests/unit/builders/solutions/test_concrete_solutions_contract.py b/tests/unit/builders/solutions/test_concrete_solutions_contract.py index dc99b28..40a8341 100644 --- a/tests/unit/builders/solutions/test_concrete_solutions_contract.py +++ b/tests/unit/builders/solutions/test_concrete_solutions_contract.py @@ -28,6 +28,9 @@ # ``SolutionMethodFactory._solution_methods``. CANONICAL_ACTIONS: Dict[str, str] = { "subscription": "CreateSubscription", + "emandate": "GetIssuerList", + "emandateb2b": "GetIssuerList", + "marketplaces": "Split", } diff --git a/tests/unit/builders/solutions/test_emandate_builder.py b/tests/unit/builders/solutions/test_emandate_builder.py new file mode 100644 index 0000000..9370f66 --- /dev/null +++ b/tests/unit/builders/solutions/test_emandate_builder.py @@ -0,0 +1,301 @@ +"""Unit tests for :class:`EmandateBuilder`.""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.solutions.emandate_builder import EmandateB2BBuilder, EmandateBuilder +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from buckaroo.exceptions._parameter_validation_error import RequiredParameterMissingError +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action + + +def test_construction_with_client_succeeds(client): + builder = EmandateBuilder(client) + assert isinstance(builder, EmandateBuilder) + assert isinstance(builder, SolutionBuilder) + + +def test_get_service_name_returns_emandate(client): + assert EmandateBuilder(client).get_service_name() == "emandate" + + +def test_get_allowed_service_parameters_get_issuer_list_returns_empty(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("GetIssuerList") == {} + + +def test_get_allowed_service_parameters_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("getissuerlist") == ( + builder.get_allowed_service_parameters("GetIssuerList") + ) + + +def test_get_allowed_service_parameters_create_mandate_marks_debtor_reference_required(client): + builder = EmandateBuilder(client) + params = builder.get_allowed_service_parameters("CreateMandate") + + assert set(params.keys()) == { + "debtorBankId", + "debtorReference", + "sequenceType", + "purchaseId", + "language", + "emandateReason", + "maxAmount", + } + assert params["debtorReference"]["required"] is True + for name, config in params.items(): + if name != "debtorReference": + assert config["required"] is False, f"{name} should be optional" + + +def test_get_allowed_service_parameters_create_mandate_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("createmandate") == ( + builder.get_allowed_service_parameters("CreateMandate") + ) + + +def test_issuer_list_posts_to_data_request_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-123", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "GetIssuerList", + "Parameters": [ + {"Name": "Issuer", "Value": "ABNANL2A"}, + {"Name": "IssuerName", "Value": "ABN AMRO"}, + ], + } + ], + }, + ) + ) + + response = EmandateBuilder(client).issuer_list() + + assert response.key == "emandate-key-123" + assert response.get_service_parameter("Issuer") == "ABNANL2A" + assert response.get_service_parameter("IssuerName") == "ABN AMRO" + assert recorded_action(mock_strategy) == "GetIssuerList" + + +def test_create_mandate_posts_to_data_request_and_parses_mandate_id(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-456", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "CreateMandate", + "Parameters": [ + {"Name": "MandateId", "Value": "MND-001"}, + ], + } + ], + }, + ) + ) + + builder = EmandateBuilder(client) + builder.add_parameter("debtorReference", "DEBTOR-001") + response = builder.create_mandate() + + assert response.key == "emandate-key-456" + assert response.get_service_parameter("MandateId") == "MND-001" + assert recorded_action(mock_strategy) == "CreateMandate" + + +def test_create_mandate_raises_when_debtor_reference_missing(client): + builder = EmandateBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.create_mandate() + + assert exc.value.parameter_name == "debtorReference" + + +def test_get_allowed_service_parameters_get_status_marks_mandate_id_required(client): + builder = EmandateBuilder(client) + params = builder.get_allowed_service_parameters("GetStatus") + + assert set(params.keys()) == {"mandateId"} + assert params["mandateId"]["required"] is True + + +def test_get_allowed_service_parameters_get_status_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("getstatus") == ( + builder.get_allowed_service_parameters("GetStatus") + ) + + +def test_status_posts_to_data_request_and_parses_status(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-789", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "GetStatus", + "Parameters": [ + {"Name": "Status", "Value": "Active"}, + ], + } + ], + }, + ) + ) + + builder = EmandateBuilder(client) + builder.add_parameter("mandateId", "MND-001") + response = builder.status() + + assert response.key == "emandate-key-789" + assert response.get_service_parameter("Status") == "Active" + assert recorded_action(mock_strategy) == "GetStatus" + + +def test_status_raises_when_mandate_id_missing(client): + builder = EmandateBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.status() + + assert exc.value.parameter_name == "mandateId" + + +def test_get_allowed_service_parameters_modify_mandate_marks_mandate_id_required(client): + builder = EmandateBuilder(client) + params = builder.get_allowed_service_parameters("ModifyMandate") + + assert set(params.keys()) == {"mandateId", "maxAmount", "language", "emandateReason"} + assert params["mandateId"]["required"] is True + for name, config in params.items(): + if name != "mandateId": + assert config["required"] is False, f"{name} should be optional" + + +def test_get_allowed_service_parameters_modify_mandate_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("modifymandate") == ( + builder.get_allowed_service_parameters("ModifyMandate") + ) + + +def test_modify_mandate_posts_to_data_request_and_parses_mandate_id(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-321", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "ModifyMandate", + "Parameters": [ + {"Name": "MandateId", "Value": "MND-002"}, + ], + } + ], + }, + ) + ) + + builder = EmandateBuilder(client) + builder.add_parameter("mandateId", "MND-002") + builder.add_parameter("maxAmount", "500.00") + response = builder.modify_mandate() + + assert response.key == "emandate-key-321" + assert response.get_service_parameter("MandateId") == "MND-002" + assert recorded_action(mock_strategy) == "ModifyMandate" + + +def test_modify_mandate_raises_when_mandate_id_missing(client): + builder = EmandateBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.modify_mandate() + + assert exc.value.parameter_name == "mandateId" + + +def test_get_allowed_service_parameters_cancel_mandate_marks_mandate_id_required(client): + builder = EmandateBuilder(client) + params = builder.get_allowed_service_parameters("CancelMandate") + + assert set(params.keys()) == {"mandateId", "purchaseId"} + assert params["mandateId"]["required"] is True + assert params["purchaseId"]["required"] is False + + +def test_get_allowed_service_parameters_cancel_mandate_is_case_insensitive(client): + builder = EmandateBuilder(client) + assert builder.get_allowed_service_parameters("cancelmandate") == ( + builder.get_allowed_service_parameters("CancelMandate") + ) + + +def test_cancel_mandate_posts_to_data_request_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + { + "Key": "emandate-key-654", + "Status": {"Code": {"Code": 190}}, + "Services": [ + { + "Name": "emandate", + "Action": "CancelMandate", + "Parameters": [], + } + ], + }, + ) + ) + + builder = EmandateBuilder(client) + builder.add_parameter("mandateId", "MND-003") + response = builder.cancel_mandate() + + assert response.key == "emandate-key-654" + assert recorded_action(mock_strategy) == "CancelMandate" + + +def test_cancel_mandate_raises_when_mandate_id_missing(client): + builder = EmandateBuilder(client) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.cancel_mandate() + + assert exc.value.parameter_name == "mandateId" + + +def test_b2b_get_service_name_returns_emandateb2b(client): + assert EmandateB2BBuilder(client).get_service_name() == "emandateb2b" + + +def test_b2b_builder_is_a_subclass_of_emandate_builder(client): + builder = EmandateB2BBuilder(client) + assert isinstance(builder, EmandateBuilder) diff --git a/tests/unit/builders/solutions/test_marketplaces_builder.py b/tests/unit/builders/solutions/test_marketplaces_builder.py new file mode 100644 index 0000000..1c01e60 --- /dev/null +++ b/tests/unit/builders/solutions/test_marketplaces_builder.py @@ -0,0 +1,65 @@ +"""Unit tests for :class:`MarketplacesBuilder`.""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.solutions.marketplaces_builder import MarketplacesBuilder +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from buckaroo.factories.solution_method_factory import SolutionMethodFactory +from buckaroo.models.payment_request import CombinableService + + +@pytest.fixture +def client(): + return object() + + +def test_is_a_solution_builder(client): + assert isinstance(MarketplacesBuilder(client), SolutionBuilder) + + +def test_service_name_is_marketplaces(client): + assert MarketplacesBuilder(client).get_service_name() == "Marketplaces" + + +def test_split_action_allows_grouped_and_days_parameters(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("Split") + assert set(allowed) == {"DaysUntilTransfer", "Marketplace", "Seller"} + + +def test_transfer_action_allows_only_split_groups(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("Transfer") + assert set(allowed) == {"Marketplace", "Seller"} + + +def test_refund_supplementary_action_allows_only_split_groups(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("RefundSupplementary") + assert set(allowed) == {"Marketplace", "Seller"} + + +def test_manual_transfer_action_allows_account_and_description_params(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("ManualTransfer") + assert set(allowed) == { + "FromAccountId", + "ToAccountId", + "FromDescription", + "ToDescription", + } + + +def test_unknown_action_allows_no_parameters(client): + assert MarketplacesBuilder(client).get_allowed_service_parameters("Bogus") == {} + + +def test_split_returns_combinable_service(client): + mp = MarketplacesBuilder(client).split( + {"marketplace": {"Amount": "10.00"}, "sellers": [{"AccountId": "S1", "Amount": "85.00"}]} + ) + assert isinstance(mp, CombinableService) + assert [s.name for s in mp.services] == ["Marketplaces"] + assert mp.services[0].action == "Split" + + +def test_registered_under_marketplaces_key(): + assert SolutionMethodFactory._solution_methods["marketplaces"] is MarketplacesBuilder 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/factories/test_solution_method_factory.py b/tests/unit/factories/test_solution_method_factory.py index 552500a..15e76a4 100644 --- a/tests/unit/factories/test_solution_method_factory.py +++ b/tests/unit/factories/test_solution_method_factory.py @@ -6,6 +6,7 @@ from buckaroo.builders.solutions.solution_builder import SolutionBuilder from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder from buckaroo.builders.solutions.default_builder import DefaultBuilder +from buckaroo.builders.solutions.emandate_builder import EmandateB2BBuilder, EmandateBuilder @pytest.fixture(autouse=True) @@ -106,6 +107,13 @@ def test_detect_method_from_payload_lowercases_uppercase_method(): ) +def test_create_builder_resolves_both_emandate_keys(client): + assert isinstance(SolutionMethodFactory.create_builder("emandate", client), EmandateBuilder) + assert isinstance( + SolutionMethodFactory.create_builder("emandateb2b", client), EmandateB2BBuilder + ) + + # SolutionMethodFactory.detect_method_from_payload deliberately does NOT warn # on fallback — diverges from PaymentMethodFactory. Locked in here. @pytest.mark.parametrize("payload", [{}, {"other": "thing"}], ids=["empty", "missing_method_key"]) 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, ) diff --git a/tests/unit/services/test_service_parameter_validator.py b/tests/unit/services/test_service_parameter_validator.py index 44ec8a3..7a394b6 100644 --- a/tests/unit/services/test_service_parameter_validator.py +++ b/tests/unit/services/test_service_parameter_validator.py @@ -218,26 +218,24 @@ def test_validate_required_parameters_raises_required_missing_for_single_gap(): def test_validate_required_parameters_raises_validation_error_for_multiple_gaps(): validator = _validator_for(KlarnaBuilder) - # Klarna Reserve requires billingCustomer, shippingCustomer, article — all missing. + # Klarna Reserve requires article and operatingCountry — both missing. with pytest.raises(ParameterValidationError) as exc: validator.validate_required_parameters([], action="Reserve") # Multiple missing -> plain ParameterValidationError (not Required...), # and the message lists each missing name. assert not isinstance(exc.value, RequiredParameterMissingError) msg = str(exc.value) - assert "billingCustomer" in msg - assert "shippingCustomer" in msg assert "article" in msg + assert "operatingCountry" in msg def test_validate_required_parameters_accepts_grouped_group_type_as_satisfying_requirement(): validator = _validator_for(KlarnaBuilder) params = [ - Parameter(name="FirstName", value="Jane", group_type="billingCustomer"), - Parameter(name="FirstName", value="John", group_type="shippingCustomer"), Parameter(name="Identifier", value="A1", group_type="article"), + Parameter(name="OperatingCountry", value="NL"), ] - # All three required group_types are present via grouped parameters. + # The grouped ``article`` requirement plus the plain operatingCountry are met. validator.validate_required_parameters(params, action="Reserve")