Commit Graph
12 Commits
Author SHA1 Message Date
libretech 66e5800418 fix(order): capture only the PayPal order the checkout stored, verified against the order
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
2026-10-09 01:12:31 +02:00
libretech c7a422621a feat(order): name the fields an order still lacks for a checkout
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
2026-10-09 01:12:21 +02:00
libretech d56ae43597 feat(checkout): capture a payment through injected dependencies
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
2026-10-09 01:12:20 +02:00
libretech f599b2c84b feat(logging): describe an error by its name, message and status code
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
2026-10-09 01:12:20 +02:00
libretech 4d77998b51 feat(checkout): verify the approved PayPal order and its capture against the order
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
2026-10-09 01:12:20 +02:00
libretech 2bbffeed2d feat(order): add private attributes for the PayPal order and its capture
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
2026-10-08 23:22:36 +02:00
libretech 20afcd4416 fix(order): write only customer fields on an order update
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
2026-10-08 22:58:21 +02:00
libretech 2992e4805e fix(order): answer 404 instead of 500 for a missing order
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
2026-10-08 22:55:13 +02:00
libretech 88f4ee75d7 feat(order): lock an order for the customer once its payment is authorised
Refs libretech/mp#71
2026-10-08 22:54:59 +02:00
libretech b9670b7229 feat(order): parse the customer's order update against the checkout fields
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
2026-10-08 22:54:59 +02:00
libretech 9b0c6af8b7 Test the checkout code; publish images only for releases (#5)
build / test (push) Successful in 7s
build / build (push) Skipped
- 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>
2026-10-08 22:36:47 +02:00
libretech 901a3b385f feat(product): price standalone products from the cover alone
build / build (push) Successful in 8m52s
Recovered work in progress from 2026-05. A product-cover can be marked
standalone, and a standalone product (Journaling Booklet) prices as the
cover price without the pattern/pages/ruling lookups, which do not exist
for it. The remaining component lookups are now guarded so a missing
relation yields null instead of calling findOne with undefined.
2026-09-23 22:10:06 +02:00