python-payway 0.0.9__tar.gz → 0.0.11__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 (22) hide show
  1. {python_payway-0.0.9/python_payway.egg-info → python_payway-0.0.11}/PKG-INFO +37 -1
  2. {python_payway-0.0.9 → python_payway-0.0.11}/README.md +36 -0
  3. {python_payway-0.0.9 → python_payway-0.0.11}/payway/client.py +17 -0
  4. {python_payway-0.0.9 → python_payway-0.0.11}/payway/constants.py +1 -0
  5. {python_payway-0.0.9 → python_payway-0.0.11}/payway/model.py +25 -3
  6. {python_payway-0.0.9 → python_payway-0.0.11}/pyproject.toml +1 -1
  7. {python_payway-0.0.9 → python_payway-0.0.11/python_payway.egg-info}/PKG-INFO +37 -1
  8. {python_payway-0.0.9 → python_payway-0.0.11}/tests/test_client.py +85 -0
  9. {python_payway-0.0.9 → python_payway-0.0.11}/LICENSE +0 -0
  10. {python_payway-0.0.9 → python_payway-0.0.11}/payway/__init__.py +0 -0
  11. {python_payway-0.0.9 → python_payway-0.0.11}/payway/customers.py +0 -0
  12. {python_payway-0.0.9 → python_payway-0.0.11}/payway/exceptions.py +0 -0
  13. {python_payway-0.0.9 → python_payway-0.0.11}/payway/test_utils.py +0 -0
  14. {python_payway-0.0.9 → python_payway-0.0.11}/payway/transactions.py +0 -0
  15. {python_payway-0.0.9 → python_payway-0.0.11}/payway/utils.py +0 -0
  16. {python_payway-0.0.9 → python_payway-0.0.11}/python_payway.egg-info/SOURCES.txt +0 -0
  17. {python_payway-0.0.9 → python_payway-0.0.11}/python_payway.egg-info/dependency_links.txt +0 -0
  18. {python_payway-0.0.9 → python_payway-0.0.11}/python_payway.egg-info/requires.txt +0 -0
  19. {python_payway-0.0.9 → python_payway-0.0.11}/python_payway.egg-info/top_level.txt +0 -0
  20. {python_payway-0.0.9 → python_payway-0.0.11}/setup.cfg +0 -0
  21. {python_payway-0.0.9 → python_payway-0.0.11}/tests/test_customers.py +0 -0
  22. {python_payway-0.0.9 → python_payway-0.0.11}/tests/test_transactions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-payway
3
- Version: 0.0.9
3
+ Version: 0.0.11
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
@@ -274,6 +274,27 @@ Update a customer's payment setup with a new credit card or bank account in PayW
274
274
  payment_setup, errors = client.update_payment_setup(new_token, payway_customer.customer_number)
275
275
  ```
276
276
 
277
+ ## Renewing the secret API key
278
+
279
+ Secret API keys expire one year after they are created. PayWay generates the replacement
280
+ 40 days before that, so an application that asks for the latest key once a day and stores
281
+ what it gets back rolls onto the new key without an administrator creating one in the
282
+ PayWay website.
283
+
284
+ ```python
285
+ api_key, errors = client.get_latest_api_key()
286
+ if api_key and api_key.key != stored_secret_api_key:
287
+ # Persist api_key.key. Log api_key.key_name - it is masked; the key itself is a password.
288
+ save_secret_api_key(api_key.key)
289
+ ```
290
+
291
+ Usually the key returned is the one that authenticated the call. Renewal chains off the
292
+ live key, so if the stored key is left to expire the call fails with a `PaywayError` and
293
+ recovery means minting a key by hand — poll daily and alert on repeated failures.
294
+
295
+ To test your renewal code, create two secret API keys in the PayWay website, configure the
296
+ first, and confirm the application switches to the second on its own.
297
+
277
298
  ## Additional notes
278
299
 
279
300
  PayWay API documentation <https://www.payway.com.au/docs/rest.html>
@@ -281,6 +302,21 @@ PayWay API documentation <https://www.payway.com.au/docs/rest.html>
281
302
  It is recommended to use PayWay's Trusted Frame <https://www.payway.com.au/docs/rest.html#trusted-frame>
282
303
  when creating a single use token of a card or bank account so your PCI-compliance scope is reduced.
283
304
 
305
+ ## Keeping the raw response
306
+
307
+ Models parsed from a PayWay response keep that response verbatim on `raw`:
308
+
309
+ ```python
310
+ transaction, errors = client.process_payment(payment)
311
+ transaction.raw # exactly what PayWay returned
312
+ ```
313
+
314
+ Parsing is lossy — keys PayWay sends that the dataclass does not declare are dropped,
315
+ absent keys become `None`, and a few are renamed (`maskedCardNumber` is parsed into
316
+ `card_number`). Store `raw` rather than `to_dict()` if you are persisting responses for
317
+ auditing, reconciliation or dispute resolution. Models you construct yourself, such as a
318
+ `PayWayPayment` you are about to send, leave `raw` as `None`.
319
+
284
320
  ## Fraud
285
321
 
286
322
  Please follow PayWay's advice about reducing your risk of fraudulent transactions. <https://www.payway.com.au/docs/card-testing.html#card-testing>
@@ -251,6 +251,27 @@ Update a customer's payment setup with a new credit card or bank account in PayW
251
251
  payment_setup, errors = client.update_payment_setup(new_token, payway_customer.customer_number)
252
252
  ```
