From d6e3e20a66966047e6771ead2a0ab878f4a711fc Mon Sep 17 00:00:00 2001 From: vildanbina Date: Tue, 12 May 2026 13:59:52 +0200 Subject: [PATCH 1/2] ci: drop TestPyPI job from publish workflow --- .github/workflows/publish.yml | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6c9e897..c652456 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 }} @@ -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 @@ -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/ From e5d6437913815584353f6b406f71d6b3387acea5 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Fri, 3 Jul 2026 15:08:00 +0200 Subject: [PATCH 2/2] feat(BTI-721): add Banking service (PaymentOrder payout) --- buckaroo/builders/payments/banking_builder.py | 57 ++++++++ buckaroo/factories/payment_method_factory.py | 2 + tests/feature/payments/test_banking.py | 59 ++++++++ .../builders/payments/test_banking_builder.py | 131 ++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 buckaroo/builders/payments/banking_builder.py create mode 100644 tests/feature/payments/test_banking.py create mode 100644 tests/unit/builders/payments/test_banking_builder.py 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/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index c040d52..43960fc 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -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 @@ -51,6 +52,7 @@ class PaymentMethodFactory(BuilderFactory): "alipay": AlipayBuilder, "applepay": ApplePayBuilder, "bancontact": BancontactBuilder, + "banking": BankingBuilder, "belfius": BelfiusBuilder, "bizum": BizumBuilder, "billink": BillinkBuilder, 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/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()