## Summary - remove hosted email-payment/payment-link creation from POS and order management - automatically capture authorized card payments instead of exposing a manual capture action - replace provider-specific Stripe wording with card-terminal terminology - retain ordinary terminal payment, receipts, order completion, and navigation - add focused unit and Playwright regression coverage Paired API change: https://github.com/copenhagentruckwash/api/pull/327 ## Verification - focused frontend unit tests: 8/8 passed - desktop card-payment Playwright: 4/4 passed - mobile card-payment Playwright: 15/15 passed - admin order action rail Playwright: 3/3 passed - ESLint, translation generation/checks, build, and diff checks passed - all 18 visual states passed their relevant DOM assertions ## Visual change previews ### View: Regular POS card-payment view **Description:** The hosted email-payment action is removed and provider-specific wording becomes generic card-terminal wording. #### Mobile **Before:**  **After:**  #### Tablet **Before:**  **After:**  #### Desktop **Before:**  **After:**  ### View: Authorized payment capture **Description:** The manual capture action is replaced by an automatically initiated capture and its in-progress state. #### Mobile **Before:**  **After:**  #### Tablet **Before:**  **After:**  #### Desktop **Before:**  **After:**  ### View: Order-dashboard action rail **Description:** The hosted Stripe invoice/payment-link action is removed while normal order actions remain. #### Mobile **Before:**  **After:**  #### Tablet **Before:**  **After:**  #### Desktop **Before:**  **After:**  ## Residual risk No live Stripe Terminal hardware or production Stripe account was used. Browser behavior is verified with repository-owned mocks; the API suite verifies route and payment-intent lifecycle wiring.
434 lines
17 KiB
JavaScript
434 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)"
|
|
);
|
|
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 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);
|
|
});
|
|
});
|