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
This commit is contained in:
3 files changed
+89
-59
No files matched your search
+19
-16
@@ -2,6 +2,7 @@ import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@
|
||||
import { paypalClientId, paypalClientSecret, paypalEnvironment } from "../../config/constants";
|
||||
import { Order } from "../../types";
|
||||
import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../checkout/paypal-order";
|
||||
import { describeError } from "../logging/error";
|
||||
|
||||
class PayPalApi {
|
||||
private readonly client: Client;
|
||||
@@ -24,7 +25,8 @@ class PayPalApi {
|
||||
this.ordersController = new OrdersController(this.client);
|
||||
}
|
||||
|
||||
async createSessionOrThrow(returnUrl: string, order: Order) {
|
||||
/** Creates the PayPal order for the order and returns PayPal's id for it, which the capture accepts, and its links. */
|
||||
async createSessionOrThrow(returnUrl: string, order: Order): Promise<{ id: string; links: unknown }> {
|
||||
// Outside the try block, so these throws are not logged here. The items are computed but not sent.
|
||||
buildPayPalOrderItems(order);
|
||||
|
||||
@@ -41,31 +43,32 @@ class PayPalApi {
|
||||
|
||||
strapi.log.info("app:i:paypal-api: " + JSON.stringify({ session: sessionData }));
|
||||
|
||||
if (typeof sessionData?.id !== "string" || sessionData.id === "") throw new Error("PayPal answered without an order id");
|
||||
return {
|
||||
id: sessionData.id,
|
||||
links: sessionData.links
|
||||
};
|
||||
} catch (error) {
|
||||
strapi.log.error("app:e:paypal-api: " + JSON.stringify({ error }));
|
||||
// Never the error object: an SDK or axios error carries the request with its Authorization header.
|
||||
strapi.log.error("app:e:paypal-api: Creating the PayPal order failed", { error: describeError(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkPaymentStatus(redirect: { orderID: string }) {
|
||||
try {
|
||||
const collect = {
|
||||
id: redirect.orderID,
|
||||
prefer: "return=minimal"
|
||||
};
|
||||
/** PayPal's order as parsed JSON in PayPal's snake_case (GET /v2/checkout/orders/:id). Throws PayPal's and network errors. */
|
||||
async getOrder(id: string): Promise<unknown> {
|
||||
const { body } = await this.ordersController.ordersGet({ id });
|
||||
return JSON.parse(body as string);
|
||||
}
|
||||
|
||||
const { body } = await this.ordersController.ordersCapture(collect);
|
||||
const captureResult = JSON.parse(body as string);
|
||||
strapi.log.info("app:i:paypal-api: " + JSON.stringify({ captureResult }));
|
||||
|
||||
return captureResult;
|
||||
} catch (error) {
|
||||
return "/result/error";
|
||||
}
|
||||
/**
|
||||
* Captures the PayPal order and returns the whole order after the capture as parsed JSON in PayPal's snake_case
|
||||
* (POST /v2/checkout/orders/:id/capture, Prefer: return=representation). PayPal answers a repeated requestId with the
|
||||
* response of the first capture instead of capturing again. Throws PayPal's and network errors.
|
||||
*/
|
||||
async captureOrder(id: string, requestId: string): Promise<unknown> {
|
||||
const { body } = await this.ordersController.ordersCapture({ id, paypalRequestId: requestId, prefer: "return=representation" });
|
||||
return JSON.parse(body as string);
|
||||
}
|
||||
|
||||
async handleWebhook(req: any) {
|
||||
|
||||
Reference in new issue
Block a user