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:
@@ -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>
|
||||
|
||||
+10
-2
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 |
@@ -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();
|
||||
|
||||
|
||||
@@ -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] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user