Files
cms/src/services/PayPalApi.ts
T
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

96 lines
3.7 KiB
TypeScript

import { Client, Environment, LogLevel, OrdersController, OrderRequest } from "@paypal/paypal-server-sdk";
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;
private readonly ordersController: OrdersController;
constructor(clientId: string, clientSecret: string, environment: Environment) {
this.client = new Client({
clientCredentialsAuthCredentials: {
oAuthClientId: clientId,
oAuthClientSecret: clientSecret
},
timeout: 0,
environment,
logging: {
logLevel: LogLevel.Info,
logRequest: { logBody: true },
logResponse: { logHeaders: true }
}
});
this.ordersController = new OrdersController(this.client);
}
/** 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);
try {
const collect = {
body: buildPayPalOrderRequest(returnUrl, order) as OrderRequest,
prefer: "return=minimal"
};
strapi.log.silly("app:d:paypal-api: Creating PayPal order " + JSON.stringify({ collect }));
const { body, ...httpResponse } = await this.ordersController.ordersCreate(collect);
const sessionData = JSON.parse(body as string);
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) {
// 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;
}
}
/** 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);
}
/**
* 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) {
// Implement PayPal webhook validation and processing
strapi.log.verbose("app:i:paypal-api: " + JSON.stringify({ req }));
// Note: PayPal webhook handling requires specific implementation
// This is a placeholder and should be replaced with actual PayPal webhook validation
return { message: "Webhook received" };
}
getClientKey() {
// PayPal doesn't use a client key like Adyen, so we'll return the client ID
return paypalClientId;
}
}
export const paypalApi = new PayPalApi(paypalClientId, paypalClientSecret, paypalEnvironment);
export type PayPalApiOptions = {
clientId: string;
clientSecret: string;
environment: Environment;
};