fix(checkout): take a repeated capture as a successful payment

The CMS answers a capture of an order already paid with this PayPal order
with { success: true, alreadyCaptured: true } instead of the order. Step 3
took that answer for a capture without authorisation and showed a payment
error. It now reloads the paid order (keeping the shown one if the reload
fails) and continues as after a first capture: confirmation, the
checkout-payment-completed event, and the redirect to the order's result
page. capturePayment is typed as Order | AlreadyCaptured, and the pure
isAlreadyCaptured (utils/captureResponse.ts) tells them apart.

Refs libretech/mp#71
This commit is contained in:
2026-10-09 02:32:38 +02:00
parent 8931afc670
commit 6f434c21ba
4 changed files with 45 additions and 5 deletions
+4 -2
View File
@@ -9,6 +9,7 @@ import type {
PaymentMethod, PaymentMethod,
ApiResponse ApiResponse
} from "~/types"; } from "~/types";
import type { AlreadyCaptured } from "~/utils/captureResponse";
/** /**
* Shop API composable. * Shop API composable.
@@ -116,8 +117,9 @@ export function useShopApi() {
}); });
}, },
async capturePayment(uuid: string, paypalOrderId: string): Promise<Order> { /** The paid order, or { success: true, alreadyCaptured: true } when an earlier capture of this PayPal order went through. */
return await $fetch<Order>(`/api/orders/${uuid}/capture/${paypalOrderId}`, { async capturePayment(uuid: string, paypalOrderId: string): Promise<Order | AlreadyCaptured> {
return await $fetch<Order | AlreadyCaptured>(`/api/orders/${uuid}/capture/${paypalOrderId}`, {
method: "POST" method: "POST"
}); });
}, },
+8 -3
View File
@@ -191,6 +191,7 @@
import { numberFormatter } from "~/utils/numberFormatter"; import { numberFormatter } from "~/utils/numberFormatter";
import { trackEvent } from "~/utils/trackEvent"; import { trackEvent } from "~/utils/trackEvent";
import { checkoutErrorMessageFor } from "~/utils/checkoutError"; import { checkoutErrorMessageFor } from "~/utils/checkoutError";
import { isAlreadyCaptured } from "~/utils/captureResponse";
import type { Order } from "~/types"; import type { Order } from "~/types";
const config = useRuntimeConfig(); const config = useRuntimeConfig();
@@ -312,10 +313,14 @@ async function initializePayPalButtons() {
onApprove: async (data) => { onApprove: async (data) => {
try { try {
// Capture payment server-side (CMS captures via PayPal server SDK and updates order) // Capture payment server-side (CMS captures via PayPal server SDK and updates order)
const capturedOrder = await shopApi.capturePayment(orderData.value.uuid, data.orderID!); const captured = await shopApi.capturePayment(orderData.value.uuid, data.orderID!);
if (capturedOrder.paymentAuthorised) { if (isAlreadyCaptured(captured)) {
orderData.value = capturedOrder; // An earlier capture of this PayPal order went through: reload the paid order, keeping the shown one if that fails.
orderData.value = await shopApi.getOrder(orderData.value.uuid).catch(() => orderData.value);
await handleSuccessfulPayment();
} else if (captured.paymentAuthorised) {
orderData.value = captured;
await handleSuccessfulPayment(); await handleSuccessfulPayment();
} else { } else {
console.error("Payment capture did not result in authorisation"); console.error("Payment capture did not result in authorisation");
+21
View File
@@ -0,0 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { isAlreadyCaptured } from "../../utils/captureResponse.ts";
test("recognises the CMS's answer to a repeated capture", () => {
assert.equal(isAlreadyCaptured({ success: true, alreadyCaptured: true }), true);
});
test("takes the paid order of a first capture for no repeated capture", () => {
const order = { uuid: "11111111-2222-4333-8444-555555555555", total: 22.85, paymentAuthorised: true, email: "erika@example.org" };
assert.equal(isAlreadyCaptured(order), false);
assert.equal(isAlreadyCaptured({ ...order, alreadyCaptured: false }), false);
});
test("takes nothing but true for both fields as a repeated capture", () => {
assert.equal(isAlreadyCaptured({ alreadyCaptured: true }), false);
assert.equal(isAlreadyCaptured({ success: true, alreadyCaptured: "true" }), false);
assert.equal(isAlreadyCaptured({ success: false, alreadyCaptured: true }), false);
for (const response of [undefined, null, "alreadyCaptured", 1, [true]]) assert.equal(isAlreadyCaptured(response), false);
});
+12
View File
@@ -0,0 +1,12 @@
// The answer of POST /api/orders/:uuid/capture/:paypalOrderId: the paid order, or, when the order is already paid with this
// PayPal order (a repeated capture), { success: true, alreadyCaptured: true } (libreshop/cms src/api/order/controllers/order.ts).
// Pure: no Nuxt or Vue imports, tested in tests/unit/captureResponse.test.ts.
/** The CMS's answer to a repeated capture: the payment went through before, and the order is paid. */
export type AlreadyCaptured = { success: true; alreadyCaptured: true };
export const isAlreadyCaptured = (response: unknown): response is AlreadyCaptured =>
typeof response === "object" &&
response !== null &&
(response as Record<string, unknown>).success === true &&
(response as Record<string, unknown>).alreadyCaptured === true;