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
28 changes: 0 additions & 28 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,6 @@ name: Publish
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
target:
description: Target repository
required: true
default: testpypi
type: choice
options:
- testpypi
- pypi

concurrency:
group: publish-${{ github.ref }}
Expand Down Expand Up @@ -49,7 +39,6 @@ jobs:
publish-pypi:
name: Publish to PyPI
needs: build
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi')
runs-on: ubuntu-latest
permissions:
id-token: write
Expand All @@ -60,20 +49,3 @@ jobs:
path: dist/

- uses: pypa/gh-action-pypi-publish@release/v1

publish-testpypi:
name: Publish to TestPyPI
needs: build
if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
57 changes: 57 additions & 0 deletions buckaroo/builders/payments/banking_builder.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions buckaroo/factories/payment_method_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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
Expand Down Expand Up @@ -51,6 +52,7 @@ class PaymentMethodFactory(BuilderFactory):
"alipay": AlipayBuilder,
"applepay": ApplePayBuilder,
"bancontact": BancontactBuilder,
"banking": BankingBuilder,
"belfius": BelfiusBuilder,
"bizum": BizumBuilder,
"billink": BillinkBuilder,
Expand Down
59 changes: 59 additions & 0 deletions tests/feature/payments/test_banking.py
Original file line number Diff line number Diff line change
@@ -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"]
]
131 changes: 131 additions & 0 deletions tests/unit/builders/payments/test_banking_builder.py
Original file line number Diff line number Diff line change
@@ -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()
Loading