Files
pleno-vue/tests/e2e/pos-desktop-card-payments.spec.js
T
Jeppe BandJeppe Bundgaard 1da6fbd1c4 Remove card payment moms selector (#232)
## Summary
- Removes the visible POS card-payment moms/tax selector from mobile and
desktop Stripe terminal payment flows.
- Keeps Stripe terminal payment intents at 25% moms by default and
updates the POS desktop E2E assertion for the removed selector.

## Testing
- `npx playwright test tests/e2e/pos-desktop-card-payments.spec.js
--project=chromium-desktop --grep "idle state renders grouped terminal
statuses"`
- Visual preview capture on `origin/master`:
`POS_CARD_PAYMENT_PREVIEW_DIR=.../before PLAYWRIGHT_WORKERS=1 npx
playwright test tests/e2e/pos-card-payment-visual-preview.spec.js
--project=chromium-desktop`
- Visual preview capture on this branch:
`POS_CARD_PAYMENT_PREVIEW_DIR=output/visual-previews/after
PLAYWRIGHT_WORKERS=1 npx playwright test
tests/e2e/pos-card-payment-visual-preview.spec.js
--project=chromium-desktop`

## Visual change previews

### View: POS card payment
**Description:** The POS card-payment step no longer shows the moms
selector. The terminal selector and card-payment action remain
available; 25% moms is still reflected in the payment summary where
shown.

#### Mobile (390x844)
**Before:** ![Before mobile POS card
payment](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/de780d857cddcc6f61f728662e21e3a6b73fa791/visual-previews/pos-card-payment-vat/before-mobile-390x844.png)

**After:** ![After mobile POS card
payment](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/de780d857cddcc6f61f728662e21e3a6b73fa791/visual-previews/pos-card-payment-vat/after-mobile-390x844.png)

#### Tablet (768x1024)
**Before:** ![Before tablet POS card
payment](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/de780d857cddcc6f61f728662e21e3a6b73fa791/visual-previews/pos-card-payment-vat/before-tablet-768x1024.png)

**After:** ![After tablet POS card
payment](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/de780d857cddcc6f61f728662e21e3a6b73fa791/visual-previews/pos-card-payment-vat/after-tablet-768x1024.png)

#### Desktop (1440x900)
**Before:** ![Before desktop POS card
payment](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/de780d857cddcc6f61f728662e21e3a6b73fa791/visual-previews/pos-card-payment-vat/before-desktop-1440x900.png)

**After:** ![After desktop POS card
payment](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/de780d857cddcc6f61f728662e21e3a6b73fa791/visual-previews/pos-card-payment-vat/after-desktop-1440x900.png)

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-28 22:31:26 +02:00

424 lines
17 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { createPosFixture, mockApi, primeMockSession } from "./support/network.js";
const ORDER_ID = 54518;
const DEPARTMENT_ID = 12;
const CARD_CUSTOMER_ID = 999;
const CARD_CUSTOMER_NAME = "Card Terminal Customer";
const CARD_ORDER_REFERENCE = "CARD-REF-54518";
const DESKTOP_POS_PERMISSIONS = [
"admin",
`department_access_${DEPARTMENT_ID}`,
"delete_order",
"edit_order",
"edit_order_items",
"get_user",
"list_customer_notes",
"list_customer_attributes",
"get_custom_prices_other",
];
const SUPERUSER_POS_PERMISSIONS = [...DESKTOP_POS_PERMISSIONS, "superuser"];
const SETUP_REQUIRED_MESSAGE =
"Card payments are not ready for this department. Open Stripe setup and choose a terminal location.";
function suppressVueDevtoolsOverlay(page) {
return page.addInitScript(() => {
window.__TW_POS_STRIPE_EMAIL_POLLING_INTERVAL_MS__ = 250;
const injectStyle = () => {
const root = document.documentElement;
if (!root) {
return;
}
const style = document.createElement("style");
style.textContent =
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
root.appendChild(style);
};
if (document.documentElement) {
injectStyle();
} else {
document.addEventListener("DOMContentLoaded", injectStyle, { once: true });
}
});
}
function createCardOrderFixture(overrides = {}) {
return createPosFixture({
ordersById: {
[ORDER_ID]: {
id: ORDER_ID,
customer_id: CARD_CUSTOMER_ID,
department_id: DEPARTMENT_ID,
reference: "CARD-REF-54518",
po: "",
safety_seal: "",
notes: "Paid at terminal",
reg_1: "ZZ99999",
reg_2: "",
reg_3: "",
invoice_collection_id: 200,
booking_id: null,
completed_at: null,
closed_at: null,
created_at: "2026-04-08 08:44:07",
},
},
...overrides,
});
}
function createStoredPaymentIntent(orderId, overrides = {}) {
const status = overrides.status || "requires_capture";
const amount = Number(overrides.amount || 124800);
const isCaptured = status === "succeeded";
return {
id: overrides.id || `pi_${orderId}`,
amount,
amount_capturable: isCaptured ? 0 : amount,
amount_received: isCaptured ? amount : 0,
currency: overrides.currency || "dkk",
status,
metadata: {
order_id: String(orderId),
reader_id: String(overrides.readerId || "reader_online_1"),
tax_percentage: String(overrides.taxPercentage || 25),
...(overrides.metadata || {}),
},
};
}
async function primeSession(page, token = "desktop-card-token") {
await primeMockSession(page, { token });
}
async function bootDesktopCardPayment(page, fixture, permissions = DESKTOP_POS_PERMISSIONS) {
await mockApi(page, {
authenticated: true,
permissions,
edgeGateways: false,
pos: fixture,
});
await primeSession(page, `desktop-card-${Date.now()}`);
await page.goto(`/admin/${DEPARTMENT_ID}/modules/pos?id=${ORDER_ID}&customer_id=${CARD_CUSTOMER_ID}&step=3`);
const stepThree = page.getByTestId("pos-step-3");
await expect(stepThree).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-panel-cart")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-metadata-grid")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-rail")).toBeVisible({ timeout: 10_000 });
await expect(
stepThree
.locator(
'[data-testid="pos-stripe-setup-required"], [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()
).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-customer-name")).toContainText(CARD_CUSTOMER_NAME, { timeout: 10_000 });
await expect(stepThree.getByTestId("pos-customer-card")).toContainText(CARD_CUSTOMER_NAME, { timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-customer-wishes-reference")).toContainText(CARD_ORDER_REFERENCE, {
timeout: 10_000,
});
return stepThree;
}
test.describe("POS desktop card payments", () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only POS card payment coverage");
await suppressVueDevtoolsOverlay(page);
});
test("misconfigured terminal location shows guided blocking state with setup CTA for superusers", async ({
page,
}) => {
const fixture = createCardOrderFixture({
stripeReadersError: {
status: 409,
code: "stripe_terminal_setup_required",
message: SETUP_REQUIRED_MESSAGE,
},
});
const stepThree = await bootDesktopCardPayment(page, fixture, SUPERUSER_POS_PERMISSIONS);
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toContainText(SETUP_REQUIRED_MESSAGE);
await expect(stepThree.getByTestId("pos-stripe-open-setup")).toHaveAttribute(
"href",
`/superuser/departments/${DEPARTMENT_ID}/stripe/setup`
);
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
});
test("misconfigured terminal location falls back to contact-superuser guidance for department operators", async ({
page,
}) => {
const fixture = createCardOrderFixture({
stripeReadersError: {
status: 409,
code: "stripe_terminal_setup_required",
message: SETUP_REQUIRED_MESSAGE,
},
});
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-contact-superuser")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-open-setup")).toHaveCount(0);
});
test("setup-required desktop flow does not expose the email payment-link action", async ({ page }) => {
const fixture = createCardOrderFixture({
stripeReadersError: {
status: 409,
code: "stripe_terminal_setup_required",
message: SETUP_REQUIRED_MESSAGE,
},
});
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-email-action")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-email-input")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toBeVisible();
});
test("configured department with zero readers keeps the unavailable-reader state", async ({ page }) => {
const fixture = createCardOrderFixture({
readers: [],
});
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(stepThree.getByTestId("pos-stripe-no-readers")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
});
test("reloading step 3 keeps the customer summary hydrated", async ({ page }) => {
const fixture = createCardOrderFixture();
await bootDesktopCardPayment(page, fixture);
await page.reload();
const stepThree = page.getByTestId("pos-step-3");
await expect(stepThree).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-customer-name")).toContainText(CARD_CUSTOMER_NAME, {
timeout: 10_000,
});
await expect(stepThree.getByTestId("pos-customer-card")).toContainText(CARD_CUSTOMER_NAME, {
timeout: 10_000,
});
await expect(stepThree.getByTestId("pos-order-customer-wishes-reference")).toContainText(CARD_ORDER_REFERENCE, {
timeout: 10_000,
});
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
});
test("step 2 customer wishes stay visible after moving to card payment step 3", async ({ page }) => {
const stepTwoReference = "CARD-STEP-2-REF";
const stepTwoPo = "PO-CARD-STEP-2";
const fixture = createCardOrderFixture({
ordersById: {
[ORDER_ID]: {
...createCardOrderFixture().ordersById[ORDER_ID],
reference: "",
po: "",
},
},
});
await mockApi(page, {
authenticated: true,
permissions: DESKTOP_POS_PERMISSIONS,
edgeGateways: false,
pos: fixture,
});
await primeSession(page, `desktop-card-step2-${Date.now()}`);
await page.goto(`/admin/${DEPARTMENT_ID}/modules/pos?id=${ORDER_ID}&customer_id=${CARD_CUSTOMER_ID}&step=2`);
const stepTwo = page.getByTestId("pos-step-2");
await expect(stepTwo).toBeVisible({ timeout: 10_000 });
await expect(stepTwo.getByTestId("pos-order-metadata-grid")).toBeVisible({ timeout: 10_000 });
await stepTwo.getByTestId("pos-order-customer-wishes-reference").click();
await stepTwo.getByTestId("pos-order-customer-wishes-reference-input").fill(stepTwoReference);
await stepTwo.getByTestId("pos-order-customer-wishes-po").click();
await stepTwo.getByTestId("pos-order-customer-wishes-po-input").fill(stepTwoPo);
await stepTwo.getByTestId("pos-next-step").click();
const stepThree = page.getByTestId("pos-step-3");
await expect(stepThree).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-customer-wishes-reference")).toContainText(stepTwoReference, {
timeout: 10_000,
});
await expect(stepThree.getByTestId("pos-order-customer-wishes-po")).toContainText(stepTwoPo, {
timeout: 10_000,
});
await expect.poll(() => fixture.ordersById[ORDER_ID].reference, { timeout: 10_000 }).toBe(stepTwoReference);
await expect.poll(() => fixture.ordersById[ORDER_ID].po, { timeout: 10_000 }).toBe(stepTwoPo);
await page.reload();
const reloadedStepThree = page.getByTestId("pos-step-3");
await expect(reloadedStepThree).toBeVisible({ timeout: 10_000 });
await expect(reloadedStepThree.getByTestId("pos-order-customer-wishes-reference")).toContainText(stepTwoReference, {
timeout: 10_000,
});
await expect(reloadedStepThree.getByTestId("pos-order-customer-wishes-po")).toContainText(stepTwoPo, {
timeout: 10_000,
});
});
test("idle state renders grouped terminal statuses and only keeps ready terminals selectable", async ({ page }) => {
const fixture = createCardOrderFixture({
readers: [
{
id: "reader_ready",
label: "Aabenraa",
status: "online",
action: null,
},
{
id: "reader_in_use",
label: "Busy reader",
status: "online",
action: { type: "process_payment_intent" },
},
{
id: "reader_offline",
label: "Offline reader",
status: "offline",
action: null,
},
],
});
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toContainText("Kortbetaling");
const desktopLayout = stepThree.getByTestId("pos-stripe-desktop-layout");
await expect(desktopLayout).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-terminal-trigger-label")).toContainText("Aabenraa");
await expect(stepThree.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveAttribute("data-status-key", "ready");
await expect(stepThree.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveCSS(
"background-color",
"rgb(22, 163, 74)"
);
await expect(stepThree.getByTestId("pos-stripe-tax-select")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-desktop-tax-field")).toHaveCount(0);
await expect(stepThree).not.toContainText("pos.pay_with_stripe");
const readerField = stepThree.getByTestId("pos-stripe-desktop-reader-field");
const actionField = stepThree.getByTestId("pos-stripe-desktop-action-field");
const [readerBox, actionBox] = await Promise.all([readerField.boundingBox(), actionField.boundingBox()]);
expect(readerBox).not.toBeNull();
expect(actionBox).not.toBeNull();
expect(readerBox.y).toBeLessThan(actionBox.y);
expect(Math.abs(readerBox.width - actionBox.width)).toBeLessThan(2);
expect(Math.abs(readerBox.x - actionBox.x)).toBeLessThan(2);
await stepThree.getByTestId("pos-stripe-terminal-trigger").click();
await expect(stepThree.getByTestId("pos-stripe-terminal-menu")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-terminal-group-ready")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-terminal-group-in_use")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-terminal-group-offline")).toBeVisible();
const optionOrder = await stepThree
.locator('[role="option"][data-testid^="pos-stripe-terminal-option-"]')
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(optionOrder).toEqual([
"pos-stripe-terminal-option-reader_ready",
"pos-stripe-terminal-option-reader_in_use",
"pos-stripe-terminal-option-reader_offline",
]);
await expect(stepThree.getByTestId("pos-stripe-terminal-option-reader_ready")).toHaveAttribute(
"aria-disabled",
"false"
);
await expect(stepThree.getByTestId("pos-stripe-terminal-option-reader_in_use")).toHaveAttribute(
"aria-disabled",
"true"
);
await expect(stepThree.getByTestId("pos-stripe-terminal-option-reader_offline")).toHaveAttribute(
"aria-disabled",
"true"
);
await expect(stepThree.getByTestId("pos-stripe-terminal-option-dot-reader_in_use")).toHaveCSS(
"background-color",
"rgb(245, 158, 11)"
);
await expect(stepThree.getByTestId("pos-stripe-terminal-option-dot-reader_offline")).toHaveCSS(
"background-color",
"rgb(220, 38, 38)"
);
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toBeEnabled();
});
test("existing requires_capture intent on reload auto-captures without showing the capture action", async ({
page,
}) => {
const fixture = createCardOrderFixture({
paymentIntentsByOrderId: {
[ORDER_ID]: createStoredPaymentIntent(ORDER_ID, {
status: "requires_capture",
}),
},
});
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-payment-succeeded")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
});
test("existing succeeded intent on reload restores the completion CTA", async ({ page }) => {
const fixture = createCardOrderFixture({
paymentIntentsByOrderId: {
[ORDER_ID]: createStoredPaymentIntent(ORDER_ID, {
status: "succeeded",
}),
},
});
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(stepThree.getByTestId("pos-stripe-payment-succeeded")).toBeVisible();
await expect(stepThree.getByTestId("pos-next-step")).toBeVisible();
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
});
test("happy path create auto-captures and exposes completion without auto-completing the order", async ({ page }) => {
const fixture = createCardOrderFixture();
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toBeVisible();
await stepThree.getByTestId("pos-stripe-create-intent").click();
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
await expect(stepThree.getByTestId("pos-stripe-payment-succeeded")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-next-step")).toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(750);
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
});
test("email payment-link controls are hidden in desktop card payment", async ({ page }) => {
const fixture = createCardOrderFixture();
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(stepThree.getByTestId("pos-stripe-email-action")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-email-input")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-email-status")).toHaveCount(0);
});
});