// 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([ ["email", "E-Mail-Adresse"], ["invoiceAddress", "Rechnungsadresse"], ["delivery", "Versandart"] ]); type CheckoutErrorFields = { message: string | undefined; errors: string[]; missing: string[] }; const isObject = (value: unknown): value is Record => 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 = 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); };