Order updates accept only the checkout fields (email, terms, addresses, delivery); the terms time is the server time; paid orders cannot be changed; unknown orders answer 404.
Capture accepts only the PayPal order the checkout created for this order, checks status, custom_id, currency and amount before and after capturing, and uses an idempotency key.
PayPal request built from the structured address, amounts in cents, handling for surcharges; recovers after a failed login; 15 s timeout.
Logging never throws, hides credentials at any depth, logs errors as name and message, production level info.
New private order fields: paypalOrderId, paypalCaptureId, paymentCapturedAt.
Verified: 141 unit tests pass; offline stack with the anonymised production copy of 2026-10-15 and a fake PayPal over TLS: order writes 7/7, capture checks 5/5, outage and log-leak checks 4/4, and 20/20 through the shop v0.1.2 routes. The capture parser matches the capture response in PayPal's Orders v2 OpenAPI specification.
Steps 4 to 6 of https://git.librete.ch/libretech/mp/issues/71.
- **Order updates** accept only the checkout fields (email, terms, addresses, delivery); the terms time is the server time; paid orders cannot be changed; unknown orders answer 404.
- **Capture** accepts only the PayPal order the checkout created for this order, checks status, custom_id, currency and amount before and after capturing, and uses an idempotency key.
- **PayPal request** built from the structured address, amounts in cents, handling for surcharges; recovers after a failed login; 15 s timeout.
- **Logging** never throws, hides credentials at any depth, logs errors as name and message, production level info.
- New private order fields: `paypalOrderId`, `paypalCaptureId`, `paymentCapturedAt`.
Verified: 141 unit tests pass; offline stack with the anonymised production copy of 2026-10-15 and a fake PayPal over TLS: order writes 7/7, capture checks 5/5, outage and log-leak checks 4/4, and 20/20 through the shop v0.1.2 routes. The capture parser matches the capture response in PayPal's Orders v2 OpenAPI specification.
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
findOne and findOneByUuid mapped order.cart before checking that the order
exists, and the service's findOneByUuid did the same, so a missing order
threw a TypeError: GET /orders/:uuid/cart, add-product, remove-product,
generate-invoice and generate-delivery-note answered 500.
The service's findOneByUuid now returns null for a missing order, and each
caller handles it: the controllers answer 404, sendInvoice and
sendDeliveryNote throw a NotFoundError.
Refs libretech/mp#71
PUT /orders/:uuid/cart passed the body through sanitize.contentAPI.input,
which keeps every public writable attribute, to the order update. The shop
forwards the browser's body unchanged, so a browser could set
paymentAuthorised on its own order, and the order lifecycle then generated
and mailed the invoice.
The handler now parses the body with parseCustomerUpdate and writes only
the fields it returns, plus the customer it finds or creates from the
invoice address. A body with any other field is answered 400 with the
error list, and the warning it logs names the fields, not their values.
A delivery method that does not exist is answered 400.
Once the payment is authorised, the order is locked for the customer:
the update, add-product and remove-product answer 409.
Refs libretech/mp#71
paypalOrderId is the PayPal order the checkout created for the order,
paypalCaptureId and paymentCapturedAt record the verified capture. All
three are private, so the content API never returns them. Strapi adds the
columns when it syncs the schema at startup.
types/generated/contentTypes.d.ts is the output of strapi
ts:generate-types, run in the release image without network.
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
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
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
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
The capture took the PayPal order id from the URL and captured it with
Prefer: return=minimal, then marked the order paid on status COMPLETED.
The checkout never stored its PayPal order (the paypalOrderId written at
capture was no schema attribute and was dropped), and neither the order
nor the amount was compared. A buyer could pay a cheap order and call
the capture of an expensive one with that PayPal order id: the expensive
order was marked paid and invoiced. checkPaymentStatus also turned every
error into the string "/result/error", which failed the capture without
a log.
checkout now answers 409 for a paid order and 400 with the missing
fields for an order without email, invoice address or delivery method.
It stores the id of the PayPal order it created as paypalOrderId and
answers { id, links } as before. A PayPal error is answered 502.
capturePayment is an adapter for src/checkout/capture-payment.ts. It
reads the PayPal order with GET, captures with
Prefer: return=representation and a PayPal-Request-Id, and writes
paymentAuthorised, paymentStatus "captured", paypalCaptureId and
paymentCapturedAt with where { id, paypalOrderId }.
updateByUuid, addProduct and removeProduct set paypalOrderId to null in
the write that changes the order, so a changed cart or address needs a
new checkout, and an earlier approval cannot be captured for it.
PayPalApi gains getOrder and captureOrder, which return PayPal's parsed
JSON and let errors propagate. createSessionOrThrow logs an error by
name, message and status code instead of the error object, which holds
the request with its Authorization header; its request building is
unchanged.
Refs libretech/mp#71
formatLogLine wrote the fields of a log call with JSON.stringify. A
circular reference threw, so Strapi's error middleware lost the error
it was logging: the axios error of a failed PayPal token request holds
its request object, which refers to itself. An Error was written as {},
and the config.headers of an axios error carried "Authorization: Basic"
with the base64 of the PayPal client id and secret into the line.
The fields are now copied before they are written:
- a field named like a credential (authorization, cookie, password,
secret, token, api key) is written as "[redacted]", at any depth;
- an Error is written as { name, message }, with the first line of the
message as describeError has it, and its stack only with the stack
option;
- an object that contains itself is written once, the reference to it
as "[Circular]";
- an instance of a class is written as its toJSON(), or as its class
name without one: a request object keeps its headers in _header;
- a bigint is written as its digits, an unreadable field as
"[unreadable]".
The three todo tests of the formatter pass and are regular tests now.
Refs libretech/mp#71
Refs libretech/mp#67
Strapi 4.25 builds its logger from @strapi/logger's default
configuration with config/logger.ts assigned over it. That default
level is "silly", and config/logger.ts set none, so production wrote
every level: the logger of the v0.1.1 image, built as Strapi builds it
with NODE_ENV=production, reports level silly. Debug and silly lines
carry order data, such as the PayPal order request with the buyer's
name and address.
The level is now info for NODE_ENV=production, which mp's compose.yml
sets for the cms, and debug in every other environment. Strapi's
request log lines are written at http, below info, so production no
longer writes them. The stack of an error field is written at debug
only.
Refs libretech/mp#71
Refs libretech/mp#67
buildPayPalOrderRequest split the text delivery address at fixed
offsets and called toFixed on the delivery price, and the items that
were computed but never sent read the product of every cart line. A
missing or two-line delivery address, a delivery method whose price is
null, such as a pickup, and a cart line whose product was deleted each
threw a TypeError, which the checkout answered 502 (D1 to D4).
The request is now built in whole cents, with the shipping address
from the first one entered of the structured delivery address, the
structured invoice address and the text delivery address:
- fullName is givenName and familyName, addressLine1 streetAddress,
adminArea2 addressLevel2, countryCode the country upper-cased or DE;
a structured address that is entered but incomplete is rejected
instead of replaced by another address;
- a text address needs three lines: the name, the street, and
"<postal code> <city>" split at the first whitespace; lines between
the name and the street become addressLine2;
- a delivery price that is null counts as 0;
- itemTotal is the net of the subtotal, round(subtotal cents * 100 /
119), taxTotal the order's VAT. The breakdown must add up to the
total to the cent, as PayPal requires, so totals computed before a
price changed are rejected here instead of by PayPal.
The items are gone. With items, PayPal requires item_total and
tax_total to be the sums of the amounts rounded per unit, which miss
the net and the VAT of the subtotal that the invoice states: 3 x 12.50
gives 31.50 and 6.00, the subtotal 31.51 and 5.99.
An empty cart, a missing total or VAT, totals that do not add up and a
missing or unusable address throw CheckoutNotReadyError, which the
checkout answers 400 "Order is not ready for checkout" with the reason.
A deleted product throws ProductUnavailableError, answered 409 "A
product in the cart is no longer available". Both are thrown before
PayPal is asked and are logged as warnings; other errors stay 502.
Tests: D1 to D4 are regular tests. The test of an order without a total
expects CheckoutNotReadyError instead of a plain Error, and the item
test is replaced by one that shows why no items are sent. The order of
paypal-verify.test.ts gains the cart line the builder now requires, and
its amount test builds the request for every total up to 3000.00
instead of assuming toFixed(2).
Refs libretech/mp#71
Refs libretech/mp#67
The order service adds the price of the order's payment method to the
total, but the PayPal breakdown left it out, so an order with a
surcharge was sent an amount its breakdown did not add up to, which
PayPal rejects (D5). Since the breakdown is now checked against the
total, such an order was answered 400.
A payment price above 0 is now sent as handling, in whole cents, and
item total, tax, shipping and handling add up to the amount to the
cent. A payment method that costs nothing sends no handling.
Every payment method and the pickup cost 0.00 today, and the shop
cannot set a payment method (parseCustomerUpdate rejects the field),
so this is robustness, not a live defect.
The D5 test is a regular test, and a test checks the sum with a
surcharge, also with a pickup without a price, for every whole-cent
subtotal up to 300.00.
Refs libretech/mp#71
Refs libretech/mp#67
The PayPal SDK client logged every request body to the console, and
with it the buyer's name and address in the order request, as well as
the response headers. createSessionOrThrow logged the whole request at
silly, which production wrote, and PayPal's whole answer at info. The
webhook logged its whole body, and the controller's webhook error
handler the error object as JSON.
The SDK now logs method, URL and status of each request, without
bodies or headers. createSessionOrThrow logs the shop's order id, the
PayPal order's id and its status, and an error by describeError with
the order id. The webhook logs the event type and the id and status of
its resource; its error handler describes the error.
Refs libretech/mp#71
Refs libretech/mp#67
The PayPal SDK keeps the promise of its OAuth token for the lifetime of
its Client, also when the token request failed. After one failed token
request, as during the DNS outage, every later call failed with that
same error and without a request until the process restarted: once DNS
was back, the investigators' harness (scenario S7) saw 0 DNS lookups.
The client had no timeout either: the SDK's default of 0 means none.
PayPalApi now reaches PayPal through createPayPalClient
(src/checkout/paypal-client.ts). It creates the SDK's Client and
OrdersController through an injected factory on the first call, and
anew after an error of the token request, an error without an answer
from PayPal (network, DNS, timeout), 401 or 5xx. PayPal's other
answers, such as 422, keep the client. A call that fails on a client
that was replaced meanwhile leaves its successor alone. A replacement
is logged as a warning with the error described.
The client times out after 15 s. paypalClientConfiguration builds its
configuration, with the logging of bodies and headers off.
The tests use a fake factory that, like the SDK, keeps a failed token
request per client, and count constructions and token requests. With
the real SDK in the harness, S7 looks DNS up again and every Client is
created with a timeout of 15000.
Refs libretech/mp#71
Refs libretech/mp#67
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Steps 4 to 6 of libretech/mp#71.
paypalOrderId,paypalCaptureId,paymentCapturedAt.Verified: 141 unit tests pass; offline stack with the anonymised production copy of 2026-10-15 and a fake PayPal over TLS: order writes 7/7, capture checks 5/5, outage and log-leak checks 4/4, and 20/20 through the shop v0.1.2 routes. The capture parser matches the capture response in PayPal's Orders v2 OpenAPI specification.
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#71capturePayment(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#71The capture took the PayPal order id from the URL and captured it with Prefer: return=minimal, then marked the order paid on status COMPLETED. The checkout never stored its PayPal order (the paypalOrderId written at capture was no schema attribute and was dropped), and neither the order nor the amount was compared. A buyer could pay a cheap order and call the capture of an expensive one with that PayPal order id: the expensive order was marked paid and invoiced. checkPaymentStatus also turned every error into the string "/result/error", which failed the capture without a log. checkout now answers 409 for a paid order and 400 with the missing fields for an order without email, invoice address or delivery method. It stores the id of the PayPal order it created as paypalOrderId and answers { id, links } as before. A PayPal error is answered 502. capturePayment is an adapter for src/checkout/capture-payment.ts. It reads the PayPal order with GET, captures with Prefer: return=representation and a PayPal-Request-Id, and writes paymentAuthorised, paymentStatus "captured", paypalCaptureId and paymentCapturedAt with where { id, paypalOrderId }. updateByUuid, addProduct and removeProduct set paypalOrderId to null in the write that changes the order, so a changed cart or address needs a new checkout, and an earlier approval cannot be captured for it. PayPalApi gains getOrder and captureOrder, which return PayPal's parsed JSON and let errors propagate. createSessionOrThrow logs an error by name, message and status code instead of the error object, which holds the request with its Authorization header; its request building is unchanged. Refs libretech/mp#71formatLogLine wrote the fields of a log call with JSON.stringify. A circular reference threw, so Strapi's error middleware lost the error it was logging: the axios error of a failed PayPal token request holds its request object, which refers to itself. An Error was written as {}, and the config.headers of an axios error carried "Authorization: Basic" with the base64 of the PayPal client id and secret into the line. The fields are now copied before they are written: - a field named like a credential (authorization, cookie, password, secret, token, api key) is written as "[redacted]", at any depth; - an Error is written as { name, message }, with the first line of the message as describeError has it, and its stack only with the stack option; - an object that contains itself is written once, the reference to it as "[Circular]"; - an instance of a class is written as its toJSON(), or as its class name without one: a request object keeps its headers in _header; - a bigint is written as its digits, an unreadable field as "[unreadable]". The three todo tests of the formatter pass and are regular tests now. Refs libretech/mp#71 Refs libretech/mp#67