Skip to content

Commit 3aeab56

Browse files
committed
refactor: harden error parser and drop dead capture mixin
1 parent ef29d8e commit 3aeab56

7 files changed

Lines changed: 409 additions & 88 deletions

File tree

buckaroo/builders/payments/capabilities/authorize_capture_capable.py

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,37 +16,34 @@
1616

1717

1818
class AuthorizeCaptureCapable:
19-
"""Mixin for payment methods that support authorization (Credit Card)."""
19+
"""Mixin contributing the Authorize / CancelAuthorize action surface.
2020
21-
def authorize(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse:
22-
"""
23-
Authorize a payment without capturing it.
21+
``capture`` lives on :class:`BaseBuilder` with the full
22+
``original_transaction_key`` / ``amount`` signature and is shared by every
23+
builder; it is intentionally not duplicated here.
24+
"""
2425

25-
Available for: Credit Card
26-
Not available for: iDEAL, Sofort, PayConiq (immediate transfer)
26+
def authorize(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse:
27+
"""Authorize a payment without capturing it.
2728
2829
Args:
29-
validate (bool): Whether to validate service parameters before building
30+
validate: Whether to validate service parameters before building.
3031
3132
Returns:
32-
PaymentResponse: The authorization response
33+
PaymentResponse: The authorization response.
3334
"""
3435
payment_request = self.build("Authorize", validate=validate)
3536
request_data = payment_request.to_dict()
3637
return self._post_transaction(request_data)
3738

3839
def authorizeEncrypted(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse:
39-
"""
40-
Authorize a payment without capturing it.
41-
42-
Available for: Credit Card
43-
Not available for: iDEAL, Sofort, PayConiq (immediate transfer)
40+
"""Authorize an encrypted-card payment without capturing it.
4441
4542
Args:
46-
validate (bool): Whether to validate service parameters before building
43+
validate: Whether to validate service parameters before building.
4744
4845
Returns:
49-
PaymentResponse: The authorization response
46+
PaymentResponse: The authorization response.
5047
"""
5148
payment_request = self.build("AuthorizeEncrypted", validate=validate)
5249
request_data = payment_request.to_dict()
@@ -57,8 +54,7 @@ def cancelAuthorize(
5754
original_transaction_key: Optional[str] = None,
5855
validate: bool = True,
5956
) -> PaymentResponse:
60-
"""
61-
Cancel a previously authorized payment.
57+
"""Cancel a previously authorized payment.
6258
6359
Uses AmountCredit (not AmountDebit) per Buckaroo API requirements.
6460
"""
@@ -83,18 +79,3 @@ def cancelAuthorize(
8379
request_data["AmountCredit"] = request_data.pop("AmountDebit")
8480

8581
return self._post_transaction(request_data)
86-
87-
def capture(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse:
88-
"""
89-
Capture a previously authorized payment.
90-
91-
Args:
92-
validate (bool): Whether to validate service parameters before building
93-
94-
Returns:
95-
PaymentResponse: The capture response
96-
"""
97-
98-
payment_request = self.build("Capture", validate=validate)
99-
request_data = payment_request.to_dict()
100-
return self._post_transaction(request_data)

buckaroo/builders/payments/riverty_builder.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
from typing import Dict, Any
22
from .payment_builder import PaymentBuilder
3+
from .capabilities.authorize_capture_capable import AuthorizeCaptureCapable
34

45

5-
class RivertyBuilder(PaymentBuilder):
6-
"""Builder for Riverty payments with bank transfer capabilities."""
6+
class RivertyBuilder(PaymentBuilder, AuthorizeCaptureCapable):
7+
"""Builder for Riverty (Afterpay New) buy-now-pay-later payments.
8+
9+
Riverty is the rebrand of AfterPay; the wire service name remains
10+
``afterpay``. The method supports Pay, Authorize/Capture/CancelAuthorize,
11+
and Refund.
12+
"""
713

814
def get_service_name(self) -> str:
915
"""Get the service name for Riverty payments."""
1016
return "afterpay"
1117

1218
def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]:
13-
"""Get the allowed service parameters for Riverty payments based on action."""
19+
"""Get the allowed service parameters for Riverty payments based on action.
1420
15-
if action.lower() in ["pay"]:
21+
Pay and Authorize share the same cart-line / customer trio.
22+
Capture and CancelAuthorize reference the original via top-level
23+
``OriginalTransactionKey`` (handled by the SDK), no service params.
24+
"""
25+
if action.lower() in ("pay", "authorize"):
1626
return {
1727
"billingCustomer": {
1828
"type": list,

buckaroo/models/payment_response.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
This module provides response objects for payment transactions.
55
"""
66