253
253
 
254
+ ## Renewing the secret API key
255
+
256
+ Secret API keys expire one year after they are created. PayWay generates the replacement
257
+ 40 days before that, so an application that asks for the latest key once a day and stores
258
+ what it gets back rolls onto the new key without an administrator creating one in the
259
+ PayWay website.
260
+
261
+ ```python
262
+ api_key, errors = client.get_latest_api_key()
263
+ if api_key and api_key.key != stored_secret_api_key:
264
+ # Persist api_key.key. Log api_key.key_name - it is masked; the key itself is a password.
265
+ save_secret_api_key(api_key.key)
266
+ ```
267
+
268
+ Usually the key returned is the one that authenticated the call. Renewal chains off the
269
+ live key, so if the stored key is left to expire the call fails with a `PaywayError` and
270
+ recovery means minting a key by hand — poll daily and alert on repeated failures.
271
+
272
+ To test your renewal code, create two secret API keys in the PayWay website, configure the
273
+ first, and confirm the application switches to the second on its own.
274
+
254
275
  ## Additional notes
255
276
 
256
277
  PayWay API documentation <https://www.payway.com.au/docs/rest.html>
@@ -258,6 +279,21 @@ PayWay API documentation <https://www.payway.com.au/docs/rest.html>
258
279
  It is recommended to use PayWay's Trusted Frame <https://www.payway.com.au/docs/rest.html#trusted-frame>
259
280
  when creating a single use token of a card or bank account so your PCI-compliance scope is reduced.
260
281
 
282
+ ## Keeping the raw response
283
+
284
+ Models parsed from a PayWay response keep that response verbatim on `raw`:
285
+
286
+ ```python
287
+ transaction, errors = client.process_payment(payment)
288
+ transaction.raw # exactly what PayWay returned
289
+ ```
290
+
291
+ Parsing is lossy — keys PayWay sends that the dataclass does not declare are dropped,
292
+ absent keys become `None`, and a few are renamed (`maskedCardNumber` is parsed into
293
+ `card_number`). Store `raw` rather than `to_dict()` if you are persisting responses for
294
+ auditing, reconciliation or dispute resolution. Models you construct yourself, such as a
295
+ `PayWayPayment` you are about to send, leave `raw` as `None`.
296
+
261
297
  ## Fraud
262
298
 
263
299
  Please follow PayWay's advice about reducing your risk of fraudulent transactions. <https://www.payway.com.au/docs/card-testing.html#card-testing>
@@ -10,6 +10,7 @@ from typing import Any
10
10
  import requests
11
11
 
