Files
pleno-vue/tests/unit/customer-search-field-pos.spec.js
T
Jeppe B 4f26ddd2cc Remove manual Stripe payment actions (#233)
## 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:** ![POS card payment before
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-pos-card-payment-mobile.png)

**After:** ![POS card payment after
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-pos-card-payment-mobile.png)

#### Tablet

**Before:** ![POS card payment before
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-pos-card-payment-tablet.png)

**After:** ![POS card payment after
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-pos-card-payment-tablet.png)

#### Desktop

**Before:** ![POS card payment before
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-pos-card-payment-desktop.png)

**After:** ![POS card payment after
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-pos-card-payment-desktop.png)

### View: Authorized payment capture

**Description:** The manual capture action is replaced by an
automatically initiated capture and its in-progress state.

#### Mobile

**Before:** ![Payment capture before
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-payment-capture-mobile.png)

**After:** ![Payment capture after
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-payment-capture-mobile.png)

#### Tablet

**Before:** ![Payment capture before
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-payment-capture-tablet.png)

**After:** ![Payment capture after
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-payment-capture-tablet.png)

#### Desktop

**Before:** ![Payment capture before
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-payment-capture-desktop.png)

**After:** ![Payment capture after
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-payment-capture-desktop.png)

### View: Order-dashboard action rail

**Description:** The hosted Stripe invoice/payment-link action is
removed while normal order actions remain.

#### Mobile

**Before:** ![Order action rail before
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-order-dashboard-mobile.png)

**After:** ![Order action rail after
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-order-dashboard-mobile.png)

#### Tablet

**Before:** ![Order action rail before
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-order-dashboard-tablet.png)

**After:** ![Order action rail after
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-order-dashboard-tablet.png)

#### Desktop

**Before:** ![Order action rail before
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/before-order-dashboard-desktop.png)

**After:** ![Order action rail after
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6ea832ae507a81e010b599fb52bdb1b375b5431c/docs/pr-previews/stripe-cleanup/after-order-dashboard-desktop.png)

## 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.
2026-07-27 19:09:40 +02:00

328 lines
10 KiB
JavaScript