7-
from typing import Dict, Any, Optional, List
7+
from typing import Any, Dict, Iterator, List, Optional
88
from dataclasses import dataclass
99
from enum import IntEnum
1010

@@ -230,6 +230,7 @@ def _parse_response(self):
230230
self.request_errors = data.get("RequestErrors")
231231
self.related_transactions = data.get("RelatedTransactions")
232232
self.consumer_message = data.get("ConsumerMessage")
233+
self.message = data.get("Message")
233234
self.order = data.get("Order")
234235
self.issuing_country = data.get("IssuingCountry")
235236
self.start_recurrent = data.get("StartRecurrent", False)
@@ -300,6 +301,112 @@ def get_service_parameter(self, parameter_name: str) -> Optional[Any]:
300301
return param.value
301302
return None
302303

304+
_ERROR_TYPES = (
305+
"ChannelErrors",
306+
"ServiceErrors",
307+
"ActionErrors",
308+
"ParameterErrors",
309+
"CustomParameterErrors",
310+
)
311+
312+
@staticmethod
313+
def _normalize_error_bucket(bucket: Any) -> List[Dict[str, Any]]:
314+
"""Coerce a ``RequestErrors`` bucket into a list of dict entries.
315+
316+
Real-world Buckaroo responses occasionally collapse a single-error
317+
bucket into a bare dict, or supply unexpected scalars. Coerce both
318+
shapes here so callers can iterate without type checks.
319+
"""
320+
if isinstance(bucket, list):
321+
return [entry for entry in bucket if isinstance(entry, dict)]
322+
if isinstance(bucket, dict):
323+
return [bucket]
324+
return []
325+
326+
def _iter_error_entries(self) -> Iterator[Dict[str, Any]]:
327+
"""Yield error-entry dicts across every bucket in priority order."""
328+
errors = self.request_errors
329+
if not isinstance(errors, dict):
330+
return
331+
for bucket_name in self._ERROR_TYPES:
332+
for entry in self._normalize_error_bucket(errors.get(bucket_name)):
333+
yield entry
334+
335+
def has_error(self) -> bool:
336+
"""Return True when ``RequestErrors`` carries any usable entry."""
337+
return next(self._iter_error_entries(), None) is not None
338+
339+
def get_first_error(self) -> Dict[str, Any]:
340+
"""Return the first error entry from ``RequestErrors``, or ``{}``."""
341+
return next(self._iter_error_entries(), {})
342+
343+
def has_consumer_message(self) -> bool:
344+
"""Return True when the response carries a non-empty ConsumerMessage."""
345+
message = self.consumer_message or {}
346+
if isinstance(message, dict):
347+
return bool(message.get("HtmlText"))
348+
return False
349+
350+
def get_consumer_message(self) -> str:
351+
"""Return the consumer-facing HTML message, or ``''``."""
352+
message = self.consumer_message or {}
353+
if isinstance(message, dict):
354+
return message.get("HtmlText") or ""
355+
return ""
356+
357+
def has_message(self) -> bool:
358+
"""Return True when the top-level ``Message`` field is set."""
359+
return bool(self.message)
360+
361+
def get_message(self) -> str:
362+
"""Return the top-level ``Message`` field, or ``''``."""
363+
return self.message or ""
364+
365+
def has_sub_code_message(self) -> bool:
366+
"""Return True when ``Status.SubCode.Description`` is set."""
367+
return bool(
368+
self.status
369+
and self.status.sub_code
370+
and self.status.sub_code.description
371+
)
372+
373+
def get_sub_code_message(self) -> str:
374+
"""Return the ``Status.SubCode.Description``, or ``''``."""
375+
if self.has_sub_code_message():
376+
return self.status.sub_code.description
377+
return ""
378+
379+
def has_some_error(self) -> bool:
380+
"""Return True when any error/message channel carries text."""
381+
return bool(self.get_some_error())
382+
383+
def get_some_error(self) -> str:
384+
"""Return the most-specific error message from the response.
385+
386+
Walks the response in priority order, mirroring PHP SDK's
387+
``TransactionResponse::getSomeError``:
388+
389+
1. The first entry from ``RequestErrors[*]`` (ChannelErrors,
390+
ServiceErrors, ActionErrors, ParameterErrors,
391+
CustomParameterErrors) → ``ErrorMessage``.
392+
2. ``ConsumerMessage.HtmlText`` (consumer-facing copy).
393+
3. Top-level ``Message`` (gateway-level message).
394+
4. ``Status.SubCode.Description`` (e.g. Riverty 491 reason).
395+
396+
Returns ``''`` when none of those carry text.
397+
"""
398+
for entry in self._iter_error_entries():
399+
message = entry.get("ErrorMessage")
400+
if message:
401+
return message
402+
if self.has_consumer_message():
403+
return self.get_consumer_message()
404+
if self.has_message():
405+
return self.get_message()
406+
if self.has_sub_code_message():
407+
return self.get_sub_code_message()
408+
return ""
409+
303410
def to_dict(self) -> Dict[str, Any]:
304411
"""Convert the response back to a dictionary."""
305412
return self._raw_data

