diff --git a/src/components/displays/department/pos/displays/PayWithStripeButton.vue b/src/components/displays/department/pos/displays/PayWithStripeButton.vue index bc921554..1e508253 100644 --- a/src/components/displays/department/pos/displays/PayWithStripeButton.vue +++ b/src/components/displays/department/pos/displays/PayWithStripeButton.vue @@ -7,6 +7,7 @@ import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue"; import PrintInvoiceFromOrderItems from "@/components/forms/department/pos/buttons/PrintInvoiceFromOrderItems.vue"; const POLLING_INTERVAL_MS = 5000; +const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required'; const props = defineProps({ departmentId: { @@ -36,7 +37,9 @@ const props = defineProps({ }); const readers = ref([]); -const error = ref(null); +const readersError = ref(null); +const paymentError = ref(null); +const actionError = ref(null); const isReady = ref(false); const isReadersLoading = ref(false); const selectedReaderId = ref(''); @@ -54,12 +57,25 @@ const taxRates = ref([ const selectedTaxRate = ref(taxRates.value[0].id); const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value); -const parseRequestError = (requestError, fallbackMessage) => { - return SessionUser.functions.parseErrorMessage?.(requestError) - || requestError?.response?.data?.data?.message - || requestError?.response?.data?.message - || requestError?.message - || fallbackMessage; +const normalizeErrorState = (errorLike, fallbackMessage, source) => { + const responsePayload = errorLike?.response?.data?.data; + const directPayload = errorLike?.data?.data ?? errorLike?.data; + const payload = responsePayload && typeof responsePayload === 'object' + ? responsePayload + : directPayload && typeof directPayload === 'object' + ? directPayload + : {}; + + return { + source, + status: errorLike?.response?.status ?? errorLike?.status ?? null, + code: payload?.code ? String(payload.code) : null, + message: payload?.message + || SessionUser.functions.parseErrorMessage?.(errorLike) + || errorLike?.response?.data?.message + || errorLike?.message + || fallbackMessage, + }; }; const getTaxRatePercentage = (taxRateId) => { @@ -67,6 +83,19 @@ const getTaxRatePercentage = (taxRateId) => { return taxRate ? taxRate.percentage : 0; }; +const clearErrorState = (...sources) => { + const sourceSet = new Set(sources); + if (sourceSet.has('readers')) { + readersError.value = null; + } + if (sourceSet.has('payment')) { + paymentError.value = null; + } + if (sourceSet.has('action')) { + actionError.value = null; + } +}; + const isReaderConnected = (reader) => reader?.status === 'online'; const getReaderStatus = (reader) => { @@ -111,6 +140,25 @@ const selectedReader = computed(() => { const isReaderSelected = computed(() => selectedReader.value !== null); const currentIntentState = computed(() => StripeModule.paymentIntents.getPaymentIntentState(paymentIntent.value)); +const hasRecoveredPaymentIntent = computed(() => { + return ['waiting_for_reader', 'ready_to_capture', 'succeeded'].includes(currentIntentState.value); +}); +const currentError = computed(() => { + if (actionError.value) { + return actionError.value; + } + if (paymentError.value) { + return paymentError.value; + } + if (!hasRecoveredPaymentIntent.value) { + return readersError.value; + } + return null; +}); +const currentErrorMessage = computed(() => currentError.value?.message || null); +const isSetupRequiredState = computed(() => currentError.value?.code === STRIPE_TERMINAL_SETUP_REQUIRED_CODE); +const canOpenStripeSetup = computed(() => SessionUser.canAccessSuperUser?.() ?? false); +const stripeSetupHref = computed(() => `/superuser/departments/${props.departmentId}/stripe/setup`); const paymentFlowState = computed(() => { if (operationState.value === 'creating') { @@ -122,9 +170,6 @@ const paymentFlowState = computed(() => { if (operationState.value === 'cancelling') { return 'cancelling'; } - if (error.value) { - return 'failed'; - } if (currentIntentState.value === 'succeeded') { return 'succeeded'; } @@ -134,9 +179,15 @@ const paymentFlowState = computed(() => { if (currentIntentState.value === 'waiting_for_reader') { return 'waiting_for_reader'; } + if (isSetupRequiredState.value) { + return 'setup_required'; + } if (paymentIntent.value === null && isReady.value && !isAnyReadersAvailable.value) { return 'reader_unavailable'; } + if (currentError.value) { + return 'failed'; + } if (currentIntentState.value === 'failed') { return 'failed'; } @@ -151,6 +202,8 @@ const paymentStateLabel = computed(() => { return 'Capturing payment'; case 'cancelling': return 'Cancelling payment'; + case 'setup_required': + return 'Setup required'; case 'reader_unavailable': return 'Reader unavailable'; case 'waiting_for_reader': @@ -172,6 +225,8 @@ const paymentStateToneClass = computed(() => { return 'is-success'; case 'failed': return 'is-danger'; + case 'setup_required': + return 'is-warning'; case 'reader_unavailable': return 'is-warning'; case 'ready_to_capture': @@ -225,6 +280,8 @@ const primaryActionLabel = computed(() => { return 'Capturing payment...'; case 'cancelling': return 'Cancelling payment...'; + case 'setup_required': + return 'Refresh terminal setup'; case 'reader_unavailable': return 'Refresh readers'; case 'waiting_for_reader': @@ -248,6 +305,8 @@ const primaryActionTestId = computed(() => { case 'capturing': case 'cancelling': return 'pos-stripe-loading'; + case 'setup_required': + return 'pos-stripe-setup-required'; case 'reader_unavailable': return 'pos-stripe-no-readers'; case 'waiting_for_reader': @@ -314,16 +373,22 @@ const getStripeReaders = async () => { if (response?.status === 200) { isReady.value = true; - error.value = null; + clearErrorState('readers'); readers.value = response?.data?.data?.data || []; attemptAutomaticReaderSelection(readers.value); return response; } - error.value = response?.data?.data?.message || 'Unable to load Stripe readers.'; + isReady.value = false; + readers.value = []; + selectedReaderId.value = ''; + readersError.value = normalizeErrorState(response, 'Unable to load Stripe readers.', 'readers'); return response; } catch (requestError) { - error.value = parseRequestError(requestError, 'Unable to load Stripe readers.'); + isReady.value = false; + readers.value = []; + selectedReaderId.value = ''; + readersError.value = normalizeErrorState(requestError, 'Unable to load Stripe readers.', 'readers'); console.error('Error fetching readers:', requestError); return null; } finally { @@ -335,11 +400,11 @@ const refreshPaymentIntent = async () => { try { const response = await StripeModule.paymentIntents.getPaymentIntent(props.order_id); if (response?.status === 200) { - error.value = null; + clearErrorState('payment'); } return response; } catch (requestError) { - error.value = parseRequestError(requestError, 'Unable to load payment intent state.'); + paymentError.value = normalizeErrorState(requestError, 'Unable to load payment intent state.', 'payment'); return null; } }; @@ -352,20 +417,22 @@ const syncCurrentState = async () => { }; const retryLoadState = async () => { - error.value = null; + clearErrorState('readers', 'payment', 'action'); pollingEnabled.value = true; await syncCurrentState(); }; const onClickCreatePaymentIntent = async () => { if (!selectedReader.value) { - error.value = 'Select a reader before starting payment.'; + actionError.value = normalizeErrorState({ + message: 'Select a reader before starting payment.', + }, 'Select a reader before starting payment.', 'action'); return; } operationState.value = 'creating'; pollingEnabled.value = true; - error.value = null; + clearErrorState('action'); try { const response = await StripeModule.paymentIntents.createPaymentIntent( @@ -375,13 +442,14 @@ const onClickCreatePaymentIntent = async () => { ); if (response?.status !== 200) { - error.value = response?.data?.data?.message || 'Unable to create payment intent.'; + actionError.value = normalizeErrorState(response, 'Unable to create payment intent.', 'action'); return; } + clearErrorState('payment'); await getStripeReaders(); } catch (requestError) { - error.value = parseRequestError(requestError, 'Unable to create payment intent.'); + actionError.value = normalizeErrorState(requestError, 'Unable to create payment intent.', 'action'); console.error('Error creating payment intent:', requestError); } finally { operationState.value = 'idle'; @@ -391,18 +459,19 @@ const onClickCreatePaymentIntent = async () => { const onClickCapturePaymentIntent = async () => { operationState.value = 'capturing'; pollingEnabled.value = true; - error.value = null; + clearErrorState('action'); try { const response = await StripeModule.paymentIntents.capturePaymentIntent(props.order_id); if (response?.status !== 200) { - error.value = response?.data?.data?.message || 'Unable to capture payment intent.'; + actionError.value = normalizeErrorState(response, 'Unable to capture payment intent.', 'action'); return; } + clearErrorState('payment'); await getStripeReaders(); } catch (requestError) { - error.value = parseRequestError(requestError, 'Unable to capture payment intent.'); + actionError.value = normalizeErrorState(requestError, 'Unable to capture payment intent.', 'action'); console.error('Error capturing payment intent:', requestError); } finally { operationState.value = 'idle'; @@ -412,19 +481,20 @@ const onClickCapturePaymentIntent = async () => { const onClickCancelPaymentIntent = async () => { operationState.value = 'cancelling'; loadingDeleteButton.value = true; - error.value = null; + clearErrorState('action'); try { const response = await StripeModule.paymentIntents.deletePaymentIntent(props.order_id); if (response?.status !== 200) { - error.value = response?.data?.data?.message || 'Unable to delete payment intent.'; + actionError.value = normalizeErrorState(response, 'Unable to delete payment intent.', 'action'); return; } pollingEnabled.value = false; + clearErrorState('payment'); await getStripeReaders(); } catch (requestError) { - error.value = parseRequestError(requestError, 'Unable to delete payment intent.'); + actionError.value = normalizeErrorState(requestError, 'Unable to delete payment intent.', 'action'); console.error('Error deleting payment intent:', requestError); } finally { operationState.value = 'idle'; @@ -459,6 +529,7 @@ const onPrimaryAction = async () => { case 'idle': await onClickCreatePaymentIntent(); return; + case 'setup_required': case 'reader_unavailable': case 'waiting_for_reader': case 'failed': @@ -513,7 +584,7 @@ watch(paymentIntent, (nextPaymentIntent) => { }, { deep: true }); watch(() => props.order_id, () => { - error.value = null; + clearErrorState('readers', 'payment', 'action'); operationState.value = 'idle'; pollingEnabled.value = true; notifiedPaymentIntentId.value = null; @@ -532,6 +603,7 @@ const shouldPoll = computed(() => { return pollingEnabled.value && paymentFlowState.value !== 'succeeded' && paymentFlowState.value !== 'failed' + && paymentFlowState.value !== 'setup_required' && paymentFlowState.value !== 'cancelling'; }); @@ -638,8 +710,8 @@ onUnmounted(() => { -
- {{ error }} +
+ {{ currentErrorMessage }}
Payment is in progress on the selected reader. Refresh the state if the reader has already collected the card. @@ -673,175 +745,225 @@ onUnmounted(() => {
-
- diff --git a/src/components/displays/department/pos/steps/PosDepartmentStep3.vue b/src/components/displays/department/pos/steps/PosDepartmentStep3.vue index 8fff001b..fd292c1a 100644 --- a/src/components/displays/department/pos/steps/PosDepartmentStep3.vue +++ b/src/components/displays/department/pos/steps/PosDepartmentStep3.vue @@ -1,4 +1,5 @@