feat: improve error handling for withdraw modal (#5054)

* feat: improve error handling for withdraw modal

* fix: add headers to error info

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-01-06 00:46:34 +00:00
committed by GitHub
parent 0cf28c6392
commit a6cd4dfc0f
4 changed files with 243 additions and 50 deletions

View File

@@ -128,6 +128,7 @@ import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
import {
createWithdrawContext,
type PaymentProvider,
type PayoutMethod,
provideWithdrawContext,
TAX_THRESHOLD_ACTUAL,
@@ -332,51 +333,76 @@ function continueWithLimit() {
setStage(nextStep.value)
}
// TODO: God we need better errors from the backend (e.g error ids), this shit is insane
function getWithdrawalError(error: any): { title: string; text: string } {
const description = error?.data?.description?.toLowerCase() || ''
function buildSupportData(error: any): Record<string, unknown> {
// Extract response headers, excluding sensitive ones
const responseHeaders: Record<string, string> = {}
if (error?.response?.headers) {
const headers = error.response.headers
const entries =
typeof headers.entries === 'function' ? [...headers.entries()] : Object.entries(headers)
for (const [key, value] of entries) {
const lowerKey = key.toLowerCase()
// Exclude sensitive headers
if (!['authorization', 'cookie', 'set-cookie'].includes(lowerKey)) {
responseHeaders[key] = value
}
}
}
return {
timestamp: new Date().toISOString(),
provider: withdrawData.value.selection.provider,
method: withdrawData.value.selection.method,
methodId: withdrawData.value.selection.methodId,
country: withdrawData.value.selection.country?.id,
amount: withdrawData.value.calculation?.amount,
fee: withdrawData.value.calculation?.fee,
request: {
url: 'POST /api/v3/payout',
},
response: {
status: error?.response?.status ?? error?.statusCode,
statusText: error?.response?.statusText,
headers: responseHeaders,
body: error?.data,
},
}
}
function formatBilingualText(
messageDescriptor: { id: string; defaultMessage: string },
values?: Record<string, string>,
): string {
const localized = formatMessage(messageDescriptor, values)
// Interpolate values into the English default message
let english = messageDescriptor.defaultMessage
if (values) {
for (const [key, value] of Object.entries(values)) {
english = english.replace(`{${key}}`, value)
}
}
if (localized === english) {
return localized
}
return `${localized}\n${english}`
}
function getWithdrawalError(
error: any,
provider: PaymentProvider | null,
): { title: string; text: string; supportData: Record<string, unknown> } {
const description = error?.data?.description?.toLowerCase() || ''
const supportData = buildSupportData(error)
// === Common patterns (all providers) ===
// Tax form error
if (description.includes('tax form')) {
return {
title: formatMessage(messages.errorTaxFormTitle),
text: formatMessage(messages.errorTaxFormText),
}
}
// Invalid crypto wallet address
if (
(description.includes('wallet') && description.includes('invalid')) ||
description.includes('wallet_address') ||
(description.includes('blockchain') && description.includes('invalid'))
) {
return {
title: formatMessage(messages.errorInvalidWalletTitle),
text: formatMessage(messages.errorInvalidWalletText),
}
}
// Invalid bank details
if (
(description.includes('bank') || description.includes('account')) &&
(description.includes('invalid') || description.includes('failed'))
) {
return {
title: formatMessage(messages.errorInvalidBankTitle),
text: formatMessage(messages.errorInvalidBankText),
}
}
// Invalid/fraudulent address
if (
description.includes('address') &&
(description.includes('invalid') ||
description.includes('verification') ||
description.includes('fraudulent'))
) {
return {
title: formatMessage(messages.errorInvalidAddressTitle),
text: formatMessage(messages.errorInvalidAddressText),
text: formatBilingualText(messages.errorTaxFormText),
supportData,
}
}
@@ -388,14 +414,106 @@ function getWithdrawalError(error: any): { title: string; text: string } {
) {
return {
title: formatMessage(messages.errorMinimumNotMetTitle),
text: formatMessage(messages.errorMinimumNotMetText),
text: formatBilingualText(messages.errorMinimumNotMetText),
supportData,
}
}
// Insufficient balance
if (description.includes('enough funds') || description.includes('insufficient')) {
return {
title: formatMessage(messages.errorInsufficientBalanceTitle),
text: formatBilingualText(messages.errorInsufficientBalanceText),
supportData,
}
}
// Email verification required
if (description.includes('verify your email') || description.includes('email_verified')) {
return {
title: formatMessage(messages.errorEmailVerificationTitle),
text: formatBilingualText(messages.errorEmailVerificationText),
supportData,
}
}
// === MuralPay-only patterns ===
if (provider === 'muralpay') {
// Invalid crypto wallet address
if (
(description.includes('wallet') && description.includes('invalid')) ||
description.includes('wallet_address') ||
(description.includes('blockchain') && description.includes('invalid'))
) {
return {
title: formatMessage(messages.errorInvalidWalletTitle),
text: formatBilingualText(messages.errorInvalidWalletText),
supportData,
}
}
// Invalid bank details
if (
(description.includes('bank') || description.includes('account')) &&
(description.includes('invalid') || description.includes('failed'))
) {
return {
title: formatMessage(messages.errorInvalidBankTitle),
text: formatBilingualText(messages.errorInvalidBankText),
supportData,
}
}
// Invalid/fraudulent address (physical address for KYC)
if (
description.includes('address') &&
(description.includes('invalid') ||
description.includes('verification') ||
description.includes('fraudulent'))
) {
return {
title: formatMessage(messages.errorInvalidAddressTitle),
text: formatBilingualText(messages.errorInvalidAddressText),
supportData,
}
}
}
// === PayPal/Venmo-only patterns ===
if (provider === 'paypal' || provider === 'venmo') {
// Account not linked
if (
description.includes('not linked') ||
description.includes('link') ||
description.includes('paypal account') ||
description.includes('venmo')
) {
return {
title: formatMessage(messages.errorAccountNotLinkedTitle),
text: formatBilingualText(messages.errorAccountNotLinkedText),
supportData,
}
}
// Country mismatch for PayPal
if (
provider === 'paypal' &&
(description.includes('us paypal') || description.includes('international paypal'))
) {
return {
title: formatMessage(messages.errorPaypalCountryMismatchTitle),
text: formatBilingualText(messages.errorPaypalCountryMismatchText),
supportData,
}
}
}
// Generic fallback
const errorDescription = error?.data?.description || ''
return {
title: formatMessage(messages.errorGenericTitle),
text: formatMessage(messages.errorGenericText),
text: formatBilingualText(messages.errorGenericText, { error: errorDescription }),
supportData,
}
}
@@ -409,11 +527,15 @@ async function handleWithdraw() {
} catch (error) {
console.error('Withdrawal failed:', error)
const { title, text } = getWithdrawalError(error)
const { title, text, supportData } = getWithdrawalError(
error,
withdrawData.value.selection.provider,
)
addNotification({
title,
text,
type: 'error',
supportData,
})
} finally {
isSubmitting.value = false
@@ -570,7 +692,40 @@ const messages = defineMessages({
errorGenericText: {
id: 'dashboard.withdraw.error.generic.text',
defaultMessage:
'We were unable to submit your withdrawal request, please check your details or contact support.',
'We were unable to submit your withdrawal request, please check your details or contact support.\n{error}',
},
errorInsufficientBalanceTitle: {
id: 'dashboard.withdraw.error.insufficient-balance.title',
defaultMessage: 'Insufficient balance',
},
errorInsufficientBalanceText: {
id: 'dashboard.withdraw.error.insufficient-balance.text',
defaultMessage: 'You do not have enough funds to make this withdrawal.',
},
errorEmailVerificationTitle: {
id: 'dashboard.withdraw.error.email-verification.title',
defaultMessage: 'Email verification required',
},
errorEmailVerificationText: {
id: 'dashboard.withdraw.error.email-verification.text',
defaultMessage: 'You must verify your email address before withdrawing funds.',
},
errorAccountNotLinkedTitle: {
id: 'dashboard.withdraw.error.account-not-linked.title',
defaultMessage: 'Account not linked',
},
errorAccountNotLinkedText: {
id: 'dashboard.withdraw.error.account-not-linked.text',
defaultMessage: 'Please link your payment account before withdrawing.',
},
errorPaypalCountryMismatchTitle: {
id: 'dashboard.withdraw.error.paypal-country-mismatch.title',
defaultMessage: 'PayPal region mismatch',
},
errorPaypalCountryMismatchText: {
id: 'dashboard.withdraw.error.paypal-country-mismatch.text',
defaultMessage:
'Please use the correct PayPal transfer option for your region (US or International).',
},
})
</script>

View File

@@ -980,12 +980,30 @@
"dashboard.withdraw.completion.wallet": {
"message": "Wallet"
},
"dashboard.withdraw.error.account-not-linked.text": {
"message": "Please link your payment account before withdrawing."
},
"dashboard.withdraw.error.account-not-linked.title": {
"message": "Account not linked"
},
"dashboard.withdraw.error.email-verification.text": {
"message": "You must verify your email address before withdrawing funds."
},
"dashboard.withdraw.error.email-verification.title": {
"message": "Email verification required"
},
"dashboard.withdraw.error.generic.text": {
"message": "We were unable to submit your withdrawal request, please check your details or contact support."
"message": "We were unable to submit your withdrawal request, please check your details or contact support.\n{error}"
},
"dashboard.withdraw.error.generic.title": {
"message": "Unable to withdraw"
},
"dashboard.withdraw.error.insufficient-balance.text": {
"message": "You do not have enough funds to make this withdrawal."
},
"dashboard.withdraw.error.insufficient-balance.title": {
"message": "Insufficient balance"
},
"dashboard.withdraw.error.invalid-address.text": {
"message": "The address you provided could not be verified. Please check your address details."
},
@@ -1010,6 +1028,12 @@
"dashboard.withdraw.error.minimum-not-met.title": {
"message": "Amount too low"
},
"dashboard.withdraw.error.paypal-country-mismatch.text": {
"message": "Please use the correct PayPal transfer option for your region (US or International)."
},
"dashboard.withdraw.error.paypal-country-mismatch.title": {
"message": "PayPal region mismatch"
},
"dashboard.withdraw.error.tax-form.text": {
"message": "You must complete a tax form to submit your withdrawal request."
},

View File

@@ -49,8 +49,13 @@
x{{ item.count }}
</div>
<ButtonStyled circular size="small">
<button v-tooltip="'Copy to clipboard'" @click="copyToClipboard(item)">
<CheckIcon v-if="copied[createNotifText(item)]" />
<button
v-tooltip="
item.supportData ? 'Copy error details for support' : 'Copy to clipboard'
"
@click="copyToClipboard(item)"
>
<CheckIcon v-if="copied[getCopyKey(item)]" />
<CopyIcon v-else />
</button>
</ButtonStyled>
@@ -106,18 +111,26 @@ function createNotifText(notif: WebNotification): string {
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
}
function getCopyKey(notif: WebNotification): string {
return notif.supportData ? `support-${notif.id}` : createNotifText(notif)
}
function checkIntercomPresence(): void {
isIntercomPresent.value = !!document.querySelector('.intercom-lightweight-app')
}
function copyToClipboard(notif: WebNotification): void {
const text = createNotifText(notif)
// If supportData is present, copy the full JSON for support; otherwise copy plain text
const text = notif.supportData
? JSON.stringify(notif.supportData, null, 2)
: createNotifText(notif)
copied.value[text] = true
const key = getCopyKey(notif)
copied.value[key] = true
navigator.clipboard.writeText(text)
setTimeout(() => {
const { [text]: _, ...rest } = copied.value
const { [key]: _, ...rest } = copied.value
copied.value = rest
}, 2000)
}

View File

@@ -8,6 +8,7 @@ export interface WebNotification {
errorCode?: string
count?: number
timer?: NodeJS.Timeout
supportData?: Record<string, unknown>
}
export type NotificationPanelLocation = 'left' | 'right'