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:
@@ -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"
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user