From 0ed5bd433ea4fbb5600eeaf697b0eb9700328884 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Wed, 15 Jul 2026 12:51:23 +0200 Subject: [PATCH] feat(BTI-16): add Split Payments (Marketplaces) solution - add MarketplacesBuilder with split, transfer, refund_supplementary and manual_transfer - register "marketplaces" solution in SolutionMethodFactory - add combine() to BaseBuilder so a supplementary service rides in one ServiceList - add CombinableService model returned by split/refund_supplementary - split and refund_supplementary combine into a payment/refund; transfer and manual_transfer post standalone DataRequests - map grouped Marketplace/Seller split params from a single spec dict - add unit and feature tests asserting the exact wire body per request type - add examples/marketplaces.py demoing all six request types - document Split Payments usage in README --- README.md | 93 ++++++ buckaroo/builders/base_builder.py | 30 +- .../solutions/marketplaces_builder.py | 207 ++++++++++++ buckaroo/factories/solution_method_factory.py | 2 + buckaroo/models/payment_request.py | 13 + examples/marketplaces.py | 241 ++++++++++++++ tests/feature/solutions/test_marketplaces.py | 308 ++++++++++++++++++ .../test_concrete_solutions_contract.py | 1 + .../solutions/test_marketplaces_builder.py | 65 ++++ 9 files changed, 957 insertions(+), 3 deletions(-) create mode 100644 buckaroo/builders/solutions/marketplaces_builder.py create mode 100644 examples/marketplaces.py create mode 100644 tests/feature/solutions/test_marketplaces.py create mode 100644 tests/unit/builders/solutions/test_marketplaces_builder.py diff --git a/README.md b/README.md index a238e30..992dd55 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - [iDIN](#idin) - [Instant Refunds](#instant-refunds) - [eMandate](#emandate) +- [Split Payments](#split-payments) - [Contribute](#contribute) - [Versioning](#versioning) - [Additional information](#additional-information) @@ -188,6 +189,98 @@ The B2B variant exposes the same five methods — swap `"emandate"` for `"emanda See [`examples/emandate.py`](examples/emandate.py) for a runnable demo of all five actions against both the B2C and B2B services. +### Split Payments + +Split Payments (Buckaroo service `Marketplaces`) lets a platform divide one +customer payment across its own funds account and one or more seller accounts. +Following the other Buckaroo SDKs, `split` and `refund_supplementary` build a +supplementary service that is *combined* into a payment or refund; `transfer` and +`manual_transfer` are standalone. + +```python +from buckaroo import BuckarooClient +from buckaroo.services.payment_service import PaymentService +from buckaroo.services.solution_service import SolutionService + +client = BuckarooClient("STORE_KEY", "SECRET_KEY", mode="test") +payments = PaymentService(client) +marketplaces = SolutionService(client) +``` + +**Split** — build the split, then combine it into the funding payment (e.g. +iDEAL). `daysUntilTransfer` is `"0"` for immediate payout, or omit it to hold the +funds until a later Transfer. + +```python +split = marketplaces.create_solution("marketplaces").split({ + "daysUntilTransfer": "2", + "marketplace": {"Amount": "10.00", "Description": "INV0001 Commission Platform"}, + "sellers": [ + {"AccountId": "SELLER_ACCOUNT_1", "Amount": "50.00", "Description": "Payout 1"}, + {"AccountId": "SELLER_ACCOUNT_2", "Amount": "35.00", "Description": "Payout 2"}, + ], +}) + +response = payments.create_payment("ideal", { + "currency": "EUR", + "amount": 95.00, + "invoice": "INV0001", + "description": "Split order INV0001", + "service_parameters": {"issuer": "ABNANL2A"}, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", +}).combine(split).pay() +``` + +**Transfer** — release held funds of an existing split payment. With no split +data it transfers everything (Transfer I); pass `marketplace`/`sellers` to +transfer a partial or re-specified split (Transfer II). + +```python +marketplaces.create_solution("marketplaces").transfer({ + "originalTransactionKey": "SPLIT_TRANSACTION_KEY", +}) +``` + +**RefundSupplementary** — refund the consumer and pull the funds back from the +target accounts. Combine it into the refund. Without seller data it reverts all +transfers (I); pass `sellers` to retrieve specific amounts per account (II). + +```python +supplementary = marketplaces.create_solution("marketplaces").refund_supplementary() + +payments.create_payment("ideal", { + "currency": "EUR", + "amount": 50.00, + "invoice": "INV0001", + "description": "Split refund INV0001", + "original_transaction_key": "SPLIT_TRANSACTION_KEY", + "refund_amount": 50.00, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", +}).combine(supplementary).refund() +``` + +**ManualTransfer** — move funds directly between two accounts. + +```python +marketplaces.create_solution("marketplaces").manual_transfer({ + "fromAccountId": "ACCOUNT_A", + "toAccountId": "ACCOUNT_B", + "fromDescription": "Deduction monthly fee", + "toDescription": "Monthly fee third party ABC", + "amount": 10.00, + "currency": "EUR", +}) +``` + +A runnable demo of all six request types is in +[`examples/marketplaces.py`](examples/marketplaces.py). + ### Contribute We really appreciate it when developers contribute to improve the Buckaroo plugins. diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index d586d8f..566728c 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -1,6 +1,13 @@ from abc import ABC, abstractmethod from typing import Dict, Any, Optional, List -from ..models.payment_request import PaymentRequest, ClientIP, Service, ServiceList, Parameter +from ..models.payment_request import ( + PaymentRequest, + ClientIP, + Service, + ServiceList, + Parameter, + CombinableService, +) from ..models.payment_response import PaymentResponse from ..services.service_parameter_validator import ServiceParameterValidator @@ -26,9 +33,26 @@ def __init__(self, client): self._services_selectable_by_client: Optional[str] = None self._client_ip: Optional[ClientIP] = None self._service_parameters: List[Parameter] = [] + self._combined_services: List[Service] = [] # Extra services merged in via combine() self._payload: Dict[str, Any] = {} # Store original payload self._validator = ServiceParameterValidator(self) + def combine(self, combinable: "CombinableService") -> "BaseBuilder": + """Merge a supplementary service into this transaction. + + Mirrors the ``combine`` pattern of the other Buckaroo SDKs: a + supplementary service (e.g. Marketplaces ``Split``) is built on its own, + then combined into a payment or refund so both ride in one request's + ``ServiceList``. The combined services are appended after this builder's + own service when the request is built. + + Builders are single-use: create a fresh one per ``create_payment`` / + ``create_solution`` call. Calling ``combine`` more than once accumulates + services rather than replacing them. + """ + self._combined_services.extend(combinable.services) + return self + def currency(self, currency: str) -> "BaseBuilder": """Set the currency for the payment.""" self._currency = currency @@ -349,8 +373,8 @@ def build( parameters=self._service_parameters if self._service_parameters else None, ) - # Create service list - service_list = ServiceList(services=[service]) + # Create service list, appending any services merged in via combine() + service_list = ServiceList(services=[service, *self._combined_services]) # Build payment request payment_request = PaymentRequest( diff --git a/buckaroo/builders/solutions/marketplaces_builder.py b/buckaroo/builders/solutions/marketplaces_builder.py new file mode 100644 index 0000000..02b6114 --- /dev/null +++ b/buckaroo/builders/solutions/marketplaces_builder.py @@ -0,0 +1,207 @@ +from typing import Any, Dict, Optional + +from .solution_builder import SolutionBuilder +from ...models.payment_request import CombinableService +from ...models.payment_response import PaymentResponse + + +class MarketplacesBuilder(SolutionBuilder): + """Builder for the Split Payments (``Marketplaces``) solution. + + Marketplaces lets a platform divide one customer payment across its own + funds account and one or more seller accounts, move held funds later, refund + from sellers, and move funds between accounts manually. + + ``split`` and ``refund_supplementary`` build a supplementary service that is + *combined* into a payment or refund (``builder.combine(...).pay()`` / + ``.refund()``), mirroring the PHP/Node SDKs. ``transfer`` and + ``manual_transfer`` are standalone data requests. + + The split spec is a single dict: + ``{"daysUntilTransfer": ..., "marketplace": {...}, "sellers": [{...}, ...]}`` + — ``marketplace`` becomes the funds-account group, each ``sellers`` entry a + ``Seller`` group with a unique GroupID. + """ + + # Grouped-parameter buckets shared by the split-defining actions. Keyed by + # the wire group type ("Marketplace"/"Seller") — the validator matches + # grouped params by group type, not by individual field name. + _SPLIT_GROUPS: Dict[str, Any] = { + "Marketplace": { + "type": dict, + "required": False, + "description": "Split group reserved for the Split Payments funds account", + }, + "Seller": { + "type": dict, + "required": False, + "description": "Split group reserved for a third-party (seller) account", + }, + } + + def get_service_name(self) -> str: + """Get the service name for Split Payments.""" + return "Marketplaces" + + def get_allowed_service_parameters(self, action: str = "Split") -> Dict[str, Any]: + """Get the allowed service parameters for Marketplaces based on action. + + ``marketplace`` and ``sellers`` are grouped-parameter buckets; + ``DaysUntilTransfer`` is a plain parameter allowed only on ``Split`` (a + Transfer is always immediate). + """ + if action.lower() == "split": + return { + "DaysUntilTransfer": { + "type": str, + "required": False, + "description": "Days (0-93) funds are held before transfer; 0 = immediate", + }, + **self._SPLIT_GROUPS, + } + + if action.lower() in ("transfer", "refundsupplementary"): + return dict(self._SPLIT_GROUPS) + + if action.lower() == "manualtransfer": + return { + "FromAccountId": { + "type": str, + "required": True, + "description": "Merchant GUID of the debited account (A)", + }, + "ToAccountId": { + "type": str, + "required": True, + "description": "Merchant GUID of the credited account (B)", + }, + "FromDescription": { + "type": str, + "required": True, + "description": "Description of the debit transaction in account A", + }, + "ToDescription": { + "type": str, + "required": True, + "description": "Description of the credit transaction in account B", + }, + } + + return {} + + def split(self, split: Dict[str, Any], validate: bool = True) -> CombinableService: + """Build a Split service to combine into a payment. + + Combine the result into the funding payment, e.g. + ``payments.create_payment("ideal", {...}).combine(mp).pay()``. + + Args: + split: Spec dict with ``daysUntilTransfer`` (0-93; "0" = immediate, + omit to hold until a later Transfer), ``marketplace`` and + ``sellers`` groups. + validate: Whether to validate and filter service parameters. + """ + self._apply_split(split) + service = self.build("Split", validate=validate).services.services[0] + return CombinableService(services=[service]) + + def refund_supplementary( + self, split: Optional[Dict[str, Any]] = None, validate: bool = True + ) -> CombinableService: + """Build a RefundSupplementary service to combine into a refund. + + Combine the result into the consumer refund, e.g. + ``payments.create_payment("ideal", {...}).combine(mp).refund()``. With no + spec it reverts all transfers; pass ``sellers``/``marketplace`` to + retrieve specific amounts per account. + + Args: + split: Optional spec dict with ``marketplace`` and ``sellers`` groups. + validate: Whether to validate and filter service parameters. + """ + self._apply_split(split or {}) + service = self.build("RefundSupplementary", validate=validate).services.services[0] + return CombinableService(services=[service]) + + def transfer(self, transfer: Dict[str, Any], validate: bool = True) -> PaymentResponse: + """Transfer held funds of an existing split payment (standalone). + + Args: + transfer: Spec dict with ``originalTransactionKey`` (required) and, + optionally, ``marketplace``/``sellers`` to re-specify the split + (partial transfer). ``DaysUntilTransfer`` does not apply. + validate: Whether to validate and filter service parameters. + + Raises: + ValueError: If ``originalTransactionKey`` is missing. + """ + original_transaction_key = transfer.get("originalTransactionKey") + if not original_transaction_key: + raise ValueError("Transfer requires an originalTransactionKey") + + self._apply_split(transfer) + request_data = self.build("Transfer", validate=validate).to_dict() + request_data["OriginalTransactionKey"] = original_transaction_key + request_data.pop("AmountDebit", None) + + return self._post_data_request(request_data) + + def manual_transfer( + self, manual_transfer: Dict[str, Any], validate: bool = True + ) -> PaymentResponse: + """Move funds directly between two accounts (standalone). + + Args: + manual_transfer: Spec dict with ``fromAccountId``, ``toAccountId``, + ``fromDescription``, ``toDescription``, ``amount`` and + ``currency`` (all required), and optional ``invoice``. + validate: Whether to validate and filter service parameters. + + Raises: + ValueError: If a required field is missing. + """ + fields = { + "FromAccountId": manual_transfer.get("fromAccountId"), + "ToAccountId": manual_transfer.get("toAccountId"), + "FromDescription": manual_transfer.get("fromDescription"), + "ToDescription": manual_transfer.get("toDescription"), + } + missing = [name for name, value in fields.items() if not value] + if manual_transfer.get("amount") is None: + missing.append("amount") + currency = manual_transfer.get("currency") or self._currency + if not currency: + missing.append("currency") + if missing: + raise ValueError(f"ManualTransfer requires: {', '.join(missing)}") + + for name, value in fields.items(): + self.add_parameter(name, value) + + request_data = self.build("ManualTransfer", validate=validate).to_dict() + request_data["Currency"] = currency + request_data["Amount"] = manual_transfer["amount"] + request_data.pop("AmountDebit", None) + + invoice = manual_transfer.get("invoice") or self._invoice + if invoice: + request_data["Invoice"] = invoice + else: + request_data.pop("Invoice", None) + + return self._post_data_request(request_data) + + def _apply_split(self, split: Dict[str, Any]) -> None: + """Map a split spec dict into grouped Marketplaces service parameters.""" + days_until_transfer = split.get("daysUntilTransfer") + if days_until_transfer is not None: + self.add_parameter("DaysUntilTransfer", days_until_transfer) + + marketplace = split.get("marketplace") + if marketplace: + for name, value in marketplace.items(): + self.add_parameter(name, value, "Marketplace") + + for index, seller in enumerate(split.get("sellers") or [], start=1): + for name, value in seller.items(): + self.add_parameter(name, value, "Seller", str(index)) diff --git a/buckaroo/factories/solution_method_factory.py b/buckaroo/factories/solution_method_factory.py index 8001a13..5cf81d2 100644 --- a/buckaroo/factories/solution_method_factory.py +++ b/buckaroo/factories/solution_method_factory.py @@ -4,6 +4,7 @@ from .builder_factory import BuilderFactory from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder from buckaroo.builders.solutions.emandate_builder import EmandateB2BBuilder, EmandateBuilder +from buckaroo.builders.solutions.marketplaces_builder import MarketplacesBuilder from buckaroo.builders.solutions.default_builder import DefaultBuilder from buckaroo.builders.solutions.solution_builder import SolutionBuilder @@ -16,6 +17,7 @@ class SolutionMethodFactory(BuilderFactory): "subscription": SubscriptionBuilder, "emandate": EmandateBuilder, "emandateb2b": EmandateB2BBuilder, + "marketplaces": MarketplacesBuilder, } @classmethod diff --git a/buckaroo/models/payment_request.py b/buckaroo/models/payment_request.py index c63e0ac..42459e1 100644 --- a/buckaroo/models/payment_request.py +++ b/buckaroo/models/payment_request.py @@ -74,6 +74,19 @@ def add_parameter(self, parameter: Union[Dict[str, Any], Parameter]) -> "Service return self +@dataclass +class CombinableService: + """A built supplementary service to merge into another transaction. + + Returned by builders that produce a service meant to ride along with a + payment or refund (e.g. Marketplaces ``Split``). Passed to + :meth:`BaseBuilder.combine`, which appends its ``services`` to the host + request's ``ServiceList``. + """ + + services: List["Service"] + + @dataclass class ServiceList: """Model for list of services.""" diff --git a/examples/marketplaces.py b/examples/marketplaces.py new file mode 100644 index 0000000..12c5ad3 --- /dev/null +++ b/examples/marketplaces.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Demo of the Split Payments (``Marketplaces``) solution. + +Marketplaces lets a platform divide one customer payment across its own funds +account and one or more seller accounts, move held funds later, refund from +sellers, and move funds manually between accounts. Following the other Buckaroo +SDKs, ``split`` and ``refund_supplementary`` build a supplementary service that +is *combined* into a payment or refund; ``transfer`` and ``manual_transfer`` are +standalone. Gated on ``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. + +The account IDs, issuer, and transaction keys below are placeholders — replace +them with values from your own Split Payments setup to run against the sandbox. +""" + +import os +import sys + +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from buckaroo.app import Buckaroo + +# Placeholder identifiers — replace with your own Split Payments values. +ISSUER = "ABNANL2A" +SELLER_1 = "789C60F316D24B088ACD471" +SELLER_2 = "369C60F316D24B088ACD238" +FUNDS_ACCOUNT = "AAAAAAAAAAAAAAAAAAAAAAA" +SPLIT_TXN_KEY = "REPLACE_WITH_SPLIT_TRANSACTION_KEY" + + +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True + + +def demo_split() -> None: + """Split: build the split, combine it into an iDEAL payment.""" + print("\n1. Split") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + marketplaces = app.solutions.create_solution("marketplaces").split( + { + "daysUntilTransfer": "2", + "marketplace": { + "Amount": "10.00", + "InvoiceNumber": "INV0000123", + "Description": "INV0001 Commission Platform", + }, + "sellers": [ + {"AccountId": SELLER_1, "Amount": "50.00", "Description": "Payout 1"}, + {"AccountId": SELLER_2, "Amount": "35.00", "Description": "Payout 2"}, + ], + } + ) + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 95.00, + "invoice": "INV0001", + "description": "Split order INV0001", + "service_parameters": {"issuer": ISSUER}, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + }, + ) + .combine(marketplaces) + .pay() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_transfer_all() -> None: + """Transfer (I): release every held split of an existing split payment.""" + print("\n2. Transfer (I) — transfer all") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution("marketplaces").transfer( + {"originalTransactionKey": SPLIT_TXN_KEY} + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_transfer_partial() -> None: + """Transfer (II): re-specify the split (partial/different distribution).""" + print("\n3. Transfer (II) — partial / re-specified") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution("marketplaces").transfer( + { + "originalTransactionKey": SPLIT_TXN_KEY, + "marketplace": {"Amount": "10.00", "Description": "INV0001 Commission Platform"}, + "sellers": [{"AccountId": SELLER_1, "Amount": "50.00"}], + } + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_refund_supplementary_all() -> None: + """RefundSupplementary (I): refund the consumer and revert all transfers.""" + print("\n4. RefundSupplementary (I) — revert all") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + marketplaces = app.solutions.create_solution("marketplaces").refund_supplementary() + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 50.00, + "invoice": "INV0001", + "description": "Split refund INV0001", + "original_transaction_key": SPLIT_TXN_KEY, + "refund_amount": 50.00, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + }, + ) + .combine(marketplaces) + .refund() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_refund_supplementary_partial() -> None: + """RefundSupplementary (II): retrieve specific amounts per seller.""" + print("\n5. RefundSupplementary (II) — per-seller") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + marketplaces = app.solutions.create_solution("marketplaces").refund_supplementary( + { + "sellers": [ + { + "AccountId": SELLER_1, + "Amount": "30.00", + "Description": "INV0001 Refund Beauty Products BV", + } + ] + } + ) + response = ( + app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 30.00, + "invoice": "INV0001", + "description": "Split refund INV0001", + "original_transaction_key": SPLIT_TXN_KEY, + "refund_amount": 30.00, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + }, + ) + .combine(marketplaces) + .refund() + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_manual_transfer() -> None: + """ManualTransfer: move funds directly between two accounts.""" + print("\n6. ManualTransfer") + print("-" * 40) + if not _have_credentials(): + return + + app = Buckaroo.from_env() + try: + response = app.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": SELLER_1, + "toAccountId": FUNDS_ACCOUNT, + "fromDescription": "Deduction monthly fee", + "toDescription": "Monthly fee third party ABC", + "amount": 10.00, + "currency": "EUR", + "invoice": "INV0001", + } + ) + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — SPLIT PAYMENTS (MARKETPLACES) DEMO") + print("=" * 60) + + demo_split() + demo_transfer_all() + demo_transfer_partial() + demo_refund_supplementary_all() + demo_refund_supplementary_partial() + demo_manual_transfer() + + print("\n" + "=" * 60) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tests/feature/solutions/test_marketplaces.py b/tests/feature/solutions/test_marketplaces.py new file mode 100644 index 0000000..b418bb0 --- /dev/null +++ b/tests/feature/solutions/test_marketplaces.py @@ -0,0 +1,308 @@ +import pytest + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers +from tests.support.recording_mock import recorded_action, recorded_request + + +def _ideal_payload(**overrides): + """Base iDEAL transaction fields used to fund a split.""" + payload = { + "currency": "EUR", + "amount": 95.00, + "description": "Split order INV0001", + "invoice": "INV0001", + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + "service_parameters": {"issuer": "ABNANL2A"}, + } + payload.update(overrides) + return payload + + +def _split_response(): + """Buckaroo-shaped Split response carrying the SplitGuid identifiers.""" + return Helpers.success_response( + { + "Services": [ + { + "Name": "Marketplaces", + "Action": None, + "Parameters": [ + {"Name": "SplitGuid_Marketplace", "Value": "GUID-MARKETPLACE"}, + {"Name": "SplitGuid_Seller_1", "Value": "GUID-SELLER-1"}, + ], + }, + {"Name": "ideal", "Action": None, "Parameters": []}, + ], + "ServiceCode": "ideal", + } + ) + + +class TestMarketplacesSplit: + """Split rides on an iDEAL payment via combine().""" + + def test_marketplaces_is_available(self, buckaroo): + assert buckaroo.solutions.is_method_supported("marketplaces") + + def test_service_name_is_marketplaces(self, buckaroo): + assert ( + buckaroo.solutions.create_solution("marketplaces").get_service_name() == "Marketplaces" + ) + + def test_split_combines_marketplaces_into_ideal_pay(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _split_response()) + ) + + mp = buckaroo.solutions.create_solution("marketplaces").split( + { + "daysUntilTransfer": "2", + "marketplace": {"Amount": "10.00", "Description": "INV0001 Commission Platform"}, + "sellers": [ + {"AccountId": "789C60F316D24B088ACD471", "Amount": "50.00"}, + {"AccountId": "369C60F316D24B088ACD238", "Amount": "35.00"}, + ], + } + ) + response = buckaroo.payments.create_payment("ideal", _ideal_payload()).combine(mp).pay() + + body = recorded_request(mock_strategy) + services = body["Services"]["ServiceList"] + # Payment method first, combined Marketplaces service second. + assert [s["Name"] for s in services] == ["ideal", "Marketplaces"] + assert services[0]["Action"] == "Pay" + assert services[1]["Action"] == "Split" + assert body["Currency"] == "EUR" + assert body["AmountDebit"] == 95.00 + + ideal_params = {p["Name"]: p["Value"] for p in services[0]["Parameters"]} + assert ideal_params["Issuer"] == "ABNANL2A" + + split = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in services[1]["Parameters"] + } + assert split[("Daysuntiltransfer", "", "")] == "2" + assert split[("Amount", "Marketplace", "")] == "10.00" + assert split[("Accountid", "Seller", "1")] == "789C60F316D24B088ACD471" + assert split[("Accountid", "Seller", "2")] == "369C60F316D24B088ACD238" + + assert response.get_service_parameter("SplitGuid_Marketplace") == "GUID-MARKETPLACE" + assert response.get_service_parameter("SplitGuid_Seller_1") == "GUID-SELLER-1" + + +def _datarequest_response(action): + return Helpers.success_response({"Services": None, "ServiceCode": "Marketplaces"}) + + +class TestMarketplacesTransfer: + """Standalone Transfer posts a DataRequest.""" + + def test_transfer_all_posts_datarequest(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("Transfer") + ) + ) + + response = buckaroo.solutions.create_solution("marketplaces").transfer( + {"originalTransactionKey": "D3732474ED0"} + ) + + body = recorded_request(mock_strategy) + services = body["Services"]["ServiceList"] + assert body["OriginalTransactionKey"] == "D3732474ED0" + assert "AmountDebit" not in body + assert len(services) == 1 + assert services[0]["Name"] == "Marketplaces" + assert recorded_action(mock_strategy) == "Transfer" + assert "Parameters" not in services[0] + assert response.status.code.code == 190 + + def test_transfer_with_splits_posts_grouped_params(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("Transfer") + ) + ) + + buckaroo.solutions.create_solution("marketplaces").transfer( + { + "originalTransactionKey": "D3732474ED0", + "marketplace": {"Amount": "10.00", "Description": "Commission"}, + "sellers": [{"AccountId": "789C60F316D24B088ACD471", "Amount": "50.00"}], + } + ) + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][0] + assert service["Action"] == "Transfer" + params = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in service["Parameters"] + } + assert params[("Amount", "Marketplace", "")] == "10.00" + assert params[("Accountid", "Seller", "1")] == "789C60F316D24B088ACD471" + # DaysUntilTransfer never applies to a Transfer. + assert not any(name == "Daysuntiltransfer" for name, _, _ in params) + + def test_transfer_requires_original_key(self, buckaroo): + with pytest.raises(ValueError): + buckaroo.solutions.create_solution("marketplaces").transfer({}) + + +def _refund_supplementary_response(): + return Helpers.success_response( + { + "Services": [{"Name": "ideal", "Action": None, "Parameters": []}], + "ServiceCode": "ideal", + "AmountCredit": 50.00, + "AmountDebit": None, + } + ) + + +class TestMarketplacesRefundSupplementary: + """RefundSupplementary rides on an iDEAL refund via combine().""" + + def test_refund_supplementary_all_combines_into_refund(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _refund_supplementary_response()) + ) + + mp = buckaroo.solutions.create_solution("marketplaces").refund_supplementary() + payload = Helpers.standard_payload( + invoice="INV0001", + original_transaction_key="D3737EDA42474ED0", + refund_amount=50.00, + ) + response = buckaroo.payments.create_payment("ideal", payload).combine(mp).refund() + + body = recorded_request(mock_strategy) + services = body["Services"]["ServiceList"] + assert [(s["Name"], s["Action"]) for s in services] == [ + ("ideal", "Refund"), + ("Marketplaces", "RefundSupplementary"), + ] + assert body["OriginalTransactionKey"] == "D3737EDA42474ED0" + assert body["AmountCredit"] == 50.00 + assert "Parameters" not in services[1] + assert response.status.code.code == 190 + + def test_refund_supplementary_with_sellers(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _refund_supplementary_response()) + ) + + mp = buckaroo.solutions.create_solution("marketplaces").refund_supplementary( + {"sellers": [{"AccountId": "789C60F316D24B088ACD471", "Amount": "30.00"}]} + ) + payload = Helpers.standard_payload( + invoice="INV0001", + original_transaction_key="D3737EDA42474ED0", + refund_amount=30.00, + ) + buckaroo.payments.create_payment("ideal", payload).combine(mp).refund() + + service = recorded_request(mock_strategy)["Services"]["ServiceList"][1] + assert service["Action"] == "RefundSupplementary" + params = { + (p["Name"], p["GroupType"], p["GroupID"]): p["Value"] for p in service["Parameters"] + } + assert params[("Accountid", "Seller", "1")] == "789C60F316D24B088ACD471" + assert params[("Amount", "Seller", "1")] == "30.00" + + +class TestMarketplacesManualTransfer: + """Standalone ManualTransfer posts a DataRequest.""" + + def test_manual_transfer_posts_accounts_and_amount(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("ManualTransfer") + ) + ) + + response = buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAAAAAAAAAAAAAAAAAA", + "toAccountId": "BBBBBBBBBBBBBBBBBBB", + "fromDescription": "Deduction monthly fee", + "toDescription": "Monthly fee third party ABC", + "amount": 10.00, + "currency": "EUR", + "invoice": "INV0001", + } + ) + + body = recorded_request(mock_strategy) + service = body["Services"]["ServiceList"][0] + assert service["Name"] == "Marketplaces" + assert service["Action"] == "ManualTransfer" + assert body["Currency"] == "EUR" + assert body["Amount"] == 10.00 + assert "AmountDebit" not in body + + params = {p["Name"]: p["Value"] for p in service["Parameters"]} + assert params["Fromaccountid"] == "AAAAAAAAAAAAAAAAAAA" + assert params["Toaccountid"] == "BBBBBBBBBBBBBBBBBBB" + assert params["Fromdescription"] == "Deduction monthly fee" + assert params["Todescription"] == "Monthly fee third party ABC" + assert response.status.code.code == 190 + + def test_manual_transfer_requires_all_fields(self, buckaroo): + with pytest.raises(ValueError): + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "", + "fromDescription": "fee", + "toDescription": "fee", + "amount": 10.00, + } + ) + + def test_manual_transfer_omits_invoice_when_absent(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/DataRequest", _datarequest_response("ManualTransfer") + ) + ) + + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "BBB", + "fromDescription": "fee", + "toDescription": "fee", + "amount": 10.00, + "currency": "EUR", + } + ) + + assert "Invoice" not in recorded_request(mock_strategy) + + def test_manual_transfer_requires_amount(self, buckaroo): + with pytest.raises(ValueError, match="amount"): + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "BBB", + "fromDescription": "fee", + "toDescription": "fee", + "currency": "EUR", + } + ) + + def test_manual_transfer_requires_currency(self, buckaroo): + with pytest.raises(ValueError, match="currency"): + buckaroo.solutions.create_solution("marketplaces").manual_transfer( + { + "fromAccountId": "AAA", + "toAccountId": "BBB", + "fromDescription": "fee", + "toDescription": "fee", + "amount": 10.00, + } + ) diff --git a/tests/unit/builders/solutions/test_concrete_solutions_contract.py b/tests/unit/builders/solutions/test_concrete_solutions_contract.py index e66135f..40a8341 100644 --- a/tests/unit/builders/solutions/test_concrete_solutions_contract.py +++ b/tests/unit/builders/solutions/test_concrete_solutions_contract.py @@ -30,6 +30,7 @@ "subscription": "CreateSubscription", "emandate": "GetIssuerList", "emandateb2b": "GetIssuerList", + "marketplaces": "Split", } diff --git a/tests/unit/builders/solutions/test_marketplaces_builder.py b/tests/unit/builders/solutions/test_marketplaces_builder.py new file mode 100644 index 0000000..1c01e60 --- /dev/null +++ b/tests/unit/builders/solutions/test_marketplaces_builder.py @@ -0,0 +1,65 @@ +"""Unit tests for :class:`MarketplacesBuilder`.""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.solutions.marketplaces_builder import MarketplacesBuilder +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from buckaroo.factories.solution_method_factory import SolutionMethodFactory +from buckaroo.models.payment_request import CombinableService + + +@pytest.fixture +def client(): + return object() + + +def test_is_a_solution_builder(client): + assert isinstance(MarketplacesBuilder(client), SolutionBuilder) + + +def test_service_name_is_marketplaces(client): + assert MarketplacesBuilder(client).get_service_name() == "Marketplaces" + + +def test_split_action_allows_grouped_and_days_parameters(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("Split") + assert set(allowed) == {"DaysUntilTransfer", "Marketplace", "Seller"} + + +def test_transfer_action_allows_only_split_groups(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("Transfer") + assert set(allowed) == {"Marketplace", "Seller"} + + +def test_refund_supplementary_action_allows_only_split_groups(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("RefundSupplementary") + assert set(allowed) == {"Marketplace", "Seller"} + + +def test_manual_transfer_action_allows_account_and_description_params(client): + allowed = MarketplacesBuilder(client).get_allowed_service_parameters("ManualTransfer") + assert set(allowed) == { + "FromAccountId", + "ToAccountId", + "FromDescription", + "ToDescription", + } + + +def test_unknown_action_allows_no_parameters(client): + assert MarketplacesBuilder(client).get_allowed_service_parameters("Bogus") == {} + + +def test_split_returns_combinable_service(client): + mp = MarketplacesBuilder(client).split( + {"marketplace": {"Amount": "10.00"}, "sellers": [{"AccountId": "S1", "Amount": "85.00"}]} + ) + assert isinstance(mp, CombinableService) + assert [s.name for s in mp.services] == ["Marketplaces"] + assert mp.services[0].action == "Split" + + +def test_registered_under_marketplaces_key(): + assert SolutionMethodFactory._solution_methods["marketplaces"] is MarketplacesBuilder