// @vitest-environment jsdom
import { nextTick, ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const posState = vi.hoisted(() => ({
customerId: null,
customerName: null,
selectCustomer: vi.fn(),
searchAndSelectCustomer: vi.fn(),
}));
const searchState = vi.hoisted(() => ({
isSearching: null,
results: null,
}));
const draftState = vi.hoisted(() => ({
draftCustomerNumber: null,
hasDraft: null,
}));
const stripeState = vi.hoisted(() => ({
isCardPaymentAvailable: null,
}));
const sessionUserState = vi.hoisted(() => ({
request: vi.fn(),
parseErrorMessage: vi.fn((error) => error?.message ?? null),
}));
vi.mock("@/components/shop/POSDepartmentProcess.vue", async () => {
posState.customerId = ref(null);
posState.customerName = ref("");
posState.selectCustomer.mockImplementation((customer) => {
posState.customerId.value = customer?.customerNumber ?? null;
posState.customerName.value = customer?.name ?? "";
});
posState.searchAndSelectCustomer.mockImplementation(async (customerNumber) => {
const selectedCustomer = {
customerNumber: Number(customerNumber),
name: `Customer ${customerNumber}`,
};
posState.selectCustomer(selectedCustomer);
return selectedCustomer;
});
return {
customer_id: posState.customerId,
customer_name: posState.customerName,
isCustomerBarred: vi.fn(() => false),
selectCustomer: posState.selectCustomer,
isCustomerSelected: () => Boolean(posState.customerId.value),
searchAndSelectCustomer: posState.searchAndSelectCustomer,
};
});
vi.mock("@/components/search/economic/customerSearch.vue", async () => {
searchState.isSearching = ref(false);
searchState.results = ref([]);
return {
isSearching: searchState.isSearching,
searchCustomerResults: searchState.results,
};
});
vi.mock("@/components/session/token/SessionUser.vue", () => {
const sessionUser = {
request: sessionUserState.request,
functions: {
parseErrorMessage: sessionUserState.parseErrorMessage,
},
objects: {
global: {
language: {
customer: "Kunde",
clear: "Clear",
},
},
},
};
return {
SessionUser: sessionUser,
default: sessionUser,
};
});
vi.mock("@/composables/useDraftTransactionCustomer.js", () => ({
useDraftTransactionCustomer: () => ({
draftTransactionCustomerNumber: draftState.draftCustomerNumber,
hasDraftTransactionCustomer: draftState.hasDraft,
}),
}));
vi.mock("@/composables/useStripeReaderAvailability.js", () => ({
useStripeReaderAvailability: () => ({
isCardPaymentAvailable: stripeState.isCardPaymentAvailable,
}),
}));
import CustomerSearchFieldPos from "@/components/forms/department/pos/input/customerSearchFieldPos.vue";
const CustomerSearchFieldStub = {
name: "CustomerSearchField",
template: '<input v-bind="$attrs" />',
};
async function flushComponentUpdates() {
await Promise.resolve();
await nextTick();
}
function mountComponent(props = {}) {
return mountWithApp(CustomerSearchFieldPos, {
props,
messages: {
en: {
pos: {
customer_picker: {
selected_customer: "Selected customer",
select_customer_invoice: "Customer invoice",
select_new_customer: "New customer",
select_draft_customer: "Defer",
select_card_payment: "Payment card",
},
},
admin: {
pos: {
company_phone: "Company phone",
invoice_email: "Invoice email",
contact_email: "Contact email",
contact_phone: "Contact phone",
ean: "EAN",
add_customer: "Add customer",
customer_creation_error: "Error: {error}",
customer_creation_error_generic: "Unable to create customer",
not_found: "Not found",
},
},
global: {
search_cvr: "Search CVR",
},
common: {
cancel: "Cancel",
},
},
},
global: {
stubs: {
CustomerSearchField: CustomerSearchFieldStub,
"customer-search-field": CustomerSearchFieldStub,
},
},
});
}
describe("CustomerSearchFieldPos", () => {
beforeEach(() => {
vi.useFakeTimers();
posState.customerId.value = null;
posState.customerName.value = "";
posState.selectCustomer.mockClear();
posState.searchAndSelectCustomer.mockClear();
searchState.isSearching.value = false;
searchState.results.value = [];
draftState.draftCustomerNumber = ref(6001);
draftState.hasDraft = ref(true);
stripeState.isCardPaymentAvailable = ref(true);
sessionUserState.request.mockReset();
sessionUserState.parseErrorMessage.mockClear();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("selects the card-payment customer without exposing Stripe wording", async () => {
const onCustomerSelected = vi.fn();
const wrapper = mountComponent({
showCardPaymentButton: true,
onCustomerSelected,
});
await wrapper.get('[data-testid="pos-card-payment-inline-action"]').trigger("click");
await nextTick();
expect(posState.selectCustomer).toHaveBeenCalledWith({
customerNumber: 999,
name: "Kortbetaling",
});
expect(onCustomerSelected).toHaveBeenCalledWith({
customerNumber: 999,
name: "Kortbetaling",
});
expect(wrapper.text()).not.toContain("Stripe");
});
it("shows the inline Ny kunde form and hides manual search while the mode is active", async () => {
const wrapper = mountComponent();
expect(wrapper.find("#pos_select_customer_input").exists()).toBe(true);
await wrapper.get('[data-testid="pos-new-customer-inline-action"]').trigger("click");
await nextTick();
expect(wrapper.find("#pos_select_customer_input").exists()).toBe(false);
expect(wrapper.find('[data-testid="pos-desktop-add-customer-inline-form"]').exists()).toBe(true);
});
it("resets the inline add-customer form when the operator cancels out of Ny kunde mode", async () => {
sessionUserState.request.mockImplementation(async (path) => {
if (path === "/cvr/search") {
return {
data: {
data: {
vat: 41004355,
name: "Truckwash ApS",
phone: "21754690",
email: "mikkel@truckwash.dk",
},
},
};
}
throw new Error(`Unexpected request: ${path}`);
});
const wrapper = mountComponent();
await wrapper.get('[data-testid="pos-new-customer-inline-action"]').trigger("click");
await wrapper.get('[data-testid="pos-desktop-add-customer-search-input"]').setValue("41004355");
await vi.advanceTimersByTimeAsync(300);
await flushComponentUpdates();
expect(wrapper.get('[data-testid="pos-desktop-add-customer-company-phone"]').element.value).toBe("21754690");
await wrapper.get('[data-testid="pos-desktop-add-customer-cancel"]').trigger("click");
await nextTick();
expect(wrapper.find('[data-testid="pos-desktop-add-customer-inline-form"]').exists()).toBe(false);
expect(wrapper.find("#pos_select_customer_input").exists()).toBe(true);
await wrapper.get('[data-testid="pos-new-customer-inline-action"]').trigger("click");
await nextTick();
expect(wrapper.get('[data-testid="pos-desktop-add-customer-search-input"]').element.value).toBe("");
expect(wrapper.get('[data-testid="pos-desktop-add-customer-company-phone"]').element.value).toBe("");
});
it("submits a new customer inline and emits the selected customer after creation", async () => {
sessionUserState.request.mockImplementation(async (path, method, payload) => {
if (path === "/cvr/search") {
return {
data: {
data: {
vat: 43423010,
name: "Wash Group ApS",
phone: "42331128",
email: "billing@wash-group.test",
},
},
};
}
if (path === "/auth/register/cvr") {
expect(method).toBe("POST");
expect(payload).toMatchObject({
cvr: "43423010",
companyPhone: 55550000,
invoiceEmail: "billing@wash-group.test",
contactEmail: "dispatch@wash-group.test",
contactPhone: 42331128,
ean: "5790001234567",
});
return {
data: {
data: {
success: true,
},
},
};
}
throw new Error(`Unexpected request: ${path}`);
});
posState.searchAndSelectCustomer.mockImplementation(async (customerNumber) => {
const selectedCustomer = {
customerNumber: Number(customerNumber),
name: "Wash Group ApS",
};
posState.selectCustomer(selectedCustomer);
return selectedCustomer;
});
const onCustomerSelected = vi.fn();
const wrapper = mountComponent({ onCustomerSelected });
await wrapper.get('[data-testid="pos-new-customer-inline-action"]').trigger("click");
await wrapper.get('[data-testid="pos-desktop-add-customer-search-input"]').setValue("43423010");
await vi.advanceTimersByTimeAsync(300);
await flushComponentUpdates();
await wrapper.get('[data-testid="pos-desktop-add-customer-company-phone"]').setValue("55550000");
await wrapper.get('[data-testid="pos-desktop-add-customer-contact-email"]').setValue("dispatch@wash-group.test");
await wrapper.get('[data-testid="pos-desktop-add-customer-ean"]').setValue("5790001234567");
await wrapper.get('[data-testid="pos-desktop-add-customer-submit"]').trigger("click");
await flushComponentUpdates();
expect(posState.searchAndSelectCustomer).toHaveBeenCalledWith("55550000");
expect(onCustomerSelected).toHaveBeenCalledWith(
expect.objectContaining({
customerNumber: 55550000,
name: "Wash Group ApS",
})
);
expect(wrapper.find('[data-testid="pos-desktop-add-customer-inline-form"]').exists()).toBe(false);
expect(wrapper.get('button[data-testid="pos-customer-invoice-inline-action"]').classes()).toContain("is-selected");
expect(wrapper.get("input[disabled]").element.value).toBe("Wash Group ApS");
});
});