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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- [Pip Installation](#pip-installation)
- [Example](#example)
- [iDIN](#idin)
- [Instant Refunds](#instant-refunds)
- [Contribute](#contribute)
- [Versioning](#versioning)
- [Additional information](#additional-information)
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 25 additions & 4 deletions buckaroo/builders/base_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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(
Expand Down
37 changes: 33 additions & 4 deletions buckaroo/builders/payments/capabilities/instant_refund_capable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
94 changes: 94 additions & 0 deletions examples/instant_refund.py
Original file line number Diff line number Diff line change
@@ -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()
16 changes: 15 additions & 1 deletion tests/feature/payments/test_ideal.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions tests/feature/payments/test_payconiq.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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"]
Loading
Loading