import { expect, test } from "@playwright/test"; import { CARD_CUSTOMER_ID, DEFAULT_ORDER_ID, REGULAR_CUSTOMER_ID, createMobilePosFixture, createPaymentIntent, getStoredPosSnapshot, setupMobilePosPage, suppressVueDevtoolsOverlay, waitForMobileNextStepCooldown, } from "./support/mobilePos.js"; test.describe("POS mobile card payments", () => { test.beforeEach(async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "Mobile card payment suite is scoped to chromium-mobile."); await suppressVueDevtoolsOverlay(page); }); test("card mode entry sets customer 999 and preserves order context", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-card-entry-token", seedState: { customerId: null, reg: "ZZ00000", reference: "CARD-CTX-REF", includePrimaryItem: false, vehicleType: 53, }, route: { step: 1, }, }); await expect(page.getByTestId("pos-mobile-next-step")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-direct-card-payment")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-direct-card-payment").click(); await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 }); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return snapshot?.metadata?.customerId ?? null; }, { timeout: 10_000 } ) .toBe(CARD_CUSTOMER_ID); const snapshot = await getStoredPosSnapshot(page); expect(snapshot?.vehicles?.vehicle_1?.reg).toBe("ZZ00000"); expect(snapshot?.metadata?.reference).toBe("CARD-CTX-REF"); }); test("@smoke direct card flow bridges step 1 to capture and resets to scanner", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-card-bridge-token", seedState: { customerId: null, reg: "CD99999", reference: "CARD-BRIDGE-REF", includePrimaryItem: false, vehicleType: 53, }, route: { step: 1, }, }); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-direct-card-payment")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-direct-card-payment").click(); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await waitForMobileNextStepCooldown(page); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1); await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-create-intent").click(); await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-capture-intent").click(); await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); await expect .poll( () => { const currentUrl = new URL(page.url()); return currentUrl.searchParams.get("step"); }, { timeout: 12_000 } ) .toBe("1"); await expect(page.getByTestId("pos-mobile-next-step")).toBeVisible({ timeout: 10_000 }); }); test("step 2 transitions to mobile card payment step 3 for customer 999", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-card-step-transition-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, primaryItemId: 53, }, route: { step: 2, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1); await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 }); await expect(page).toHaveURL(/step=3/); expect(fixture.requestCounters.markAsCompleted).toBe(0); }); test("reader unavailable state is recoverable and non-fatal", async ({ page }) => { const fixture = createMobilePosFixture({ readers: [], }); await setupMobilePosPage(page, fixture, { token: "mobile-card-reader-unavailable-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, }, route: { step: 3, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-stripe-no-readers")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-stripe-reader-unavailable-message")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-stripe-create-intent")).toHaveCount(0); await expect(page.getByTestId("pos-stripe-error-message")).toHaveCount(0); fixture.readers = [ { id: "reader_online_2", label: "Recovered Reader", status: "online", action: null, }, ]; await page.getByTestId("pos-stripe-no-readers").click(); await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-stripe-create-intent")).toBeEnabled({ timeout: 10_000 }); await expect(page.getByTestId("pos-stripe-selected-reader")).toContainText("Recovered Reader"); expect(fixture.requestCounters.paymentIntentCreate).toBe(0); }); test("reader available creates intent once and reaches capture state", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-card-create-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, }, route: { step: 3, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("pos-stripe-create-intent")).toBeEnabled(); await page.getByTestId("pos-stripe-create-intent").click(); await expect.poll(() => fixture.requestCounters.paymentIntentCreate, { timeout: 10_000 }).toBe(1); await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 }); expect(fixture.requestCounters.markAsCompleted).toBe(0); }); test("step 3 back offers resume or cancel when payment is active", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-card-cancel-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, }, route: { step: 3, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-create-intent").click(); await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-step-3-back").click(); await expect(page.locator(".swal2-popup")).toBeVisible({ timeout: 10_000 }); await page.locator(".swal2-confirm").click(); await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 }); expect(fixture.requestCounters.paymentIntentDelete).toBe(0); await page.getByTestId("pos-mobile-step-3-back").click(); await expect(page.locator(".swal2-popup")).toBeVisible({ timeout: 10_000 }); await page.locator(".swal2-deny").click(); await expect.poll(() => fixture.requestCounters.paymentIntentDelete, { timeout: 10_000 }).toBe(1); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await expect(page).toHaveURL(/step=2/); expect(fixture.requestCounters.markAsCompleted).toBe(0); }); test("capture success completes once and resets scanner flow", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-card-capture-success-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, }, route: { step: 3, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-create-intent").click(); await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-capture-intent").click(); await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); await expect .poll( () => { const currentUrl = new URL(page.url()); return currentUrl.searchParams.get("step"); }, { timeout: 12_000 } ) .toBe("1"); await expect(page.getByTestId("pos-mobile-next-step")).toBeVisible({ timeout: 10_000 }); }); test("guardrail: completion never runs before capture success", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-card-guardrail-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, }, route: { step: 3, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-create-intent").click(); await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 }); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 2_500 }).toBe(0); }); test("step 3 bootstrap recovery fetches existing requires_capture intent", async ({ page }) => { const fixture = createMobilePosFixture({ paymentIntentsByOrderId: { [DEFAULT_ORDER_ID]: createPaymentIntent(DEFAULT_ORDER_ID, { status: "requires_capture", }), }, }); await setupMobilePosPage(page, fixture, { token: "mobile-card-recovery-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, }, route: { step: 3, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 }); await expect.poll(() => fixture.requestCounters.paymentIntentGet, { timeout: 10_000 }).toBeGreaterThan(0); await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 }); expect(fixture.requestCounters.paymentIntentCreate).toBe(0); }); test("step 3 bootstrap recovery completes already succeeded intent once", async ({ page }) => { const fixture = createMobilePosFixture({ paymentIntentsByOrderId: { [DEFAULT_ORDER_ID]: createPaymentIntent(DEFAULT_ORDER_ID, { status: "succeeded", }), }, }); await setupMobilePosPage(page, fixture, { token: "mobile-card-succeeded-recovery-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, }, route: { step: 3, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 }); await expect.poll(() => fixture.requestCounters.paymentIntentGet, { timeout: 10_000 }).toBeGreaterThan(0); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); expect(fixture.requestCounters.paymentIntentCreate).toBe(0); expect(fixture.requestCounters.paymentIntentCapture).toBe(0); await expect .poll( () => { const currentUrl = new URL(page.url()); return currentUrl.searchParams.get("step"); }, { timeout: 12_000 } ) .toBe("1"); }); test("returning to step 2 and re-entering step 3 does not duplicate order items", async ({ page }) => { const fixture = createMobilePosFixture(); await setupMobilePosPage(page, fixture, { token: "mobile-card-step-reentry-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, }, route: { step: 2, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1); await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-step-3-back").click(); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await page.waitForTimeout(2100); await page.getByTestId("pos-mobile-next-step").click(); await expect(page.getByTestId("pos-mobile-step-3")).toBeVisible({ timeout: 10_000 }); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1); await expect.poll(() => fixture.requestCounters.orderItemsGet, { timeout: 10_000 }).toBeGreaterThan(0); }); test("payment-intent get/create/capture failures surface retryable error states", async ({ page }) => { const fixture = createMobilePosFixture({ failureBudget: { paymentIntentGet: 0, paymentIntentCreate: 2, paymentIntentCapture: 2, }, }); await setupMobilePosPage(page, fixture, { token: "mobile-card-error-recovery-token", seedState: { customerId: CARD_CUSTOMER_ID, includePrimaryItem: true, }, route: { step: 3, orderId: DEFAULT_ORDER_ID, customerId: CARD_CUSTOMER_ID, }, }); await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-create-intent").click(); await expect.poll(() => fixture.requestCounters.paymentIntentCreate, { timeout: 10_000 }).toBe(2); await expect(page.getByTestId("pos-stripe-error")).toBeVisible({ timeout: 10_000 }); fixture.failureBudget.paymentIntentGet = 2; await page.getByTestId("pos-stripe-error").click(); await expect(page.getByTestId("pos-stripe-error")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-error").click(); await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-create-intent").click(); await expect.poll(() => fixture.requestCounters.paymentIntentCreate, { timeout: 10_000 }).toBe(3); await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-capture-intent").click(); await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(2); await expect(page.getByTestId("pos-stripe-error")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-error").click(); await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-stripe-capture-intent").click(); await expect.poll(() => fixture.requestCounters.paymentIntentCapture, { timeout: 10_000 }).toBe(3); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); }); test("regression: non-card mobile customer still follows step-2 complete/reset path", async ({ page }) => { const regularCustomerId = REGULAR_CUSTOMER_ID; const fixture = createMobilePosFixture({ ordersById: { [DEFAULT_ORDER_ID]: { id: DEFAULT_ORDER_ID, customer_id: regularCustomerId, department_id: 1, reference: "REF-9201", notes: "", reg_1: "AB12345", reg_2: "", reg_3: "", invoice_collection_id: null, booking_id: null, completed_at: null, created_at: "2026-01-01T10:00:00.000Z", }, }, }); await setupMobilePosPage(page, fixture, { token: "mobile-non-card-regression-token", seedState: { customerId: regularCustomerId, includePrimaryItem: true, }, route: { step: 2, orderId: DEFAULT_ORDER_ID, customerId: regularCustomerId, }, }); await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("pos-mobile-next-step").click(); await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1); await expect .poll( () => { const currentUrl = new URL(page.url()); return currentUrl.searchParams.get("step"); }, { timeout: 12_000 } ) .toBe("1"); expect(fixture.requestCounters.markAsCompleted).toBe(0); await expect(page.getByTestId("pos-mobile-step-3")).toHaveCount(0); }); });