Refactor POS booking flow and error handling:

- Improved error state management with `normalizeErrorState` and `clearErrorState` utilities.
- Enhanced Stripe payment flow with clear status messaging and support for terminal setup requirements.
- Updated POS component templates (`PosDepartmentStep3.vue`, `PayWithStripeButton.vue`) for better error messages and UX.
- Added `pos-step-4` navigation and tests in e2e flows.
- Introduced utility `toPositiveInteger` for robust numeric validation in bookings.
This commit is contained in:
Jeppe Bundgaard
2026-04-14 10:27:29 +02:00
parent c14f0cc1c4
commit e88c2d14d5
15 changed files with 503 additions and 224 deletions
@@ -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(() => {
</div>
</div>
<div v-if="error" class="notification is-danger is-light" data-testid="pos-stripe-error-message">
{{ error }}
<div v-if="currentErrorMessage" class="notification is-danger is-light" data-testid="pos-stripe-error-message">
{{ currentErrorMessage }}
</div>
<div v-else-if="paymentFlowState === 'waiting_for_reader'" class="notification is-info is-light" data-testid="pos-stripe-waiting-message">
Payment is in progress on the selected reader. Refresh the state if the reader has already collected the card.
@@ -673,175 +745,225 @@ onUnmounted(() => {
</div>
</template>
<div v-else class="columns">
<template v-if="StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent)">
<div class="column">
<NextStep class="is-fullwidth" label="Complete" />
<template v-else>
<div
v-if="paymentFlowState === 'setup_required'"
class="pos-stripe-setup-required"
data-testid="pos-stripe-setup-required"
>
<div class="pos-stripe-setup-required__content">
<div>
<p class="pos-stripe-setup-required__title">Card payments are not ready for this department.</p>
<p class="pos-stripe-setup-required__message">
{{ currentErrorMessage }}
</p>
<p
v-if="!canOpenStripeSetup"
class="pos-stripe-setup-required__message pos-stripe-setup-required__message--secondary"
data-testid="pos-stripe-contact-superuser"
>
Contact a superuser to open Stripe setup and choose a terminal location for this department.
</p>
</div>
<div class="pos-stripe-setup-required__actions">
<button
class="button is-warning"
type="button"
data-testid="pos-stripe-refresh-setup"
@click="retryLoadState"
>
<span class="icon is-small">
<i class="fas fa-rotate-right"></i>
</span>
<span>Refresh terminal setup</span>
</button>
<a
v-if="canOpenStripeSetup"
class="button is-link"
:href="stripeSetupHref"
data-testid="pos-stripe-open-setup"
>
<span class="icon is-small">
<i class="fas fa-arrow-up-right-from-square"></i>
</span>
<span>Open Stripe setup</span>
</a>
</div>
</div>
</template>
<div class="column">
<template v-if="error">
<button
class="button is-danger"
type="button"
data-testid="pos-stripe-error"
@click="retryLoadState"
>
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>{{ error }}</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'reader_unavailable'">
<button
class="button is-warning"
type="button"
data-testid="pos-stripe-no-readers"
@click="retryLoadState"
>
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>No readers available</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'idle'">
<button
class="button"
type="button"
data-testid="pos-stripe-create-intent"
:disabled="!isReaderSelected"
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
@click="onClickCreatePaymentIntent"
>
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>{{ props.label }}</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'ready_to_capture'">
<button
class="button"
type="button"
data-testid="pos-stripe-capture-intent"
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
@click="onClickCapturePaymentIntent"
>
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>{{ primaryActionLabel }}</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'succeeded'">
<button
class="button is-text"
type="button"
data-testid="pos-stripe-payment-succeeded"
:style="{ 'color': Colors.buttons.success.backgroundColor, 'text-decoration': 'none' }"
@click="showPaymentDetails(paymentIntent)"
>
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
<span>
{{ SessionUser.objects.global.language.payment.payment_recieved }}
{{ paymentAmountSummary }}
</span>
</button>
</template>
<template v-else>
<button
class="button"
type="button"
data-testid="pos-stripe-payment-in-progress"
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
@click="retryLoadState"
>
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>Payment in progress</span>
</button>
</template>
</div>
<div class="column">
<template v-if="shouldShowReaderSelection">
<div class="select">
<select v-model="selectedReaderId" data-testid="pos-stripe-reader-select">
<option disabled value="">
<template v-if="isAnyReadersAvailable">Select a reader</template>
<template v-else>No available readers</template>
</option>
<template v-for="reader of availableReaders" :key="reader.id">
<option :value="String(reader.id)">
{{ reader.label }} ({{ getReaderStatus(reader) }})
<div v-else class="columns">
<template v-if="StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent)">
<div class="column">
<NextStep class="is-fullwidth" label="Complete" />
</div>
</template>
<div class="column">
<template v-if="currentErrorMessage">
<button
class="button is-danger"
type="button"
data-testid="pos-stripe-error"
@click="retryLoadState"
>
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>{{ currentErrorMessage }}</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'reader_unavailable'">
<button
class="button is-warning"
type="button"
data-testid="pos-stripe-no-readers"
@click="retryLoadState"
>
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>No readers available</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'idle'">
<button
class="button"
type="button"
data-testid="pos-stripe-create-intent"
:disabled="!isReaderSelected"
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
@click="onClickCreatePaymentIntent"
>
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>{{ props.label }}</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'ready_to_capture'">
<button
class="button"
type="button"
data-testid="pos-stripe-capture-intent"
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
@click="onClickCapturePaymentIntent"
>
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>{{ primaryActionLabel }}</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'succeeded'">
<button
class="button is-text"
type="button"
data-testid="pos-stripe-payment-succeeded"
:style="{ 'color': Colors.buttons.success.backgroundColor, 'text-decoration': 'none' }"
@click="showPaymentDetails(paymentIntent)"
>
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
<span>
{{ SessionUser.objects.global.language.payment.payment_recieved }}
{{ paymentAmountSummary }}
</span>
</button>
</template>
<template v-else>
<button
class="button"
type="button"
data-testid="pos-stripe-payment-in-progress"
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
@click="retryLoadState"
>
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>Payment in progress</span>
</button>
</template>
</div>
<div class="column">
<template v-if="shouldShowReaderSelection">
<div class="select">
<select v-model="selectedReaderId" data-testid="pos-stripe-reader-select">
<option disabled value="">
<template v-if="isAnyReadersAvailable">Select a reader</template>
<template v-else>No available readers</template>
</option>
</template>
<template v-if="isAnyReadersUnavailable">
<option disabled>Unavailable readers</option>
<template v-for="reader of readers" :key="`${reader.id}-desktop`">
<option v-if="!isReaderAvailable(reader)" :value="String(reader.id)" disabled>
<template v-for="reader of availableReaders" :key="reader.id">
<option :value="String(reader.id)">
{{ reader.label }} ({{ getReaderStatus(reader) }})
</option>
</template>
</template>
</select>
<template v-if="isAnyReadersUnavailable">
<option disabled>Unavailable readers</option>
<template v-for="reader of readers" :key="`${reader.id}-desktop`">
<option v-if="!isReaderAvailable(reader)" :value="String(reader.id)" disabled>
{{ reader.label }} ({{ getReaderStatus(reader) }})
</option>
</template>
</template>
</select>
</div>
</template>
</div>
<template v-if="shouldShowTaxSelection">
<div class="column">
<div class="select">
<select v-model="selectedTaxRate" data-testid="pos-stripe-tax-select">
<template v-for="taxRate in taxRates" :key="taxRate.id">
<option :value="taxRate.id">
{{ taxRate.display_name }} ({{ taxRate.percentage }}% VAT)
</option>
</template>
</select>
</div>
</div>
</template>
<div class="column">
<template v-if="canCancelPaymentIntent">
<button
class="button is-danger"
type="button"
data-testid="pos-stripe-delete-intent"
:class="{ 'is-loading': loadingDeleteButton }"
@click="onClickCancelPaymentIntent"
>
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
<span>Delete Payment Intent</span>
</button>
</template>
</div>
<template v-if="StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent)">
<div class="column">
<PrintInvoiceFromOrderItems
:order_id="props.order_id"
:order-items="props.order_items"
:tax_percentage="paymentIntentTaxPercentage"
:paid="true"
/>
</div>
</template>
</div>
<template v-if="shouldShowTaxSelection">
<div class="column">
<div class="select">
<select v-model="selectedTaxRate" data-testid="pos-stripe-tax-select">
<template v-for="taxRate in taxRates" :key="taxRate.id">
<option :value="taxRate.id">
{{ taxRate.display_name }} ({{ taxRate.percentage }}% VAT)
</option>
</template>
</select>
</div>
</div>
</template>
<div class="column">
<template v-if="canCancelPaymentIntent">
<button
class="button is-danger"
type="button"
data-testid="pos-stripe-delete-intent"
:class="{ 'is-loading': loadingDeleteButton }"
@click="onClickCancelPaymentIntent"
>
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
<span>Delete Payment Intent</span>
</button>
</template>
</div>
<template v-if="StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent)">
<div class="column">
<PrintInvoiceFromOrderItems
:order_id="props.order_id"
:order-items="props.order_items"
:tax_percentage="paymentIntentTaxPercentage"
:paid="true"
/>
</div>
</template>
</div>
</template>
</template>
<style scoped>
@@ -907,4 +1029,40 @@ onUnmounted(() => {
.select.is-fullwidth select {
width: 100%;
}
.pos-stripe-setup-required {
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: 16px;
padding: 1rem 1.1rem;
background: linear-gradient(180deg, rgba(255, 251, 235, 0.98) 0%, rgba(255, 247, 237, 0.98) 100%);
}
.pos-stripe-setup-required__content {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.pos-stripe-setup-required__title {
margin: 0;
font-size: 0.98rem;
font-weight: 700;
color: #7c2d12;
}
.pos-stripe-setup-required__message {
margin: 0.35rem 0 0;
color: #9a3412;
line-height: 1.45;
}
.pos-stripe-setup-required__message--secondary {
color: #7c2d12;
}
.pos-stripe-setup-required__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
</style>
@@ -33,8 +33,8 @@ setProductsCategory(null);
<div class="pos-step-2__actions" data-testid="pos-step-2-actions">
<NextStepError class="is-fullwidth" />
<ButtonsBox class="pos-actions pos-actions--desktop">
<Cancel class="is-fullwidth" />
<NextStep class="is-fullwidth" />
<Cancel class="is-fullwidth" />
</ButtonsBox>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup>
import { computed } from "vue";
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
import {
@@ -16,6 +17,7 @@ import { useI18n } from "vue-i18n";
import PosDesktopOrderWorkspace from "@/components/displays/department/pos/order/PosDesktopOrderWorkspace.vue";
const { t } = useI18n();
const isGuestStripeCustomer = computed(() => Number(customer_id) === 999);
</script>
<template>
@@ -39,12 +41,7 @@ const { t } = useI18n();
<template #rail-actions>
<NextStepError class="is-fullwidth" />
<ButtonsBox class="pos-actions pos-actions--rail">
<Cancel
tabindex="2"
class="is-fullwidth"
:label="t('common.cancel')"
/>
<template v-if="customer_id !== 999">
<template v-if="!isGuestStripeCustomer">
<NextStep
class="is-fullwidth"
:label="t('pos.complete')"
@@ -59,6 +56,11 @@ const { t } = useI18n();
v-bind:order_items="order_items"
/>
</template>
<Cancel
tabindex="2"
class="is-fullwidth"
:label="t('common.cancel')"
/>
</ButtonsBox>
</template>
</PosDesktopOrderWorkspace>
@@ -32,15 +32,15 @@ const { t } = useI18n();
<template #rail-actions>
<NextStepError class="is-fullwidth" />
<ButtonsBox class="pos-actions pos-actions--rail">
<NextStep
class="is-fullwidth"
:label="t('pos.complete')"
/>
<Cancel
tabindex="2"
class="is-fullwidth"
:label="t('common.cancel')"
/>
<NextStep
class="is-fullwidth"
:label="t('pos.complete')"
/>
</ButtonsBox>
</template>
</PosDesktopOrderWorkspace>
@@ -317,10 +317,18 @@ const onClick = async () => {
});
if (restoredOrderId) {
console.warn('Order ID retrieved from local storage:', restoredOrderId);
nextStep({isMobile: true, orderCreation: false});
nextStep({
isMobile: true,
orderCreation: false,
bookingId: metadata.getBookingId?.() ?? null,
});
return;
}
nextStep({isMobile: true, orderCreation: true});
nextStep({
isMobile: true,
orderCreation: true,
bookingId: metadata.getBookingId?.() ?? null,
});
break;
case 2:
step1();
@@ -104,6 +104,7 @@ onMounted(async () => {
<b-field class="permission-checkbox">
<b-checkbox
:model-value="selectedPermissions.includes(node.key)"
:data-testid="`permission-node-checkbox-${node.key}`"
@update:model-value="(checked) => togglePermission(node.key, !!checked)"
@input="(checked) => togglePermission(node.key, !!checked)"
>
@@ -649,6 +649,7 @@ export const createOrder = (options = { isMobile: false }) => {
if (!token) {
return Promise.resolve(false);
}
const normalizedBookingId = toPositiveInteger(options.bookingId ?? selectedOrderBookingId.value);
isCreatingOrder.value = true;
createOrderRequest = axios
.post(
@@ -663,6 +664,7 @@ export const createOrder = (options = { isMobile: false }) => {
reg_1: reg_1.value,
reg_2: reg_2.value,
reg_3: reg_3.value,
...(normalizedBookingId ? { booking_id: normalizedBookingId } : {}),
is_handheld: options.isMobile,
},
{
+19 -6
View File
@@ -12,6 +12,11 @@ function json(body, status = 200) {
};
}
function toPositiveInteger(value) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function toOrderBookingListEntry(booking, stripDetails = false) {
if (!stripDetails || !booking || typeof booking !== "object") {
return booking;
@@ -556,7 +561,7 @@ async function mockPosApi(page, fixture) {
reg_2: body.reg_2 || "",
reg_3: body.reg_3 || "",
invoice_collection_id: null,
booking_id: null,
booking_id: toPositiveInteger(body.booking_id),
completed_at: null,
created_at: new Date().toISOString(),
};
@@ -1316,6 +1321,8 @@ test.describe("POS flow", () => {
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-3")).toBeVisible({ timeout: 10_000 });
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-4")).toBeVisible({ timeout: 10_000 });
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect.poll(() => fixture.ordersById[9300]?.booking_id || null, { timeout: 10_000 }).toBe(8101);
@@ -1325,7 +1332,8 @@ test.describe("POS flow", () => {
id: 8101,
order_id: 9300,
});
await expect.poll(() => fixture.completedBookingIds, { timeout: 10_000 }).toContain(8101);
await expect.poll(() => fixture.markCompletedOrderIds, { timeout: 10_000 }).toContain(9300);
await expect.poll(() => fixture.completedBookingIds.length, { timeout: 10_000 }).toBe(0);
});
test("desktop opens a chooser for multiple matching order bookings and hydrates the selected booking", async ({
@@ -1450,6 +1458,8 @@ test.describe("POS flow", () => {
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-3")).toBeVisible({ timeout: 10_000 });
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-4")).toBeVisible({ timeout: 10_000 });
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect.poll(() => fixture.ordersById[9300]?.booking_id || null, { timeout: 10_000 }).toBe(8102);
await expect
@@ -1458,8 +1468,8 @@ test.describe("POS flow", () => {
id: 8102,
order_id: 9300,
});
await expect.poll(() => fixture.completedBookingIds, { timeout: 10_000 }).toContain(8102);
expect(fixture.completedBookingIds).not.toContain(8103);
await expect.poll(() => fixture.markCompletedOrderIds, { timeout: 10_000 }).toContain(9300);
await expect.poll(() => fixture.completedBookingIds.length, { timeout: 10_000 }).toBe(0);
});
test("desktop booking selector prioritizes and highlights bookings scheduled for today", async ({
@@ -2215,6 +2225,7 @@ test.describe("POS flow", () => {
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.ordersById[9300]?.booking_id ?? null, { timeout: 10_000 }).toBe(8111);
await expect
.poll(() => (fixture.orderItemsByOrderId[9300] || []).map((item) => Number(item.product_id)), { timeout: 10_000 })
.toEqual([53, 63]);
@@ -2222,6 +2233,8 @@ test.describe("POS flow", () => {
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-3")).toBeVisible({ timeout: 10_000 });
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-4")).toBeVisible({ timeout: 10_000 });
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect.poll(() => fixture.ordersById[9300]?.booking_id || null, { timeout: 10_000 }).toBe(8111);
await expect
@@ -2233,8 +2246,8 @@ test.describe("POS flow", () => {
{ timeout: 10_000 }
)
.toBe(true);
await expect.poll(() => fixture.completedBookingIds, { timeout: 10_000 }).toContain(8111);
expect(fixture.completedBookingIds).not.toContain(8112);
await expect.poll(() => fixture.markCompletedOrderIds, { timeout: 10_000 }).toContain(9300);
await expect.poll(() => fixture.completedBookingIds.length, { timeout: 10_000 }).toBe(0);
});
test("desktop continue without booking skips hydration and completion requests", async ({ page }, testInfo) => {
+1
View File
@@ -661,6 +661,7 @@ test.describe("POS mobile order flow", () => {
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.ordersById[9300]?.booking_id ?? null, { timeout: 10_000 }).toBe(8202);
await waitForBookingHydration(page, {
primaryId: 63,
addonProductIds: [],
+70 -2
View File
@@ -189,6 +189,14 @@ async function getHorizontalBounds(locator) {
});
}
async function expectPrimaryActionAboveClearAll(primaryAction, clearAllAction) {
const primaryBounds = await getHorizontalBounds(primaryAction);
const clearAllBounds = await getHorizontalBounds(clearAllAction);
expect(primaryBounds.top).toBeLessThan(clearAllBounds.top);
expect(primaryBounds.bottom).toBeLessThan(clearAllBounds.bottom);
}
async function openOrderDetailAddItems(page) {
const addItemsPanel = page.locator('[data-testid="pos-order-add-items-panel"]:visible').first();
const addItemsBackButton = page.locator('[data-testid="pos-order-add-items-back"]:visible').first();
@@ -446,6 +454,8 @@ test.describe("POS visuals", () => {
await page.locator('[data-testid="pos-next-step"]:visible').click();
const stepTwo = page.getByTestId("pos-step-2");
const primaryAction = stepTwo.getByTestId("pos-next-step");
const clearAllAction = stepTwo.getByRole("button", { name: /Slet alle/i });
await expect(stepTwo).toBeVisible();
await expect(stepTwo.getByTestId("pos-order-add-items-panel")).toBeVisible();
await expect(stepTwo.getByTestId("pos-order-panel-cart")).toBeVisible();
@@ -453,6 +463,9 @@ test.describe("POS visuals", () => {
await expect(page.locator('[data-testid="pos-order-customer-name"]:visible').first()).toHaveText(
/\(TEST\) Pleno Vognmandsforretning/
);
await expect(primaryAction).toBeVisible();
await expect(clearAllAction).toBeVisible();
await expectPrimaryActionAboveClearAll(primaryAction, clearAllAction);
await expect(stepTwo).toHaveScreenshot("pos-step-2-desktop.png", {
maxDiffPixels: 300,
});
@@ -477,13 +490,17 @@ test.describe("POS visuals", () => {
await page.goto("/admin/12/modules/pos?id=54518&customer_id=12345679&step=3");
await customerResponse;
const stepThree = page.getByTestId("pos-step-3");
const primaryAction = stepThree.getByTestId("pos-next-step");
const clearAllAction = stepThree.getByRole("button", { name: /Slet alle/i });
await expect(stepThree).toBeVisible();
await expect(stepThree.getByTestId("pos-order-panel-cart")).toBeVisible();
await expect(stepThree.getByTestId("pos-order-metadata-grid")).toBeVisible();
await expect(stepThree.getByTestId("pos-order-rail")).toBeVisible();
await expect(stepThree).toContainText("Tilføj flere varer");
await expect(stepThree.getByRole("button", { name: /Slet alle/i })).toBeVisible();
await expect(primaryAction).toBeVisible();
await expect(clearAllAction).toBeVisible();
await expect(stepThree.getByRole("button", { name: /Fuldfør/i })).toBeVisible();
await expectPrimaryActionAboveClearAll(primaryAction, clearAllAction);
await expect(stepThree).toHaveScreenshot("pos-step-3-desktop.png", {
maxDiffPixels: 300,
});
@@ -508,18 +525,69 @@ test.describe("POS visuals", () => {
await page.goto("/admin/12/modules/pos?id=54518&customer_id=12345679&step=4");
await customerResponse;
const stepFour = page.getByTestId("pos-step-4");
const primaryAction = stepFour.getByTestId("pos-next-step");
const clearAllAction = stepFour.getByRole("button", { name: /Slet alle/i });
await expect(stepFour).toBeVisible();
await expect(stepFour.getByTestId("pos-order-panel-cart")).toBeVisible();
await expect(stepFour.getByTestId("pos-order-metadata-grid")).toBeVisible();
await expect(stepFour.getByTestId("pos-order-rail")).toBeVisible();
await expect(stepFour).toContainText("Tilføj flere varer");
await expect(stepFour.getByRole("button", { name: /Slet alle/i })).toBeVisible();
await expect(primaryAction).toBeVisible();
await expect(clearAllAction).toBeVisible();
await expect(stepFour.getByRole("button", { name: /Fuldfør/i })).toBeVisible();
await expectPrimaryActionAboveClearAll(primaryAction, clearAllAction);
await expect(stepFour).toHaveScreenshot("pos-step-4-desktop.png", {
maxDiffPixels: 300,
});
});
test("desktop step 3 guest stripe action order", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: createPosFixture({
ordersById: {
54518: {
id: 54518,
customer_id: 999,
department_id: 12,
reference: "CARD-REF-54518",
notes: "Paid at terminal",
reg_1: "ZZ99999",
reg_2: "",
reg_3: "",
invoice_collection_id: null,
booking_id: null,
completed_at: null,
closed_at: null,
created_at: "2026-04-08 08:44:07",
},
},
}),
});
await primeSession(page, "pos-visual-desktop-step-3-guest-token");
await page.goto("/admin/12/modules/pos?id=54518&customer_id=999&step=3");
const stepThree = page.getByTestId("pos-step-3");
const primaryAction = stepThree
.locator(
'[data-testid="pos-stripe-create-intent"], [data-testid="pos-stripe-capture-intent"], [data-testid="pos-stripe-no-readers"], [data-testid="pos-stripe-payment-in-progress"], [data-testid="pos-stripe-payment-succeeded"], [data-testid="pos-stripe-error"]'
)
.first();
const clearAllAction = stepThree.getByRole("button", { name: /Slet alle/i });
await expect(stepThree).toBeVisible();
await expect(stepThree.getByTestId("pos-order-panel-cart")).toBeVisible();
await expect(stepThree.getByTestId("pos-order-metadata-grid")).toBeVisible();
await expect(stepThree.getByTestId("pos-order-rail")).toBeVisible();
await expect(primaryAction).toBeVisible();
await expect(clearAllAction).toBeVisible();
await expectPrimaryActionAboveClearAll(primaryAction, clearAllAction);
});
test("desktop order detail add-items workspace snapshot", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+1 -1
View File
@@ -389,7 +389,7 @@ test("customer user can invite and manage chauffører from /user/subusers", asyn
await page.getByTestId("subuser-permissions-100").click();
await expect(page.getByText("Tilladelsesnoder for Updated Driver")).toBeVisible();
await page.locator('input[type="checkbox"]').first().check();
await page.getByTestId("permission-node-checkbox-SUBUSERS_LIST").click();
await page.getByRole("button", { name: "Gem" }).click();
await expect(page.getByText(/SUBUSERS_LIST/)).toBeVisible();
+1
View File
@@ -1477,6 +1477,7 @@ export async function mockMobilePosApi(page, fixture) {
reg_1: body.reg_1 || "",
reg_2: body.reg_2 || "",
reg_3: body.reg_3 || "",
booking_id: toPositiveInteger(body.booking_id),
created_at: new Date().toISOString(),
});
fixture.orderItemsByOrderId[orderId] = [];
+39 -14
View File
@@ -127,6 +127,11 @@ function normalizeSafetySealValue(value) {
return String(value).trim();
}
function normalizePositiveIntegerValue(value) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function isWashCertificateProduct(product) {
const productId = Number(product?.id ?? product?.product_id ?? product?.product?.id ?? 0);
if (productId === 41) {
@@ -1122,6 +1127,7 @@ export function createPosFixture(overrides = {}) {
54518: {},
},
paymentIntentsByOrderId: {},
stripeReadersError: null,
readers: [{ id: "reader_online_1", label: "Mobile Reader", status: "online", action: null }],
nextOrderId: 54519,
nextOrderItemId: 9200,
@@ -1587,26 +1593,26 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
return true;
}
if (pathname.endsWith("/orders") && method === "POST") {
const body = request.postDataJSON?.() || {};
const orderId = posFixture.nextOrderId++;
posFixture.ordersById[orderId] = {
id: orderId,
if (pathname.endsWith("/orders") && method === "POST") {
const body = request.postDataJSON?.() || {};
const orderId = posFixture.nextOrderId++;
posFixture.ordersById[orderId] = {
id: orderId,
customer_id: Number(body.customer_id),
department_id: Number(body.department_id || body.department || 12),
reference: body.reference || "",
po: body.po || "",
safety_seal: normalizeSafetySealValue(body.safety_seal),
notes: body.notes || "",
reg_1: normalizeRegistrationValue(body.reg_1),
reg_2: normalizeRegistrationValue(body.reg_2),
reg_3: normalizeRegistrationValue(body.reg_3),
invoice_collection_id: null,
booking_id: null,
completed_at: null,
closed_at: null,
created_at: normalizeCreatedAtValue(body.created_at) || toSqlDateTime(),
include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice),
reg_1: normalizeRegistrationValue(body.reg_1),
reg_2: normalizeRegistrationValue(body.reg_2),
reg_3: normalizeRegistrationValue(body.reg_3),
invoice_collection_id: null,
booking_id: normalizePositiveIntegerValue(body.booking_id),
completed_at: null,
closed_at: null,
created_at: normalizeCreatedAtValue(body.created_at) || toSqlDateTime(),
include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice),
};
posFixture.orderItemsByOrderId[orderId] = [];
posFixture.economicModuleOrdersByOrderId[orderId] = { invoice_id: null, invoice_draft_id: null };
@@ -1905,6 +1911,25 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
}
if (pathname.endsWith("/modules/stripe/department/terminal/readers") && method === "GET") {
if (posFixture.stripeReadersError) {
const configuredError = posFixture.stripeReadersError;
await route.fulfill(
json(
{
success: false,
data: {
message: configuredError.message || "Unable to load Stripe readers.",
code: configuredError.code || null,
},
meta: {},
includes: {},
},
Number(configuredError.status || 409)
)
);
return true;
}
await route.fulfill(json({ success: true, data: { data: posFixture.readers || [] } }));
return true;
}