Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- [iDIN](#idin)
- [Instant Refunds](#instant-refunds)
- [eMandate](#emandate)
- [Split Payments](#split-payments)
- [Contribute](#contribute)
- [Versioning](#versioning)
- [Additional information](#additional-information)
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 27 additions & 3 deletions buckaroo/builders/base_builder.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
207 changes: 207 additions & 0 deletions buckaroo/builders/solutions/marketplaces_builder.py
Original file line number Diff line number Diff line change
@@ -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))
2 changes: 2 additions & 0 deletions buckaroo/factories/solution_method_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -16,6 +17,7 @@ class SolutionMethodFactory(BuilderFactory):
"subscription": SubscriptionBuilder,
"emandate": EmandateBuilder,
"emandateb2b": EmandateB2BBuilder,
"marketplaces": MarketplacesBuilder,
}

@classmethod
Expand Down
Loading
Loading