Basket and BasketItem decide their schema independently and nothing checks that they agree:
Basket.isV3() is totalValueGross is not None — and it also picks the endpoint, /v1/baskets vs /v3/baskets.
BasketItem.isV3() is amountPerUnitGross is not None or amountDiscountPerUnitGross is not None.
Measured against the sandbox, all four combinations:
| Basket |
Item |
Result |
| v1 |
v1 |
stored correctly |
| v3 |
v3 |
stored correctly |
| v3 |
v1 |
refused, API.600.410.051 |
| v1 |
v3 |
201, and every item amount stored as 0.0000 |
The dangerous one in full — sent:
{"basketItemReferenceId": "i", "quantity": 1, "vat": 19, "amountPerUnitGross": 907.8,
"amountDiscountPerUnitGross": 90.78}
read back:
{"basketItemReferenceId": "i", "quantity": 1, "vat": 19.0, "amountDiscount": 0.0,
"amountGross": 0.0, "amountVat": 0.0, "amountPerUnit": 0.0, "amountNet": 0.0}
The basket-level total survives (it is a v1 field and was set), so the basket looks right at a glance and every line is worth nothing. A payment method that renders the basket to the customer shows a cart full of zero-priced items against a non-zero invoice.
Reaching this needs no exotic code: Basket(amountTotalGross=…, basketItems=[BasketItem(amountPerUnitGross=…)]). BaseModel.__init__ swallowing unknown kwargs (#17) makes it easier still, since a mistyped v1 field leaves the item looking v3.
Basket's docstring already says "don't mix the schemas within one basket", which is exactly the kind of rule validateBeforeRequest() exists to enforce — and unlike a returnUrl blocklist it encodes nothing that Unzer could relax later, since the two schemas are mutually exclusive by construction.
Suggested: refuse in Basket.serialize() (or validateBeforeRequest) when self.isV3() disagrees with any item's isV3(), naming the item.
Related, from the same measurements: a basket is only readable through the schema it was created with (API.600.410.024 otherwise), and the ids differ visibly — v1 gives s-bsk-72, v3 gives a UUID.
BasketandBasketItemdecide their schema independently and nothing checks that they agree:Basket.isV3()istotalValueGross is not None— and it also picks the endpoint,/v1/basketsvs/v3/baskets.BasketItem.isV3()isamountPerUnitGross is not None or amountDiscountPerUnitGross is not None.Measured against the sandbox, all four combinations:
API.600.410.0510.0000The dangerous one in full — sent:
{"basketItemReferenceId": "i", "quantity": 1, "vat": 19, "amountPerUnitGross": 907.8, "amountDiscountPerUnitGross": 90.78}read back:
{"basketItemReferenceId": "i", "quantity": 1, "vat": 19.0, "amountDiscount": 0.0, "amountGross": 0.0, "amountVat": 0.0, "amountPerUnit": 0.0, "amountNet": 0.0}The basket-level total survives (it is a v1 field and was set), so the basket looks right at a glance and every line is worth nothing. A payment method that renders the basket to the customer shows a cart full of zero-priced items against a non-zero invoice.
Reaching this needs no exotic code:
Basket(amountTotalGross=…, basketItems=[BasketItem(amountPerUnitGross=…)]).BaseModel.__init__swallowing unknown kwargs (#17) makes it easier still, since a mistyped v1 field leaves the item looking v3.Basket's docstring already says "don't mix the schemas within one basket", which is exactly the kind of rulevalidateBeforeRequest()exists to enforce — and unlike areturnUrlblocklist it encodes nothing that Unzer could relax later, since the two schemas are mutually exclusive by construction.Suggested: refuse in
Basket.serialize()(orvalidateBeforeRequest) whenself.isV3()disagrees with any item'sisV3(), naming the item.Related, from the same measurements: a basket is only readable through the schema it was created with (
API.600.410.024otherwise), and the ids differ visibly — v1 givess-bsk-72, v3 gives a UUID.