python-payway 0.0.7__tar.gz → 0.0.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {python_payway-0.0.7/python_payway.egg-info → python_payway-0.0.8}/PKG-INFO +39 -7
  2. {python_payway-0.0.7 → python_payway-0.0.8}/README.md +35 -4
  3. {python_payway-0.0.7 → python_payway-0.0.8}/payway/client.py +71 -28
  4. {python_payway-0.0.7 → python_payway-0.0.8}/payway/constants.py +28 -14
  5. python_payway-0.0.8/payway/model.py +286 -0
  6. python_payway-0.0.8/payway/utils.py +27 -0
  7. {python_payway-0.0.7 → python_payway-0.0.8}/pyproject.toml +7 -6
  8. {python_payway-0.0.7 → python_payway-0.0.8/python_payway.egg-info}/PKG-INFO +39 -7
  9. {python_payway-0.0.7 → python_payway-0.0.8}/python_payway.egg-info/SOURCES.txt +0 -1
  10. {python_payway-0.0.7 → python_payway-0.0.8}/tests/test_client.py +115 -1
  11. python_payway-0.0.7/payway/model.py +0 -453
  12. python_payway-0.0.7/payway/utils.py +0 -23
  13. python_payway-0.0.7/tests/__init__.py +0 -0
  14. {python_payway-0.0.7 → python_payway-0.0.8}/LICENSE +0 -0
  15. {python_payway-0.0.7 → python_payway-0.0.8}/payway/__init__.py +0 -0
  16. {python_payway-0.0.7 → python_payway-0.0.8}/payway/customers.py +0 -0
  17. {python_payway-0.0.7 → python_payway-0.0.8}/payway/exceptions.py +0 -0
  18. {python_payway-0.0.7 → python_payway-0.0.8}/payway/test_utils.py +0 -0
  19. {python_payway-0.0.7 → python_payway-0.0.8}/payway/transactions.py +0 -0
  20. {python_payway-0.0.7 → python_payway-0.0.8}/python_payway.egg-info/dependency_links.txt +0 -0
  21. {python_payway-0.0.7 → python_payway-0.0.8}/python_payway.egg-info/requires.txt +0 -0
  22. {python_payway-0.0.7 → python_payway-0.0.8}/python_payway.egg-info/top_level.txt +0 -0
  23. {python_payway-0.0.7 → python_payway-0.0.8}/setup.cfg +0 -0
  24. {python_payway-0.0.7 → python_payway-0.0.8}/tests/test_customers.py +0 -0
  25. {python_payway-0.0.7 → python_payway-0.0.8}/tests/test_transactions.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: python-payway
3
- Version: 0.0.7
3
+ Version: 0.0.8
4
4
  Summary: Python client for working with Westpac's PayWay REST API
5
5
  Author-email: Ben Napper <reppan197@gmail.com>
6
6
  License: MIT
@@ -11,7 +11,7 @@ Keywords: payway,westpac,api,client
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Operating System :: OS Independent
14
- Requires-Python: >=3.8
14
+ Requires-Python: >=3.11
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: requests>=2.20.0
@@ -19,6 +19,7 @@ Provides-Extra: dev
19
19
  Requires-Dist: pre-commit>=3.5.0; extra == "dev"
20
20
  Requires-Dist: ruff==0.6.6; extra == "dev"
21
21
  Requires-Dist: mypy>=0.971; extra == "dev"
22
+ Dynamic: license-file
22
23
 
23
24
  # PayWay REST API - Python
24
25
 
@@ -32,6 +33,8 @@ Requires-Dist: mypy>=0.971; extra == "dev"
32
33
 
33
34
  ## Install
34
35
 
36
+ Requires Python 3.11 or later.
37
+
35
38
  ```bash
36
39
  pip install python-payway
37
40
  ```
