v0.1.2: checkout fixes for CMS v0.2.0 (#18)
Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #18.
This commit is contained in:
1 parent
60b8f1ab58
commit
fccff147ba
29 files changed
+1108
-71
No files matched your search
@@ -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;
|
||||
@@ -0,0 +1,98 @@
|
||||
// The message a customer sees when a checkout request fails: German, addressed with "du" like the rest of the shop, chosen from the
|
||||
// constants below, so it never shows the server's text or personal data. The shop's order routes answer a CMS error with its status
|
||||
// and { …, data: { message, errors?, missing? } } (server/utils/cmsError.ts); a raw CMS (Strapi) error body,
|
||||
// { data: null, error: { status, name, message, details } }, is read as well.
|
||||
// Pure: no Nuxt or Vue imports, tested in tests/unit/checkoutError.test.ts.
|
||||
|
||||
// The CMS's messages (libreshop/cms src/api/order/controllers/order.ts and src/checkout/), matched but never shown.
|
||||
const INVALID_ORDER_UPDATE = "Invalid order update";
|
||||
const ORDER_LOCKED = "Order can no longer be changed";
|
||||
const ALREADY_PAID = "Order is already paid";
|
||||
const CHECKOUT_NOT_READY = "Order is not ready for checkout";
|
||||
const PRODUCT_UNAVAILABLE = "A product in the cart is no longer available";
|
||||
const PAYMENT_NOT_THIS_ORDER = "Payment does not belong to this order";
|
||||
const PAYMENT_NOT_MATCHING = "Payment does not match this order";
|
||||
const ORDER_CHANGED = "Order changed during the payment";
|
||||
// PayPal answered the capture, or captured the payment, but the CMS could not confirm or record it: the money may be taken.
|
||||
const PAYMENT_NOT_CONFIRMED = "Payment could not be confirmed";
|
||||
const PAYMENT_NOT_RECORDED = "Payment could not be recorded";
|
||||
|
||||
export const CHECKOUT_ERROR_MESSAGES = {
|
||||
invalidEmail: "Bitte gib eine gültige E-Mail-Adresse ein.",
|
||||
invalidInput: "Deine Angaben konnten nicht gespeichert werden. Bitte prüfe sie und versuche es noch einmal.",
|
||||
alreadyPaid: "Diese Bestellung ist bereits bezahlt und kann nicht mehr geändert werden.",
|
||||
productUnavailable: "Ein Produkt in deinem Warenkorb ist nicht mehr verfügbar. Bitte prüfe deinen Warenkorb.",
|
||||
notReady: "Deine Bestellung ist noch nicht vollständig. Bitte prüfe deine Angaben in den vorherigen Schritten.",
|
||||
restartPayment: "Die Zahlung passt nicht zu deiner Bestellung, oder deine Bestellung wurde während der Zahlung geändert. Bitte starte die Zahlung neu.",
|
||||
paypalUnavailable: "PayPal ist gerade nicht erreichbar. Bitte versuche es in ein paar Minuten noch einmal.",
|
||||
paymentUnconfirmed: "Deine Zahlung konnte nicht bestätigt werden. Bitte bezahle nicht noch einmal, sondern melde dich bei uns.",
|
||||
generic: "Es ist ein Fehler aufgetreten. Bitte versuche es erneut."
|
||||
} as const;
|
||||
|
||||
// The fields a checkout can miss (the CMS's missingCheckoutFields), as the customer knows them from the checkout's steps.
|
||||
const MISSING_FIELD_LABELS = new Map<string, string>([
|
||||
["email", "E-Mail-Adresse"],
|
||||
["invoiceAddress", "Rechnungsadresse"],
|
||||
["delivery", "Versandart"]
|
||||
]);
|
||||
|
||||
type CheckoutErrorFields = { message: string | undefined; errors: string[]; missing: string[] };
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
||||
|
||||
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
|
||||
|
||||
const readCheckoutError = (body: unknown): CheckoutErrorFields => {
|
||||
if (!isObject(body)) return { message: undefined, errors: [], missing: [] };
|
||||
// The shop's answer: { error: true, url, statusCode, statusMessage, message, data: { message, errors?, missing? } }.
|
||||
if (isObject(body.data)) {
|
||||
return { message: text(body.data.message) ?? text(body.message), errors: strings(body.data.errors), missing: strings(body.data.missing) };
|
||||
}
|
||||
// The CMS's answer: { data: null, error: { status, name, message, details: { errors?, missing? } } }.
|
||||
if (isObject(body.error)) {
|
||||
const details: Record<string, unknown> = isObject(body.error.details) ? body.error.details : {};
|
||||
return { message: text(body.error.message), errors: strings(details.errors), missing: strings(details.missing) };
|
||||
}
|
||||
return { message: text(body.message), errors: [], missing: [] };
|
||||
};
|
||||
|
||||
// The CMS names a rejected field first, as in "data.email: must be an email address of at most 254 characters".
|
||||
const isEmailError = (error: string): boolean => error === "data.email" || error.startsWith("data.email:");
|
||||
|
||||
const notReadyMessage = (missing: string[]): string => {
|
||||
const labels = [...new Set(missing.map((field) => MISSING_FIELD_LABELS.get(field)).filter((label) => label !== undefined))];
|
||||
return labels.length > 0 ? `Deine Bestellung ist noch nicht vollständig. Bitte ergänze: ${labels.join(", ")}.` : CHECKOUT_ERROR_MESSAGES.notReady;
|
||||
};
|
||||
|
||||
/** The customer's message for a failed checkout request, from the status and the parsed body of its answer. */
|
||||
export const checkoutErrorMessage = (status: number | undefined, body: unknown): string => {
|
||||
const { message, errors, missing } = readCheckoutError(body);
|
||||
|
||||
if (status === 400) {
|
||||
if (message === INVALID_ORDER_UPDATE || errors.length > 0) {
|
||||
return errors.some(isEmailError) ? CHECKOUT_ERROR_MESSAGES.invalidEmail : CHECKOUT_ERROR_MESSAGES.invalidInput;
|
||||
}
|
||||
if (message === CHECKOUT_NOT_READY || missing.length > 0) return notReadyMessage(missing);
|
||||
return CHECKOUT_ERROR_MESSAGES.generic;
|
||||
}
|
||||
if (status === 409) {
|
||||
if (message === ORDER_LOCKED || message === ALREADY_PAID) return CHECKOUT_ERROR_MESSAGES.alreadyPaid;
|
||||
if (message === PRODUCT_UNAVAILABLE) return CHECKOUT_ERROR_MESSAGES.productUnavailable;
|
||||
if (message === PAYMENT_NOT_THIS_ORDER || message === PAYMENT_NOT_MATCHING || message === ORDER_CHANGED) {
|
||||
return CHECKOUT_ERROR_MESSAGES.restartPayment;
|
||||
}
|
||||
return CHECKOUT_ERROR_MESSAGES.generic;
|
||||
}
|
||||
if (message === PAYMENT_NOT_CONFIRMED || message === PAYMENT_NOT_RECORDED) return CHECKOUT_ERROR_MESSAGES.paymentUnconfirmed;
|
||||
if (status === 502) return CHECKOUT_ERROR_MESSAGES.paypalUnavailable;
|
||||
return CHECKOUT_ERROR_MESSAGES.generic;
|
||||
};
|
||||
|
||||
/** The customer's message for an error thrown by $fetch: ofetch's FetchError carries the answer's status and parsed body. */
|
||||
export const checkoutErrorMessageFor = (error: unknown): string => {
|
||||
if (!isObject(error)) return CHECKOUT_ERROR_MESSAGES.generic;
|
||||
const status = typeof error.status === "number" ? error.status : typeof error.statusCode === "number" ? error.statusCode : undefined;
|
||||
return checkoutErrorMessage(status, error.data);
|
||||
};
|
||||
Reference in new issue
Block a user