docs: Document every class and fix the docstrings that were wrong - #12
Merged
Conversation
sveneberth
force-pushed
the
docs/class-docstrings
branch
from
August 21, 2026 23:24
d1d7f5c to
8bcca94
Compare
All 60 classes have a docstring now, where 31 did. The ones worth reading are the models that carry behaviour rather than just fields: what a payment is as opposed to a transaction, that a pending response is not a failure, that `processing` holds different fields per payment method and may be incomplete while a verification is outstanding. The payment type classes explain why they are empty — created in the browser, so there is nothing for the server to send — and `Card` says outright that taking card data would put the integration under PCI-DSS obligations. Three docstrings were wrong: `Customer` had firstname and lastname swapped in their descriptions, and `Address` called two positional parameters optional. Property getters now carry the documentation rather than only their setters, because that is the one Sphinx renders. And the two public `charge()` methods were undocumented, which for a method that captures money is the wrong place to be terse. Module docstrings for the six modules where they orient rather than repeat the filename. `serialize`/`fromDict` overrides stay undocumented on purpose: their contract is on `BaseModel` and an override that does nothing special has nothing to add. Along the way: the client check in `get_configuration` was duplicated in `get_configurations`, which is where the client is actually used.
sveneberth
force-pushed
the
docs/class-docstrings
branch
from
August 21, 2026 23:32
8bcca94 to
79416b5
Compare
sveneberth
added a commit
that referenced
this pull request
Sep 2, 2026
…that survive an unexpected body (#13) Three findings from tracking down a failing Klarna checkout, all measured against the sandbox. ## How a discount has to reach a basket Neither schema accepts a basket item with a negative amount — so a discount cannot be sent as its own negative `voucher` line, which is the shape a consumer arrives at naturally (one item per article, one for the voucher, the grosses adding up to the order total). It belongs in a positive discount field of the item it reduces, and the two schemas verify very different amounts of it: | | v1 | v3 | |---|---|---| | negative item amounts | `API.600.200.131` + `API.600.410.018` | `API.600.200.131` | | discount field | `amountDiscount` (per line or per unit is **unverified**, see below) | `amountDiscountPerUnitGross`, **per unit** | | item amount | `amountGross` stays the pre-discount gross | `amountPerUnitGross` minus the discount must stay positive | | total | **not checked at all** | exact to the cent, `API.600.410.062` | | `vat` per item | optional, stored as 0 | mandatory, `API.600.410.052` | | discount above the item | accepted | refused, `API.600.410.064` | `TestBasket` grows from 2 to 13 tests, one per row plus the per-unit semantics of the v3 discount. `AGENTS.md` carries the same as prose, with the v1 tolerance marked as a warning rather than a licence: a charge does not compare the basket to the payment amount either, but a method that hands the basket to a partner system may well be stricter. Two things about the last row are worth stating precisely, because a first version of it was wrong. The per-item cap only shows itself when the basket total stays positive: with a single over-discounted line the total goes negative too and the API answers the generic `API.600.200.131` "Amount has to be positive", which the negative total alone explains. With a second, larger item it names the line instead — `API.600.410.064`, "Basket item i1 'amountDiscountPerUnitGross' does not equal to 'amountPerUnitGross'". And the charge-does-not-check-the-basket claim is now backed by Prepayment rather than iDEAL, which this repository states cannot be driven server-side: against one basket worth 817.02, charges of 817.02, 726.24, 907.80 and **1.00** were all accepted and booked at face value. The two existing basket tests are fixed along the way: they passed `type="goods"`, but the parameter is `kind`. `BaseModel.__init__` swallows unknown kwargs, so `"type": ""` went out and no item type was ever exercised. ## Error responses that survive an unexpected body ```py errorResponse = ErrorResponse.fromDict(r.json()) # ← raised JSONDecodeError ``` Not every 4xx comes from the API. A gateway in front of it answers with an HTML page, and parsing that as JSON raised a bare `JSONDecodeError` from inside the SDK — losing the status code and the body, and looking like an SDK bug rather than a refused request. Guarding that turned out to need three steps rather than one, because the first attempt was both too narrow and too wide: - **Too narrow.** `fromDict` indexed `timestamp`, `url` and `errors`, so a 4xx whose body *is* JSON but is not this API's envelope raised `KeyError`/`TypeError` — not a `ValueError`. Measured, these all still escaped `_request` bare: `{"message": "Forbidden"}` (the canonical API-gateway body), `null`, `[]`, `"Forbidden"`. - **Too wide.** `fromDict` parsed the timestamp with one hard-coded format while the API is known to use two, and that `ValueError` landed in the same handler. A well-formed API error with a different format was reported as "non-JSON body" — while quoting JSON — and arrived with an empty `errors` list. The second is the one that mattered: `UnzerClient.createOrUpdateCustomer` falls back to `updateCustomer` on `errors[0].code == "API.410.200.010"`, so an emptied list turns a recoverable checkout into a failed one, silently. That path is exercised routinely in production. So `_request` now guards only `r.json()` and reports a body that decodes but does not fit the schema separately, and `ErrorResponse.fromDict` treats only `errors` as required — it is what identifies the body as an error envelope. Everything else missing or unreadable costs that field alone: the timestamp goes through `utils.parseDateTime` and falls back to `None` with a warning, and `Error` gained defaults so an entry missing `merchantMessage` keeps its code. A body that is not an envelope still raises, so callers keep the distinction between "the API refused this" and "something else answered". Measured across `2026-08-21 10:15:32`, `21.08.2026 10:15:32`, `2026-08-21T10:15:32`, `2026-08-21 10:15:32.123` and a missing timestamp: the codes arrive in all five and the `createOrUpdateCustomer` fallback fires again. Closes #14. The trigger that surfaced all this is worth knowing for anyone developing locally: | `returnUrl` | Result | |---|---| | `http://localhost:8080/…` | **403, nginx HTML** | | `http://127.0.0.1:8080/…`, `https://localhost:8080/…` | **403, nginx HTML** | | `http://192.168.1.5:8080/…` | **403, nginx HTML** | | `https://example.com/…` | API answers | | `http://lvh.me:8080/…`, `http://localtest.me:8080/…`, `http://127-0-0-1.nip.io:8080/…` | API answers | The gateway refuses localhost and private IP literals before the API sees the request. Hostnames that merely *resolve* to 127.0.0.1 pass, so the check is on the string. A rate limit is ruled out: `localhost` still gets a 403 right after a successful call with a public host. ## Documentation claims corrected by measurement Three assertions in the docstrings and in `AGENTS.md` did not survive being checked: - **The Pay later methods do not require v3.** A sandbox *authorize* with `PaylaterInstallment` and with Klarna succeeds on a v1 basket carrying `amountDiscount` exactly as it does on v3, booking the requested amount unchanged. The `Basket` docstring and the schema table both claimed otherwise. - **`vat` is mandatory in v3**, not "optional" as `BasketItem` said. - **Mixing the schemas fails silently in one direction.** A v3 item in a v1 basket is accepted with a 201 and every item amount stored as `0.0000`; the reverse is refused loudly with `API.600.410.051`. A basket is also only readable through the schema it was created with (`API.600.410.024`), and single use — `API.330.200.152` on a second charge, so a retry after a failed authorize needs a new one. The silent-zeroing case is filed as #16, since enforcing it belongs in `Basket.serialize`. The v1 `amountDiscount` semantics are now labelled *unverified* rather than asserted: the table said per line, `basketItem.py:47` says per unit, every v1 discount test uses `quantity=1` where the two are indistinguishable, and v1 reconciles nothing — so the basket endpoint cannot settle it. Only the rendered basket or the partner system can. ## Verification - `pytest -m sandbox tests/sandbox/test_live_api.py::TestBasket` — 13 passed against the real sandbox. - `pytest` — 291 passed, including the new mocked cases for the HTML body, the five foreign-JSON shapes, the five timestamp formats and the incomplete error entry. - `pycodestyle --diff` — clean. - One pre-existing sandbox test is red and untouched by this branch (verified by stashing): `TestPaymentPage::test_redirect_url_points_at_the_sandbox` expects `sbx-` in the redirect URL, the API now answers `payment.test.unzer.com`. ## Filed rather than fixed here #15 (the remaining `_request` branches: retry-exhausted `statusCode == 0`, the unguarded and doubled `r.json()` on 2xx, `<= 201` sending a 204 into the error branch, `r` not reset after a transport failure), #16 (schema mix), #17 (`BaseModel` swallows unknown kwargs — `examples/03_installment_plans.py:57` still carries the `type=` bug this branch fixed in the tests), #18 (`BasketItem.serialize` does not round its amounts while `Basket.serialize` does). ## Note on the target branch Against `develop`, not `main`, although the commits are patch level: `main` carries neither `tests/` nor `AGENTS.md` yet — #8 through #12 are still only on `develop`, so the files this branch changes do not exist there.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #11 — merge that first, GitHub then retargets this to
develop.All 60 classes have a docstring now, where 31 did. The ones worth reading are the models that
carry behaviour rather than just fields: what a payment is as opposed to a transaction, that a
pending response is not a failure but a redirect the customer still has to follow, that
processingholds different fields per payment method and can be incomplete while a verificationis outstanding.
The payment type classes explain why they are empty — created in the browser, so there is
nothing for the server to send — and
Cardsays outright that taking card data would put theintegration under PCI-DSS obligations. That question came up twice while working on this SDK, and
the answer belongs where the empty class is.
Three docstrings were factually wrong:
Customerhad firstname and lastname swapped in theirdescriptions, and
Addresscalled two positional parameters optional.Property getters now carry the documentation rather than only their setters, because the getter's
docstring is the one Sphinx renders. And the two public
charge()methods had none at all, whichfor a method that captures money is the wrong place to be terse.
Module docstrings for the six modules where they orient rather than restate the filename.
serialize/fromDictoverrides stay undocumented on purpose: their contract is onBaseModel,and an override that does nothing special has nothing to add. That leaves 60/60 classes and 69 of
126 public functions documented, the gap being those 55 overrides.
One non-doc change came with it, named in the commit: the client check in
get_configurationwasduplicated in
get_configurations, and now sits only where the client is actually used.