@@ -131,6 +134,36 @@ payment = PayWayPayment(customer_number=customer_number,
131
134
  transaction, errors = client.process_payment(payment)
132
135
  ```
133
136
 
137
+ ## Retries
138
+
139
+ Retries are off by default. Opt in with `max_retries` (and optionally `retry_delay`, the base
140
+ wait in seconds between attempts):
141
+
142
+ ```python
143
+ client = Client(merchant_id='<your_payway_merchant_id>',
144
+ bank_account_id='<your_payway_bank_account_id>',
145
+ publishable_api_key='<your_payway_publishable_api_key>',
146
+ secret_api_key='<your_payway_secret_api_key>',
147
+ max_retries=2,
148
+ retry_delay=1.0)
149
+ ```
150
+
151
+ This follows PayWay's retry guidance (<https://www.payway.com.au/docs/rest.html#network-errors>):
152
+
153
+ - Requests are resent on connection errors, timeouts and HTTP 429/503 responses, waiting
154
+ `retry_delay` seconds between attempts (linear backoff, or the response's `Retry-After`
155
+ header when present). PayWay suggests a 20 second wait; keep `retry_delay` small for
156
+ synchronous checkout flows.
157
+ - POSTs are only retried when an `idempotency_key` was supplied — the same
158
+ `Idempotency-Key` is resent so PayWay replays the original response instead of
159
+ processing a duplicate payment. POSTs without a key (and PUTs) are never retried.
160
+ GETs are always safe to retry.
161
+ - Other errors (including HTTP 500/502/504) are never retried, per PayWay's advice.
162
+
163
+ ```python
164
+ transaction, errors = client.process_payment(payment, idempotency_key=str(uuid.uuid4()))
165
+ ```
166
+
134
167
  ## Handling errors
135
168
 
136
169
  Documented errors (such as 422 Unprocessable entity) are parsed into an PaymentError class that you can use in an customer error message.
@@ -234,12 +267,11 @@ Please follow PayWay's advice about reducing your risk of fraudulent transaction
234
267
  ## Running the project
235
268
 
236
269
  ```bash
237
- uv python install 3.8.19
238
- uv venv --python 3.8.19
239
- source .venv/bin/activate
240
- uv sync --extra dev
270
+ uv sync
241
271
  ```
242
272
 
273
+ uv provisions a suitable Python (3.11+) and installs the dev dependencies automatically.
274
+
243
275
  ## Testing
244
276
 
245
277
  ```bash
@@ -10,6 +10,8 @@
10
10
 
11
11
  ## Install
12
12
 
13
+ Requires Python 3.11 or later.
14
+
13
15
  ```bash
14
16
  pip install python-payway
15
17
  ```
@@ -109,6 +111,36 @@ payment = PayWayPayment(customer_number=customer_number,
109
111
  transaction, errors = client.process_payment(payment)
110
112
  ```
111
113
 
114
+ ## Retries
115
+
116
+ Retries are off by default. Opt in with `max_retries` (and optionally `retry_delay`, the base
117
+ wait in seconds between attempts):
118
+
119
+ ```python
120
+ client = Client(merchant_id='<your_payway_merchant_id>',
121
+ bank_account_id='<your_payway_bank_account_id>',
122
+ publishable_api_key='<your_payway_publishable_api_key>',
123
+ secret_api_key='<your_payway_secret_api_key>',
124
+ max_retries=2,
125
+ retry_delay=1.0)
126
+ ```
127
+
128
+ This follows PayWay's retry guidance (<https://www.payway.com.au/docs/rest.html#network-errors>):
129
+
130
+ - Requests are resent on connection errors, timeouts and HTTP 429/503 responses, waiting
131
+ `retry_delay` seconds between attempts (linear backoff, or the response's `Retry-After`
132
+ header when present). PayWay suggests a 20 second wait; keep `retry_delay` small for
133
+ synchronous checkout flows.
134
+ - POSTs are only retried when an `idempotency_key` was supplied — the same
135
+ `Idempotency-Key` is resent so PayWay replays the original response instead of
136
+ processing a duplicate payment. POSTs without a key (and PUTs) are never retried.
137
+ GETs are always safe to retry.
138
+ - Other errors (including HTTP 500/502/504) are never retried, per PayWay's advice.
139
+
140
+ ```python
141
+ transaction, errors = client.process_payment(payment, idempotency_key=str(uuid.uuid4()))
142
+ ```
143
+
112
144
  ## Handling errors
113
145
 
114
146
  Documented errors (such as 422 Unprocessable entity) are parsed into an PaymentError class that you can use in an customer error message.
@@ -212,12 +244,11 @@ Please follow PayWay's advice about reducing your risk of fraudulent transaction
212
244
  ## Running the project
213
245
 
214
246
  ```bash
215
- uv python install 3.8.19
216
- uv venv --python 3.8.19
217
- source .venv/bin/activate
218
- uv sync --extra dev
247
+ uv sync
219
248
  ```
220
249
 
250
+ uv provisions a suitable Python (3.11+) and installs the dev dependencies automatically.
251
+
221
252
  ## Testing
222
253
 
223
254
  ```bash
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import time
5
+ from collections.abc import Callable
6
+ from http import HTTPStatus
4
7
  from logging import getLogger
5
8
  from typing import Any
6
9
 
@@ -11,9 +14,11 @@ from payway.constants import (
11
14
  CREDIT_CARD_PAYMENT_CHOICE,
12
15
  CUSTOMER_URL,
13
16
  PAYWAY_ERROR_RESPONSE_CODES,
17
+ RETRYABLE_STATUS_CODES,
14
18
  TOKEN_NO_REDIRECT,
15
19
  TRANSACTION_URL,
16
20
  VALID_PAYMENT_METHOD_CHOICES,
21
+ PaymentMethod,
17
22
  )
18
23
  from payway.customers import CustomerRequest
19
24
  from payway.exceptions import PaywayError
@@ -43,18 +48,22 @@ class Client(CustomerRequest, TransactionRequest):
43
48
  secret_api_key = ""
44
49
  publishable_api_key = ""
45
50
 
46
- def __init__(
51
+ def __init__( # noqa: PLR0913
47
52
  self,
48
53
  merchant_id: str,
49
54
  bank_account_id: str,
50
55
  secret_api_key: str,
51
56
  publishable_api_key: str,
57
+ max_retries: int = 0,
58
+ retry_delay: float = 1.0,
52
59
  ) -> None:
53
60
  """
54
61
  :param merchant_id: PayWay Merchant ID
55
62
  :param bank_account_id: PayWay Bank Account ID
56
63
  :param secret_api_key: PayWay Secret APi Key
57
64
  :param publishable_api_key: PayWay Publishable API Key
65
+ :param max_retries: retries per request on network errors and HTTP 429/503 (0 disables)
66
+ :param retry_delay: base seconds to wait between attempts (Retry-After header wins if present)
58
67
  """
59
68
  self._validate_credentials(
60
69
  merchant_id,
@@ -66,9 +75,11 @@ class Client(CustomerRequest, TransactionRequest):
66
75
  self.bank_account_id = bank_account_id
67
76
  self.secret_api_key = secret_api_key
68
77
  self.publishable_api_key = publishable_api_key
78
+ self.max_retries = max_retries
79
+ self.retry_delay = retry_delay
69
80
  session = requests.Session()
70
81
  session.auth = (self.secret_api_key, "")
71
- session.headers = {"content-type": "application/x-www-form-urlencoded"}
82
+ session.headers["content-type"] = "application/x-www-form-urlencoded"
72
83
  self.session = session
73
84
  session_no_headers = requests.Session()
74
85
  session_no_headers.auth = session.auth
@@ -95,7 +106,10 @@ class Client(CustomerRequest, TransactionRequest):
95
106
  )
96
107
 
97
108
  def get_request(self, endpoint: str) -> requests.Response:
98
- return requests.get(url=endpoint, auth=(self.secret_api_key, ""), timeout=30)
109
+ return self._send_with_retries(
110
+ lambda: requests.get(url=endpoint, auth=(self.secret_api_key, ""), timeout=30),
111
+ can_retry=True,
112
+ )
99
113
 
100
114
  def post_request(
101
115
  self, endpoint: str, data: dict[str, Any], auth: tuple[str, str] | None = None, idempotency_key: str | None = None
@@ -109,9 +123,41 @@ class Client(CustomerRequest, TransactionRequest):
109
123
  headers = {"content-type": "application/x-www-form-urlencoded"}
110
124
  if idempotency_key:
111
125
  headers["Idempotency-Key"] = idempotency_key
112
- return requests.post(url=endpoint, auth=auth, data=data, headers=headers, timeout=30)
126
+ return self._send_with_retries(
127
+ lambda: requests.post(url=endpoint, auth=auth, data=data, headers=headers, timeout=30),
128
+ can_retry=bool(idempotency_key),
129
+ )
130
+
131
+ def _send_with_retries(self, send: Callable[[], requests.Response], can_retry: bool) -> requests.Response: # noqa: FBT001
132
+ """
133
+ Resend on network errors and HTTP 429/503 per PayWay's retry guidance
134
+ https://www.payway.com.au/docs/rest.html#network-errors
135
+ Requests without an Idempotency-Key must pass can_retry=False: retrying
136
+ them could double-charge. Other statuses (including 500/502/504) are
137
+ returned as-is.
138
+ """
139
+ retries = self.max_retries if can_retry else 0
140
+ for attempt in range(retries):
141
+ try:
142
+ response = send()
143
+ except (requests.ConnectionError, requests.Timeout) as exc:
144
+ logger.warning("PayWay request failed (%s), retrying", exc)
145
+ time.sleep(self._retry_wait(attempt, None))
146
+ continue
147
+ if response.status_code not in RETRYABLE_STATUS_CODES:
148
+ return response
149
+ logger.warning("PayWay responded %s, retrying", response.status_code)
150
+ time.sleep(self._retry_wait(attempt, response))
151
+ return send()
152
+
153
+ def _retry_wait(self, attempt: int, response: requests.Response | None) -> float:
154
+ retry_after = response.headers.get("Retry-After") if response is not None else None
155
+ if retry_after and retry_after.isdigit():
156
+ return float(retry_after)
157
+ return self.retry_delay * (attempt + 1)
113
158
 
114
159
  def put_request(self, endpoint: str, data: dict[str, Any]) -> requests.Response:
160
+ # No Idempotency-Key is sent on PUTs, so they are never retried
115
161
  return requests.put(
116
162
  url=endpoint,
117
163
  auth=(self.secret_api_key, ""),
@@ -121,30 +167,27 @@ class Client(CustomerRequest, TransactionRequest):
121
167
  )
122
168
 
123
169
  def create_token(
124
- self, payway_obj: BankAccount | PayWayCard, payment_method: str, idempotency_key: str | None = None
170
+ self, payway_obj: BankAccount | PayWayCard, payment_method: PaymentMethod | str, idempotency_key: str | None = None
125
171
  ) -> tuple[TokenResponse | None, list[PaymentError] | None]:
126
172
  """
127
173
  Creates a single use token for a Customer's payment setup (credit card or bank account)
128
174
  :param payway_obj: object: one of model.PayWayCard or model.BankAccount object
129
- :param payment_method: str: one of `card` or `direct_debit`
175
+ :param payment_method: PaymentMethod or str: one of `card` or `direct_debit`
130
176
  :param idempotency_key: str: unique value to avoid duplicate POSTs
131
177
  """
132
- data = payway_obj.to_dict()
133
- if payment_method == "card":
134
- payway_payment_method = CREDIT_CARD_PAYMENT_CHOICE
135
- elif payment_method == "direct_debit":
136
- payway_payment_method = BANK_ACCOUNT_PAYMENT_CHOICE
137
- else:
178
+ try:
179
+ payment_method = PaymentMethod(payment_method)
180
+ except ValueError as exc:
138
181
  valid_payment_method_choices = ", ".join(VALID_PAYMENT_METHOD_CHOICES)
139
182
  raise PaywayError(
140
183
  message=f"Invalid payment method. Must be one of {valid_payment_method_choices}",
141
184
  code="INVALID_PAYMENT_METHOD",
142
- )
143
- data.update(
144
- {
145
- "paymentMethod": payway_payment_method,
146
- },
147
- )
185
+ ) from exc
186
+ data = payway_obj.to_dict()
187
+ if payment_method is PaymentMethod.CARD:
188
+ data["paymentMethod"] = CREDIT_CARD_PAYMENT_CHOICE
189
+ else:
190
+ data["paymentMethod"] = BANK_ACCOUNT_PAYMENT_CHOICE
148
191
  logger.info("Sending Create Token request to PayWay.")
149
192
  response = self.post_request(
150
193
  TOKEN_NO_REDIRECT,
@@ -155,7 +198,7 @@ class Client(CustomerRequest, TransactionRequest):
155
198
  errors = self._validate_response(response)
156
199
  if errors:
157
200
  return None, errors
158
- return TokenResponse().from_dict(response.json()), errors
201
+ return TokenResponse.from_dict(response.json()), errors
159
202
 
160
203
  def create_card_token(
161
204
  self, card: PayWayCard, idempotency_key: str | None = None
@@ -164,7 +207,7 @@ class Client(CustomerRequest, TransactionRequest):
164
207
  :param card: PayWayCard object represents a customer's credit card details
165
208
  :param idempotency_key: str: unique value to avoid duplicate POSTs
166
209
  """
167
- return self.create_token(card, "card", idempotency_key=idempotency_key)
210
+ return self.create_token(card, PaymentMethod.CARD, idempotency_key=idempotency_key)
168
211
 
169
212
  def create_bank_account_token(
170
213
  self, bank_account: BankAccount, idempotency_key: str | None = None
@@ -176,7 +219,7 @@ class Client(CustomerRequest, TransactionRequest):
176
219
  """
177
220
  return self.create_token(
178
221
  bank_account,
179
- "direct_debit",
222
+ PaymentMethod.DIRECT_DEBIT,
180
223
  idempotency_key=idempotency_key,
181
224
  )
182
225
 
@@ -210,7 +253,7 @@ class Client(CustomerRequest, TransactionRequest):
210
253
  errors = self._validate_response(response)
211
254
  if errors:
212
255
  return None, errors
213
- customer = PayWayCustomer().from_dict(response.json())
256
+ customer = PayWayCustomer.from_dict(response.json())
214
257
  return customer, errors
215
258
 
216
259
  def process_payment(
@@ -241,19 +284,19 @@ class Client(CustomerRequest, TransactionRequest):
241
284
  http_error_msg = f"{response.status_code} Client Error: {response.reason} for url: {response.url}"
242
285
  raise PaywayError(code=str(response.status_code), message=http_error_msg)
243
286
 
244
- if response.status_code in [404, 422]: # Documented PayWay errors in JSON
245
- return PaymentError().from_dict(response.json())
287
+ if response.status_code in [HTTPStatus.NOT_FOUND, HTTPStatus.UNPROCESSABLE_ENTITY]: # Documented PayWay errors in JSON
288
+ return PaymentError.from_dict(response.json())
246
289
 
247
- if response.status_code == 500:
290
+ if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
248
291
  try:
249
292
  errors = response.json()
250
- except json.JSONDecodeError:
293
+ except json.JSONDecodeError as exc:
251
294
  raise PaywayError(
252
295
  code=str(response.status_code),
253
296
  message="Internal server error",
254
- )
297
+ ) from exc
255
298
  # Documented PayWay server errors in JSON
256
- payway_error = ServerError().from_dict(errors)
299
+ payway_error = ServerError.from_dict(errors)
257
300
  message = payway_error.to_message()
258
301
  raise PaywayError(code=str(response.status_code), message=message)
259
302
 
@@ -1,5 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from enum import StrEnum
4
+ from http import HTTPStatus
5
+
6
+
7
+ class PaymentMethod(StrEnum):
8
+ CARD = "card"
9
+ DIRECT_DEBIT = "direct_debit"
10
+
11
+
3
12
  PAYWAY_API_URL = "https://api.payway.com.au/rest/v1"
4
13
  TOKEN_URL = PAYWAY_API_URL + "/single-use-tokens-redirect"
5
14
  TRANSACTION_URL = PAYWAY_API_URL + "/transactions"
@@ -65,7 +74,10 @@ EFT_RESPONSE_CODES = {
65
74
  "QS": "Transaction Successful",
66
75
  "QT": "Invalid currency",
67
76
  "QU": "Unknown Customer IP Address",
68
- "QV": "Invalid Original Order Number specified for Refund, Refund amount exceeds capture amount, or Previous capture was not approved",
77
+ "QV": (
78
+ "Invalid Original Order Number specified for Refund, Refund amount exceeds capture amount, "
79
+ "or Previous capture was not approved"
80
+ ),
69
81
  "QW": "Invalid Reference Number",
70
82
  "QX": "Network Error has occurred",
71
83
  "QY": "Card Type Not Accepted",
@@ -138,18 +150,20 @@ DIRECT_DEBIT_CHOICES = (
138
150
  ("remittanceProcessingService", "Remittance Processing Service"),
139
151
  )
140
152
  PAYMENT_METHOD_CHOICES = OTHER_PAYMENT_CHOICES + DIRECT_DEBIT_CHOICES
141
- VALID_PAYMENT_METHOD_CHOICES = ["card", "direct_debit"]
153
+ VALID_PAYMENT_METHOD_CHOICES = [method.value for method in PaymentMethod]
154
+ # PayWay advises resending with the same Idempotency-Key on 429/503 only
155
+ RETRYABLE_STATUS_CODES = frozenset({HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.SERVICE_UNAVAILABLE})
142
156
  PAYWAY_ERROR_RESPONSE_CODES = [
143
- 400,
144
- 401,
145
- 403,
146
- 405,
147
- 406,
148
- 407,
149
- 409,
150
- 410,
151
- 415,
152
- 429,
153
- 501,
154
- 503,
157
+ HTTPStatus.BAD_REQUEST,
158
+ HTTPStatus.UNAUTHORIZED,
159
+ HTTPStatus.FORBIDDEN,
160
+ HTTPStatus.METHOD_NOT_ALLOWED,
161
+ HTTPStatus.NOT_ACCEPTABLE,
162
+ HTTPStatus.PROXY_AUTHENTICATION_REQUIRED,
163
+ HTTPStatus.CONFLICT,
164
+ HTTPStatus.GONE,
165
+ HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
166
+ HTTPStatus.TOO_MANY_REQUESTS,
167
+ HTTPStatus.NOT_IMPLEMENTED,
168
+ HTTPStatus.SERVICE_UNAVAILABLE,
155
169
  ]