Skip to content

Latest commit

 

History

History
89 lines (66 loc) · 3.64 KB

File metadata and controls

89 lines (66 loc) · 3.64 KB

Contributing

Setup

python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
python -m pytest        # or: python -m unittest discover -s tests
ruff check . && mypy

The test suite is written with unittest, so it also runs with no dependencies via python -m unittest discover -s tests.

The one thing to get right: signature parity

The SDK's core job is reproducing a byte-exact, order-sensitive HMAC that the server rebuilds independently. The server signs base64(hmac_sha256(implode('', Arr::except($request->validated(), [...])))), and Laravel's validated() returns fields in rules() order, skipping absent ones.

Because the SDK uses snake_case parameters that match the wire names, the order of fields in src/paylink/_field_orders.py must match the order of keys in the endpoint's FormRequest rules(), minus token/signature (and payment_mode, which is sent with signed=False).

When you touch an endpoint:

  1. Open the matching FormRequest under app/Http/Requests/Application/ExternalPaymentIntegration/ in the PayLink repo and read rules() top to bottom.
  2. Mirror that order in the EndpointSpec. build_signed_body signs the values in that order — order is not something the type checker can verify for you.
  3. Add a golden vector (below).

Golden vectors

tests/golden-signatures.json is shared with the JS SDK and generated by PHP using the same primitive the server uses, with no Laravel or DB dependency. It is what proves the Python, JS, and PHP implementations agree byte-for-byte.

Each case pairs a camelCase input with values (the exact ordered concatenation the server builds) and the expected signature. test_golden_signatures.py asserts both that build_signature(values) produces expected and that build_signed_body derives the same signature from structured input.

Adding a field to an endpoint without adding a golden case means that field's position is untested.

Webhooks sign by opt-out — the mirror-image trap

Requests sign by opt-in: _field_orders.py lists exactly what gets signed. Webhooks are the opposite. PaymentIntegrationWebhookJob copies the whole payload and unset()s a fixed exclusion list before hashing, so a new webhook field is signed if it is added before that unset() and unsigned if added after (as auth_code is).

_OPTIONAL_SIGNED in src/paylink/webhooks.py must mirror that decision, and because presence is detected with in payload, it is a strict either/or:

Server behaviour _OPTIONAL_SIGNED
Signs the field (added before unset) must list it
Sends it unsigned (added after) must not list it

Getting it backwards breaks verification for every webhook carrying that field.

Retry safety

Requests are only ever replayed when replaying cannot double-charge: GETs, anything carrying an Idempotency-Key, and calls explicitly flagged replay_safe (pure reads such as check-status). The default for a POST is "do not retry"; override it only deliberately.

Style

  • Explicit type hints; the package ships py.typed and is checked with mypy --strict.
  • Keep runtime dependencies at zero — the standard library only.
  • Prefer a docstring explaining why over an inline comment restating what.

Releasing

  1. Bump the version in two places — they must agree:
    • [project].version in pyproject.toml, and
    • __version__ in src/paylink/_version.py (the User-Agent would otherwise report a stale version).
  2. Update CHANGELOG.md.
  3. Tag the release v<version> and publish to PyPI.