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(() => {
-
-
-
-
+
+
+
+
+
Card payments are not ready for this department.
+
+ {{ currentErrorMessage }}
+
+
+ Contact a superuser to open Stripe setup and choose a terminal location for this department.
+
+
+
+
-
-
-
-
-
-
-
-
- {{ error }}
-
-
-
-
-
-
-
-
- No readers available
-
-
-
-
-
-
-
-
- {{ props.label }}
-
-
-
-
-
-
-
-
- {{ primaryActionLabel }}
-
-
-
-
-
-
-
-
-
- {{ SessionUser.objects.global.language.payment.payment_recieved }}
- {{ paymentAmountSummary }}
-
-
-
-
-
-
-
-
-
- Payment in progress
-
-
-
-
-
-
-
- Select a reader
- No available readers
-
-
-
- {{ reader.label }} ({{ getReaderStatus(reader) }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentErrorMessage }}
+
+
+
+
+
+
+
+
+ No readers available
+
+
+
+
+
+
+
+
+ {{ props.label }}
+
+
+
+
+
+
+
+
+ {{ primaryActionLabel }}
+
+
+
+
+
+
+
+
+
+ {{ SessionUser.objects.global.language.payment.payment_recieved }}
+ {{ paymentAmountSummary }}
+
+
+
+
+
+
+
+
+
+ Payment in progress
+
+
+
+
+
+
+
+
+
+ Select a reader
+ No available readers
-
-
- Unavailable readers
-
-
+
+
{{ reader.label }} ({{ getReaderStatus(reader) }})
-
-
+
+ Unavailable readers
+
+
+ {{ reader.label }} ({{ getReaderStatus(reader) }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ taxRate.display_name }} ({{ taxRate.percentage }}% VAT)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Delete Payment Intent
+
+
+
+
+
+
-
-
-
-
-
-
-
- {{ taxRate.display_name }} ({{ taxRate.percentage }}% VAT)
-
-
-
-
-
-
-
-
-
-
-
-
-
- Delete Payment Intent
-
-
-
-
-
-
-
-
+
diff --git a/src/components/displays/department/pos/steps/PosDepartmentStep2.vue b/src/components/displays/department/pos/steps/PosDepartmentStep2.vue
index 32e34f87..a6617969 100644
--- a/src/components/displays/department/pos/steps/PosDepartmentStep2.vue
+++ b/src/components/displays/department/pos/steps/PosDepartmentStep2.vue
@@ -33,8 +33,8 @@ setProductsCategory(null);
-
+
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 @@
@@ -39,12 +41,7 @@ const { t } = useI18n();
-
-
+
+
diff --git a/src/components/displays/department/pos/steps/PosDepartmentStep4.vue b/src/components/displays/department/pos/steps/PosDepartmentStep4.vue
index c3bf6a75..474cc794 100644
--- a/src/components/displays/department/pos/steps/PosDepartmentStep4.vue
+++ b/src/components/displays/department/pos/steps/PosDepartmentStep4.vue
@@ -32,15 +32,15 @@ const { t } = useI18n();
+
-
diff --git a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue
index ca4d5d3f..7271e7fa 100644
--- a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue
+++ b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue
@@ -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();
diff --git a/src/components/displays/selectors/SubuserGrantPermissionNodes.vue b/src/components/displays/selectors/SubuserGrantPermissionNodes.vue
index d8bc4ee6..52d445ac 100644
--- a/src/components/displays/selectors/SubuserGrantPermissionNodes.vue
+++ b/src/components/displays/selectors/SubuserGrantPermissionNodes.vue
@@ -104,6 +104,7 @@ onMounted(async () => {
togglePermission(node.key, !!checked)"
@input="(checked) => togglePermission(node.key, !!checked)"
>
diff --git a/src/components/shop/POSDepartmentProcess.vue b/src/components/shop/POSDepartmentProcess.vue
index c40c4321..380a78ac 100644
--- a/src/components/shop/POSDepartmentProcess.vue
+++ b/src/components/shop/POSDepartmentProcess.vue
@@ -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,
},
{
diff --git a/tests/e2e/pos-flow.spec.js b/tests/e2e/pos-flow.spec.js
index 684197d1..d4b602da 100644
--- a/tests/e2e/pos-flow.spec.js
+++ b/tests/e2e/pos-flow.spec.js
@@ -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) => {
diff --git a/tests/e2e/pos-mobile-order-flow.spec.js b/tests/e2e/pos-mobile-order-flow.spec.js
index 68bf3529..5613a18f 100644
--- a/tests/e2e/pos-mobile-order-flow.spec.js
+++ b/tests/e2e/pos-mobile-order-flow.spec.js
@@ -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: [],
diff --git a/tests/e2e/pos.visual.spec.js b/tests/e2e/pos.visual.spec.js
index 47fbcf21..0b4725e6 100644
--- a/tests/e2e/pos.visual.spec.js
+++ b/tests/e2e/pos.visual.spec.js
@@ -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");
diff --git a/tests/e2e/pos.visual.spec.js-snapshots/pos-step-3-desktop-chromium-desktop-win32.png b/tests/e2e/pos.visual.spec.js-snapshots/pos-step-3-desktop-chromium-desktop-win32.png
index 43841c79..9818341e 100644
Binary files a/tests/e2e/pos.visual.spec.js-snapshots/pos-step-3-desktop-chromium-desktop-win32.png and b/tests/e2e/pos.visual.spec.js-snapshots/pos-step-3-desktop-chromium-desktop-win32.png differ
diff --git a/tests/e2e/pos.visual.spec.js-snapshots/pos-step-4-desktop-chromium-desktop-win32.png b/tests/e2e/pos.visual.spec.js-snapshots/pos-step-4-desktop-chromium-desktop-win32.png
index 43841c79..269d8285 100644
Binary files a/tests/e2e/pos.visual.spec.js-snapshots/pos-step-4-desktop-chromium-desktop-win32.png and b/tests/e2e/pos.visual.spec.js-snapshots/pos-step-4-desktop-chromium-desktop-win32.png differ
diff --git a/tests/e2e/subuser-management.spec.ts b/tests/e2e/subuser-management.spec.ts
index eff33c6d..b6e64b6a 100644
--- a/tests/e2e/subuser-management.spec.ts
+++ b/tests/e2e/subuser-management.spec.ts
@@ -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();
diff --git a/tests/e2e/support/mobilePos.js b/tests/e2e/support/mobilePos.js
index aafb580c..55fb27c6 100644
--- a/tests/e2e/support/mobilePos.js
+++ b/tests/e2e/support/mobilePos.js
@@ -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] = [];
diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js
index d6b6e754..57632c54 100644
--- a/tests/e2e/support/network.js
+++ b/tests/e2e/support/network.js
@@ -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;
}