tests/unit/builders/payments/capabilities/test_authorize_capture_capable.py

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ def test_authorize_encrypted_posts_action_AuthorizeEncrypted(self):
8787
# ---------------------------------------------------------------------------
8888
# capture()
8989
#
90-
# AuthorizeCaptureCapable.capture is dead code: BaseBuilder.capture shadows
91-
# it through the MRO on every real builder (see TestMroShadowing below). Any
92-
# test calling the mixin method directly would only exercise unreachable code.
90+
# ``AuthorizeCaptureCapable`` does not contribute ``capture``;
91+
# :meth:`BaseBuilder.capture` carries the full
92+
# ``original_transaction_key`` / ``amount`` signature for every builder.
93+
# Behavioural coverage lives in the BaseBuilder tests.
9394
#
9495
# ---------------------------------------------------------------------------
9596
# cancelAuthorize()
@@ -208,30 +209,13 @@ def _build(action="Pay", validate=True, strict_validation=False):
208209
class TestMroShadowing:
209210
"""Pin how capability methods compose into a builder's MRO."""
210211

211-
def test_base_builder_capture_shadows_mixin_capture(self):
212+
def test_capture_resolves_to_base_builder(self):
212213
_, client = wire_recording_http()
213214
builder = _ready_builder(client)
214215

215-
# The capture the instance resolves is BaseBuilder's (needs auth key).
216+
# ``capture`` lives only on BaseBuilder; the mixin no longer ships one.
216217
assert type(builder).capture.__qualname__ == "BaseBuilder.capture"
217-
# The mixin's simpler capture is still reachable via the class itself.
218-
assert AuthorizeCaptureCapable.capture.__qualname__ == ("AuthorizeCaptureCapable.capture")
219-
220-
def test_mixin_capture_posts_capture_action_when_invoked_directly(self):
221-
"""Direct-invocation pin on the mixin's ``capture`` body.
222-
223-
No composed builder routes to this method because ``BaseBuilder.capture``
224-
shadows it in MRO. The method is only callable as an unbound reference.
225-
Pinned here so the shadowed logic still has a behavioral contract.
226-
"""
227-
mock, client = wire_recording_http()
228-
mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "cap"}))
229-
builder = _ready_builder(client)
230-
231-
response = AuthorizeCaptureCapable.capture(builder, validate=False)
232-
233-
assert recorded_action(mock) == "Capture"
234-
assert response.key == "cap"
218+
assert not hasattr(AuthorizeCaptureCapable, "capture")
235219

236220

237221
class TestMultiCapabilityBuilder:
@@ -240,10 +224,9 @@ class TestMultiCapabilityBuilder:
240224
def test_all_six_action_methods_invoke_and_post_expected_actions(self):
241225
"""MRO-resolved action methods must each post the right Buckaroo Action.
242226
243-
``capture`` resolves to :meth:`BaseBuilder.capture` (needs an auth
244-
key) - the mixin's ``capture`` is dead code. This test pins both the
245-
resolution AND the on-wire Action for every method on a composed
246-
builder.
227+
``capture`` resolves to :meth:`BaseBuilder.capture` (the mixin no
228+
longer ships one). This test pins both the resolution AND the on-wire
229+
Action for every method on a composed builder.
247230
"""
248231
mock, client = wire_recording_http()
249232
# One queued response per invoked action (six total).

tests/unit/builders/payments/test_concrete_builders_contract.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
"authorize",
5353
"authorizeEncrypted",
5454
"cancelAuthorize",
55-
"capture",
5655
],
5756
BankTransferCapabilities: ["instantRefund", "payFastCheckout"],
5857
EncryptedPayCapable: ["payEncrypted"],
@@ -144,6 +143,7 @@ def test_capability_method_present_and_callable(
144143
# of — direct mixin plus transitive bases.
145144
EXPECTED_CAPABILITIES: Dict[str, set] = {
146145
"creditcard": {EncryptedPayCapable, AuthorizeCaptureCapable},
146+
"riverty": {AuthorizeCaptureCapable},
147147
"ideal": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable},
148148
"paybybank": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable},
149149
"payconiq": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable},

0 commit comments

Comments
 (0)