Files
pleno-vue/tests/e2e/pos-desktop-card-payments.spec.js
T
Jeppe BandJeppe Bundgaard cf1509e34f Fix POS step metadata persistence (#138)
* Fix POS step metadata persistence

* Preserve registration save payload formatting

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 10:20:13 +02:00

540 lines
21 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 || {}),
},
};
}
function createHostedInvoice(orderId, overrides = {}) {
const amountDue = Number(overrides.amount_due ?? 1562);
const paid = Boolean(overrides.paid ?? false);
return {
id: orderId,
invoice_id: overrides.invoice_id || `in_${orderId}`,
customer_id: Number(overrides.customer_id ?? CARD_CUSTOMER_ID),
url: overrides.url || `https://stripe.example.test/invoices/${orderId}`,
created_at: overrides.created_at || "2026-04-08 08:44:07",
paid,
status: overrides.status || (paid ? "paid" : "open"),
amount_due: amountDue,
amount_paid: Number(overrides.amount_paid ?? (paid ? amountDue : 0)),
};
}
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 still exposes the inline email card payment 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")).toBeVisible();
await stepThree.getByTestId("pos-stripe-email-action").click();
await expect(stepThree.getByTestId("pos-stripe-email-input")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-email-submit")).toBeVisible();
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("Betal med Stripe");
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)"
);
const taxSelect = stepThree.getByTestId("pos-stripe-tax-select");
await expect(taxSelect).toHaveValue("1");
await expect(taxSelect).not.toContainText("pos.stripe");
await expect(stepThree).not.toContainText("pos.pay_with_stripe");
const taxField = stepThree.getByTestId("pos-stripe-desktop-tax-field");
const readerField = stepThree.getByTestId("pos-stripe-desktop-reader-field");
const actionField = stepThree.getByTestId("pos-stripe-desktop-action-field");
const [taxBox, readerBox, actionBox] = await Promise.all([
taxField.boundingBox(),
readerField.boundingBox(),
actionField.boundingBox(),
]);
expect(taxBox).not.toBeNull();
expect(readerBox).not.toBeNull();
expect(actionBox).not.toBeNull();
expect(taxBox.y).toBeLessThan(readerBox.y);
expect(readerBox.y).toBeLessThan(actionBox.y);
expect(Math.abs(taxBox.width - readerBox.width)).toBeLessThan(2);
expect(Math.abs(readerBox.width - actionBox.width)).toBeLessThan(2);
expect(Math.abs(taxBox.x - readerBox.x)).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 restores 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")).toBeVisible();
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 and capture 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")).toBeVisible({ timeout: 10_000 });
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
await stepThree.getByTestId("pos-stripe-capture-intent").click();
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 card payment shows inline status and updates after polling", async ({ page }) => {
const fixture = createCardOrderFixture();
const stepThree = await bootDesktopCardPayment(page, fixture);
const paidOrderPoll = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
page.off("response", handleResponse);
reject(new Error("Timed out waiting for the paid Stripe order poll response."));
}, 10_000);
const handleResponse = async (response) => {
if (response.request().method() !== "GET") {
return;
}
const url = new URL(response.url());
if (!url.pathname.endsWith("/order")) {
return;
}
if (url.searchParams.get("id") !== String(ORDER_ID) || url.searchParams.get("include_all") !== "true") {
return;
}
const payload = await response.json().catch(() => null);
const stripeModuleOrders =
payload?.data?.includes?.stripeModuleOrders ||
payload?.includes?.stripeModuleOrders ||
payload?.data?.stripeModuleOrders ||
{};
if (String(stripeModuleOrders?.status || "") !== "paid") {
return;
}
clearTimeout(timeoutId);
page.off("response", handleResponse);
resolve(stripeModuleOrders);
};
page.on("response", handleResponse);
});
await stepThree.getByTestId("pos-stripe-email-action").click();
await stepThree.getByTestId("pos-stripe-email-input").fill("card-email@example.com");
await stepThree.getByTestId("pos-stripe-email-submit").click();
await expect(stepThree.getByTestId("pos-stripe-email-status")).toHaveText(/Afventer betaling|Awaiting payment/);
fixture.stripeModuleOrdersByOrderId[ORDER_ID] = createHostedInvoice(ORDER_ID, {
...fixture.stripeModuleOrdersByOrderId[ORDER_ID],
status: "paid",
paid: true,
amount_due: 0,
amount_paid: 1562,
});
await paidOrderPoll;
await expect(stepThree.getByTestId("pos-stripe-email-status")).toHaveText(/Betalt|Paid/, { timeout: 1_000 });
await expect(stepThree.getByTestId("pos-next-step")).toBeVisible({ timeout: 1_000 });
});
test("email card payment can be cancelled before submit and returns to the idle action", async ({ page }) => {
const fixture = createCardOrderFixture();
const stepThree = await bootDesktopCardPayment(page, fixture);
await stepThree.getByTestId("pos-stripe-email-action").click();
await expect(stepThree.getByTestId("pos-stripe-email-input")).toBeVisible();
await stepThree.getByTestId("pos-stripe-email-cancel").click();
await expect(stepThree.getByTestId("pos-stripe-email-action")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-email-input")).toHaveCount(0);
});
test("email card payment cancel clears the hosted invoice selection and restores the idle action", async ({
page,
}) => {
const fixture = createCardOrderFixture();
const stepThree = await bootDesktopCardPayment(page, fixture);
await stepThree.getByTestId("pos-stripe-email-action").click();
await stepThree.getByTestId("pos-stripe-email-input").fill("cancel-email@example.com");
await stepThree.getByTestId("pos-stripe-email-submit").click();
await expect
.poll(() => fixture.stripeModuleOrdersByOrderId[ORDER_ID]?.invoice_id || null, { timeout: 10_000 })
.not.toBeNull();
await stepThree.getByTestId("pos-stripe-email-cancel").click();
await expect
.poll(() => Object.keys(fixture.stripeModuleOrdersByOrderId[ORDER_ID] || {}).length, { timeout: 10_000 })
.toBe(0);
await expect(stepThree.getByTestId("pos-stripe-email-action")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-stripe-email-status")).toHaveCount(0);
});
});