12
12
  from payway.constants import (
13
+ API_KEY_URL,
13
14
  BANK_ACCOUNT_PAYMENT_CHOICE,
14
15
  CREDIT_CARD_PAYMENT_CHOICE,
15
16
  CUSTOMER_URL,
@@ -26,6 +27,7 @@ from payway.model import (
26
27
  BankAccount,
27
28
  PaymentError,
28
29
  PaymentSetup,
30
+ PayWayApiKey,
29
31
  PayWayCard,
30
32
  PayWayCustomer,
31
33
  PayWayPayment,
@@ -361,6 +363,21 @@ class Client(CustomerRequest, TransactionRequest):
361
363
  return None, errors
362
364
  return PayWayTransaction.from_dict(response.json()), errors
363
365
 
366
+ def get_latest_api_key(self) -> tuple[PayWayApiKey | None, list[PaymentError] | None]:
367
+ """
368
+ Return the secret API key to use from now on, authenticated with the current one.
369
+ Usually this is the key already in use. PayWay generates the replacement 40 days
370
+ before the current key expires, and returns that instead once it exists, so callers
371
+ polling daily and persisting the result renew without an administrator minting a key
372
+ in the PayWay website.
373
+ https://www.payway.com.au/docs/rest.html#automate-secret-api-key-renewal
374
+ """
375
+ response = self.get_request(f"{API_KEY_URL}/latest")
376
+ errors = self._validate_response(response)
377
+ if errors:
378
+ return None, errors
379
+ return PayWayApiKey.from_dict(response.json()), errors
380
+
364
381
  def get_customer(self, customer_id: str) -> tuple[PayWayCustomer | None, list[PaymentError] | None]:
365
382
  """
366
383
  Returns a PayWay Customer's Payment Setup, [Payment] Schedule, Contact Details, Custom Fields and Notes
@@ -13,6 +13,7 @@ PAYWAY_API_URL = "https://api.payway.com.au/rest/v1"
13
13
  TOKEN_URL = PAYWAY_API_URL + "/single-use-tokens"
14
14
  TRANSACTION_URL = PAYWAY_API_URL + "/transactions"
15
15
  CUSTOMER_URL = PAYWAY_API_URL + "/customers"
16
+ API_KEY_URL = PAYWAY_API_URL + "/api-keys"
16
17
  TRANSACTION_APPROVED = "0"
17
18
 
18
19
  SUMMARY_CODES = {
@@ -15,9 +15,16 @@ class PayWayModel:
15
15
  alias: PayWay key when it is not the camelCase of the field name
16
16
  exclude: omit the field from to_dict output
17
17
  from_dict: callable applied to a non-None raw value when parsing
18
+
19
+ Instances built by from_dict keep the response body they were parsed from
20
+ on ``raw``, unchanged. Parsing is lossy - undeclared PayWay keys are dropped,
21
+ absent ones become None, and aliases rename them - so callers persisting a
22
+ response for auditing or dispute resolution should store ``raw``, not
23
+ ``to_dict()``. Models you build yourself leave it None.
18
24
  """
19
25
 
20
26
  __dataclass_fields__: ClassVar[dict[str, Any]]
27
+ raw: dict[str, Any] | None = None
21
28
 
22
29
  def to_dict(self) -> dict[str, Any]:
23
30
  result = {}
@@ -39,7 +46,9 @@ class PayWayModel:
39
46
  if converter is not None and value is not None:
40
47
  value = converter(value)
41
48
  kwargs[f.name] = value
42
- return cls(**kwargs)
49
+ instance = cls(**kwargs)
50
+ instance.raw = data
51
+ return instance
43
52
 
44
53
 
45
54
  @dataclass
@@ -65,8 +74,10 @@ class PayWayCard(PayWayModel):
65
74
 
66
75
  @classmethod
67
76
  def from_dict(cls, data: dict[str, Any]) -> PayWayCard:
68
- data = {**data, "cardNumber": data.get("maskedCardNumber") or data.get("cardNumber")}
69
- return super().from_dict(data)
77
+ card = super().from_dict({**data, "cardNumber": data.get("maskedCardNumber") or data.get("cardNumber")})
78
+ # Keep PayWay's own body, not the copy rewritten for the alias above.
79
+ card.raw = data
80
+ return card
70
81
 
71
82
 
72
83
  @dataclass
@@ -284,3 +295,14 @@ class TokenResponse(PayWayModel):
284
295
  payment_method: str | None = None
285
296
  card: PayWayCard | None = field(default=None, metadata={"alias": "creditCard", "from_dict": PayWayCard.from_dict})
286
297
  bank_account: dict[str, Any] | None = None
298
+
299
+
300
+ @dataclass
301
+ class PayWayApiKey(PayWayModel):
302
+ """
303
+ key_name: str: masked form of the key, safe to log
304
+ key: str: the secret API key itself - treat it like a password
305
+ """
306
+
307
+ key_name: str | None = None
308
+ key: str | None = None
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-payway"
7
- version = "0.0.9"
7
+ version = "0.0.11"
8
8
  description = "Python client for working with Westpac's PayWay REST API"
9
9
  authors = [
10
10
  { name = "Ben Napper", email = "reppan197@gmail.com" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-payway
3
- Version: 0.0.9
3
+ Version: 0.0.11
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
@@ -274,6 +274,27 @@ Update a customer's payment setup with a new credit card or bank account in PayW
274
274
  payment_setup, errors = client.update_payment_setup(new_token, payway_customer.customer_number)
275
275
  ```
276
276
 
277
+ ## Renewing the secret API key
278
+
279
+ Secret API keys expire one year after they are created. PayWay generates the replacement
280
+ 40 days before that, so an application that asks for the latest key once a day and stores
281
+ what it gets back rolls onto the new key without an administrator creating one in the
282
+ PayWay website.
283
+
284
+ ```python
285
+ api_key, errors = client.get_latest_api_key()
286
+ if api_key and api_key.key != stored_secret_api_key:
287
+ # Persist api_key.key. Log api_key.key_name - it is masked; the key itself is a password.
288
+ save_secret_api_key(api_key.key)
289
+ ```
290
+
291
+ Usually the key returned is the one that authenticated the call. Renewal chains off the
292
+ live key, so if the stored key is left to expire the call fails with a `PaywayError` and
293
+ recovery means minting a key by hand — poll daily and alert on repeated failures.
294
+
295
+ To test your renewal code, create two secret API keys in the PayWay website, configure the
296
+ first, and confirm the application switches to the second on its own.
297
+
277
298
  ## Additional notes
278
299
 
279
300
  PayWay API documentation <https://www.payway.com.au/docs/rest.html>
@@ -281,6 +302,21 @@ PayWay API documentation <https://www.payway.com.au/docs/rest.html>
281
302
  It is recommended to use PayWay's Trusted Frame <https://www.payway.com.au/docs/rest.html#trusted-frame>
282
303
  when creating a single use token of a card or bank account so your PCI-compliance scope is reduced.
283
304
 
305
+ ## Keeping the raw response
306
+
307
+ Models parsed from a PayWay response keep that response verbatim on `raw`:
308
+
309
+ ```python
310
+ transaction, errors = client.process_payment(payment)
311
+ transaction.raw # exactly what PayWay returned
312
+ ```
313
+
314
+ Parsing is lossy — keys PayWay sends that the dataclass does not declare are dropped,
315
+ absent keys become `None`, and a few are renamed (`maskedCardNumber` is parsed into
316
+ `card_number`). Store `raw` rather than `to_dict()` if you are persisting responses for
317
+ auditing, reconciliation or dispute resolution. Models you construct yourself, such as a
318
+ `PayWayPayment` you are about to send, leave `raw` as `None`.
319
+
284
320
  ## Fraud
285
321
 
286
322
  Please follow PayWay's advice about reducing your risk of fraudulent transactions. <https://www.payway.com.au/docs/card-testing.html#card-testing>
@@ -179,6 +179,29 @@ class TestClient(unittest.TestCase):
179
179
  self.assertEqual(transaction.status, "approved")
180
180
  self.assertEqual(transaction.response_code, "11")
181
181
 
182
+ @patch("requests.post")
183
+ def test_process_payment_keeps_the_raw_response(self, mock_post) -> None:
184
+ """
185
+ Parsing drops keys PayWay sent, so ``raw`` keeps the body verbatim for
186
+ callers that persist responses for auditing or dispute resolution.
187
+ """
188
+ response = load_json_file("tests/data/transaction.json")
189
+ mock_post.return_value.status_code = 200
190
+ mock_post.return_value.json.return_value = response
191
+ payment = copy.deepcopy(self.payment)
192
+ payment.customer_number = "1"
193
+ payment.token = "2bcec36f-7b02-43db-b3ec-bfb65acfe272"
194
+ payment.order_number = "5200"
195
+ payment.merchant_id = self.client.merchant_id
196
+
197
+ transaction, _ = self.client.process_payment(payment)
198
+
199
+ self.assertEqual(transaction.raw, response)
200
+ # cardScheme and cardType are not modelled, so only raw still has them.
201
+ self.assertEqual(transaction.raw["creditCard"]["cardScheme"], "visa")
202
+ self.assertNotIn("cardScheme", transaction.to_dict()["creditCard"])
203
+ self.assertEqual(transaction.card.raw, response["creditCard"])
204
+
182
205
  @patch("requests.post")
183
206
  def test_process_payment_with_idempotency_key(self, mock_post) -> None:
184
207
  """
@@ -262,6 +285,68 @@ class TestClient(unittest.TestCase):
262
285
  self.assertIsNone(ps_errors)
263
286
  self.assertIsNotNone(ps)
264
287
 
288
+ @patch("requests.get")
289
+ def test_get_latest_api_key(self, mock_get) -> None:
290
+ mock_get.return_value.status_code = 200
291
+ mock_get.return_value.json.return_value = {
292
+ "keyName": "T10000_SEC...1A4",
293
+ "key": "T10000_SEC_RANDOM_RANDOM_RANDOM_1A4",
294
+ }
295
+
296
+ api_key, errors = self.client.get_latest_api_key()
297
+
298
+ self.assertIsNone(errors)
299
+ self.assertEqual(api_key.key_name, "T10000_SEC...1A4")
300
+ self.assertEqual(api_key.key, "T10000_SEC_RANDOM_RANDOM_RANDOM_1A4")
301
+ self.assertEqual(mock_get.call_args.kwargs["url"], "https://api.payway.com.au/rest/v1/api-keys/latest")
302
+ self.assertEqual(mock_get.call_args.kwargs["auth"], ("TPUBLISHABLE-SECRET", ""))
303
+
304
+ @patch("requests.get")
305
+ def test_get_latest_api_key_returns_the_replacement_key(self, mock_get) -> None:
306
+ """
307
+ PayWay generates the next secret key 40 days before the current one expires and
308
+ returns it here in place of the key that authenticated the request.
309
+ """
310
+ mock_get.return_value.status_code = 200
311
+ mock_get.return_value.json.return_value = {
312
+ "keyName": "T10000_SEC...9B2",
313
+ "key": "T10000_SEC_NEXT_NEXT_NEXT_9B2",
314
+ }
315
+
316
+ api_key, errors = self.client.get_latest_api_key()
317
+
318
+ self.assertIsNone(errors)
319
+ self.assertNotEqual(api_key.key, self.client.secret_api_key)
320
+ self.assertEqual(api_key.key, "T10000_SEC_NEXT_NEXT_NEXT_9B2")
321
+
322
+ @patch("requests.get")
323
+ def test_get_latest_api_key_returns_payway_errors(self, mock_get) -> None:
324
+ mock_get.return_value.status_code = 422
325
+ mock_get.return_value.json.return_value = {
326
+ "data": [{"fieldName": "apiKey", "message": "Invalid API key.", "fieldValue": ""}]
327
+ }
328
+
329
+ api_key, errors = self.client.get_latest_api_key()
330
+
331
+ self.assertIsNone(api_key)
332
+ self.assertEqual(len(errors), 1)
333
+ self.assertEqual(errors[0].message, "Invalid API key.")
334
+
335
+ @patch("requests.get")
336
+ def test_get_latest_api_key_raises_when_the_current_key_is_rejected(self, mock_get) -> None:
337
+ """
338
+ Renewal chains off the live key, so an expired one cannot fetch its replacement -
339
+ recovering needs an administrator to create a key in the PayWay website.
340
+ """
341
+ mock_get.return_value.status_code = 401
342
+ mock_get.return_value.reason = "Unauthorized"
343
+ mock_get.return_value.url = "https://api.payway.com.au/rest/v1/api-keys/latest"
344
+
345
+ with self.assertRaises(PaywayError) as context:
346
+ self.client.get_latest_api_key()
347
+
348
+ self.assertIn("401", str(context.exception))
349
+
265
350
 
266
351
  class TestClientRetries(unittest.TestCase):
267
352
  def setUp(self) -> None:
File without changes
File without changes