missingCheckoutFields returns which of email, invoiceAddress and
delivery an order lacks, so the checkout can answer 400 before it
creates a PayPal order.
Refs libretech/mp#71
capturePayment(deps, { uuid, paypalOrderId, now }) is the capture flow
without Strapi or the PayPal SDK, so it is tested with fakes:
- a missing order is answered 404;
- a paid order is answered alreadyCaptured for the PayPal order it was
paid with, else 409, without a call or a write;
- a PayPal order the checkout did not store on the order is answered
409 before PayPal is asked anything;
- a PayPal order that does not verify as approved for the order is
answered 409 without a capture;
- the capture is sent with the PayPal-Request-Id
capture-<uuid>-<paypalOrderId>, so a retry captures once;
- a capture that does not verify leaves the order unpaid, is logged as
an error with the order id and the reason, and is answered 502: PayPal
may have taken the money, so it needs a manual refund check;
- the payment is written only while the order still carries the PayPal
order id, so an order whose cart or address changed during the
capture stays unpaid (409, logged with the capture id);
- PayPal and network errors are answered 502 and logged by name,
message and status code only.
Refs libretech/mp#71
describeError keeps an error's name, the first line of its message, cut
to 200 characters, and its HTTP status code. An error of the PayPal SDK
or of axios carries its request, whose Authorization header holds the
PayPal client credentials or an access token, so the error object itself
must not reach the log. The SDK's response validation error quotes the
response values on the lines after the first.
Refs libretech/mp#71
verifyApprovedOrder accepts PayPal's order only if it is APPROVED, its
first purchase unit's custom_id is the order's uuid, and its amount is the
order total in EUR. verifyCompletedCapture accepts the order after the
capture only if it and its first capture are COMPLETED for the order
total in EUR, and returns the capture id. Both read PayPal's Orders v2
JSON in snake_case, as PayPalApi parses the response body.
Money is compared in integer cents: PayPal's "12.34" is parsed strictly,
the order total is Math.round(total * 100). For every total up to
3000.00 the amount the checkout sends (total.toFixed(2)) parses to the
cents the capture compares.
A rejection reason repeats only PayPal's statuses, currency codes and
amounts, never the payer's or the shipping data of the response.
Refs libretech/mp#71
parseCustomerUpdate accepts { data } with a subset of the seven fields the
shop's checkout sends: email, acceptedTermsAndConditionsAt, invoiceAddress,
deliveryAddress, invoiceAddressStructured, deliveryAddressStructured and
delivery. Any other key, at the top level, in data or in a structured
address, rejects the whole body, and the errors name the fields without
repeating their values.
The terms are recorded at the server's time whenever the client sends a
true value: the invoice is dated by it.
Refs libretech/mp#71
- src/checkout/ (PayPal order request, pricing, VAT) and src/logging/format.ts
hold the checkout's pure code, moved out of PayPalApi, the product service
and config without a behaviour change.
- npm test runs node:test on the TypeScript sources (type stripping), with no
dependencies: 21 tests, 13 pin today's output, 8 describe the correct
behaviour for the known defects of libretech/mp#67
and are marked todo.
- CI: the tests run in the image's base (node:22.14.0-alpine) for every pull
request, push to main and release tag. The image is built for pull requests
and built and published only for v* tags; main no longer publishes :main
and :sha-* images.
Refs libretech/mp#71
Co-authored-by: Michael Czechowski <mail@librete.ch>