diff --git a/README.md b/README.md index b5b1a91..1398b99 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [Pip Installation](#pip-installation) - [Example](#example) - [iDIN](#idin) +- [Instant Refunds](#instant-refunds) - [Contribute](#contribute) - [Versioning](#versioning) - [Additional information](#additional-information) @@ -113,6 +114,24 @@ 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. + ### 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 704cb1f..d586d8f 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -398,15 +398,20 @@ def pay(self, validate: bool = True, strict_validation: bool = False) -> Payment return self._post_transaction(request_data) - def refund(self, validate: bool = True) -> PaymentResponse: + def _build_refund_request_data(self, action: str, validate: bool = True) -> Dict[str, Any]: """ - Execute a refund transaction. + Build the wire request body shared by refund-style actions. + + 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 +427,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,6 +443,22 @@ def refund(self, validate: bool = True) -> PaymentResponse: request_data["AmountCredit"] = request_data["AmountDebit"] del request_data["AmountDebit"] + return request_data + + def refund(self, validate: bool = True) -> PaymentResponse: + """ + Execute a refund transaction. + + Args: + validate (bool): Whether to validate service parameters before building + + Returns: + PaymentResponse: The refund response + + Raises: + ValueError: If required fields are missing + """ + request_data = self._build_refund_request_data("Refund", validate) return self._post_transaction(request_data) def pay_remainder( 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/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/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_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/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_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_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