diff --git a/composables/useShopApi.ts b/composables/useShopApi.ts index 1baa4fe..c0f8877 100644 --- a/composables/useShopApi.ts +++ b/composables/useShopApi.ts @@ -9,6 +9,7 @@ import type { PaymentMethod, ApiResponse } from "~/types"; +import type { AlreadyCaptured } from "~/utils/captureResponse"; /** * Shop API composable. @@ -116,8 +117,9 @@ export function useShopApi() { }); }, - async capturePayment(uuid: string, paypalOrderId: string): Promise { - return await $fetch(`/api/orders/${uuid}/capture/${paypalOrderId}`, { + /** The paid order, or { success: true, alreadyCaptured: true } when an earlier capture of this PayPal order went through. */ + async capturePayment(uuid: string, paypalOrderId: string): Promise { + return await $fetch(`/api/orders/${uuid}/capture/${paypalOrderId}`, { method: "POST" }); }, diff --git a/pages/checkout/3.vue b/pages/checkout/3.vue index 16064cc..5bd5ecd 100644 --- a/pages/checkout/3.vue +++ b/pages/checkout/3.vue @@ -191,6 +191,7 @@ import { numberFormatter } from "~/utils/numberFormatter"; import { trackEvent } from "~/utils/trackEvent"; import { checkoutErrorMessageFor } from "~/utils/checkoutError"; +import { isAlreadyCaptured } from "~/utils/captureResponse"; import type { Order } from "~/types"; const config = useRuntimeConfig(); @@ -312,10 +313,14 @@ async function initializePayPalButtons() { onApprove: async (data) => { try { // 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) { - orderData.value = capturedOrder; + if (isAlreadyCaptured(captured)) { + // 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(); } else { console.error("Payment capture did not result in authorisation"); diff --git a/tests/unit/captureResponse.test.ts b/tests/unit/captureResponse.test.ts new file mode 100644 index 0000000..1470882 --- /dev/null +++ b/tests/unit/captureResponse.test.ts @@ -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); +}); diff --git a/utils/captureResponse.ts b/utils/captureResponse.ts new file mode 100644 index 0000000..9dca3d8 --- /dev/null +++ b/utils/captureResponse.ts @@ -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).success === true && + (response as Record).alreadyCaptured === true;