Files
pleno-vue/tests/e2e/invoicing-period.smoke.spec.js
T
Jeppe Bundgaard 2108dd6c94 Send Selvvask import period dates
Include selected invoice-period dates when importing XLVask usage from the Selvvask period view.
2026-07-06 23:49:36 +02:00

2802 lines
98 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isCompactProject } from "./support/projects";
const periodRouteReadyTimeout = process.env.CI ? 30_000 : 15_000;
function json(body, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
function matchesApiPath(urlString, expectedPath) {
const url = new URL(urlString);
return url.pathname === expectedPath || url.pathname === `/api${expectedPath}`;
}
async function suppressVueDevtoolsOverlay(page) {
await page.addInitScript(() => {
const STYLE_ID = "__e2e-hide-vue-devtools";
localStorage.setItem("lastVersionCheck", String(Date.now()));
const apply = () => {
const target = document.head || document.documentElement;
if (!target) {
return;
}
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent =
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
target.appendChild(style);
}
const container = document.getElementById("__vue-devtools-container__");
if (container) {
container.style.display = "none";
container.style.pointerEvents = "none";
}
};
apply();
const startObserving = () => {
if (!document.documentElement) {
requestAnimationFrame(startObserving);
return;
}
const observer = new MutationObserver(apply);
observer.observe(document.documentElement, { childList: true, subtree: true });
};
startObserving();
});
}
function createPeriodPayload() {
return {
types: {
all: [
{
id: 11,
customer_number: 4001,
customer_name: "Acme Fleet",
requires_action: true,
transactions: [{ id: 9001, date: "2026-03-10T10:00:00.000Z", amount: 120, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {
fixed_pricing: {
price: 500,
},
},
},
{
id: 12,
customer_number: 4002,
customer_name: "Nordic Transport",
requires_action: false,
transactions: [{ id: 9002, date: "2026-03-11T10:00:00.000Z", amount: 80, booked: true, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
invoice_per_order: [
{
id: 13,
customer_number: 7001,
customer_name: "Invoice Per Order Co",
requires_action: true,
transactions: [{ id: 9003, date: "2026-03-12T10:00:00.000Z", amount: 75, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
fixed_pricing: [
{
id: 11,
customer_number: 4001,
customer_name: "Acme Fleet",
requires_action: true,
transactions: [{ id: 9005, date: "2026-03-31T10:00:00.000Z", amount: 1000, booked: true, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {
fixed_pricing: {
price: 1000,
},
},
},
],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [
{
id: 14,
customer_number: 8001,
customer_name: "Subscription Movers",
requires_action: true,
transactions: [{ id: 9004, date: "2026-03-13T10:00:00.000Z", amount: 60, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
possible_duplicates: [],
},
};
}
function createFlaggedPeriodPayload({
resolvedManualFlagIds = [],
resolvedAutomaticFingerprints = [],
flaggedTransactionBooked = false,
flaggedCustomerRequiresAction = true,
includeHiddenFilteredFlag = false,
} = {}) {
const payload = createPeriodPayload();
payload.types.all[0].requires_action = flaggedCustomerRequiresAction;
payload.types.all[0].transactions = payload.types.all[0].transactions.map((transaction) => ({
...transaction,
booked: flaggedTransactionBooked,
date: "2026-06-09T10:00:00.000Z",
}));
const flags = [
{
id: 501,
source: "manual",
severity: "red",
status: "active",
target_type: "customer",
target_id: 4001,
customer_number: 4001,
reason: "Manual red flag for customer review.",
message: "Manual red flag for customer review.",
created_at: "2026-05-11 10:00:00",
created_by: 7,
created_by_name: "Jeppe",
},
{
id: "auto-order-reference-1",
source: "automatic",
severity: "yellow",
status: "active",
target_type: "order_field",
target_id: 9001,
field: "reference",
customer_number: 4001,
order_id: 9001,
order_item_id: null,
definition_key: "customer_rule_requires_reference",
fingerprint: "order-reference-fingerprint-1",
message_key: "invoice_period.flags.automatic.customer_rule_requires_reference",
message_params: {},
message: "Order is missing a required reference.",
context: {
department_id: 1,
order_id: 9001,
order_items: [
{ id: 7701, product_name: "Spot Free", quantity: 1, price: 99 },
{ id: 7702, product_name: "Wash", quantity: 1, price: 199 },
],
},
},
{
id: "auto-price-1",
source: "automatic",
severity: "yellow",
status: "active",
target_type: "order_item_field",
target_id: 7701,
field: "price",
customer_number: 4001,
order_id: 9001,
order_item_id: 7701,
definition_key: "price_mismatch",
fingerprint: "price-fingerprint-1",
message_key: "invoice_period.flags.automatic.price_mismatch",
message_params: {
product: "Spot Free",
},
message: "Spot Free product price differs from expected.",
message_parts: [
{ type: "order_item", text: "Spot Free" },
{ type: "text", text: " product price differs from " },
{ type: "expected_price", text: "expected" },
{ type: "text", text: "." },
],
context: {
department_id: 1,
order_id: 9001,
order_item_id: 7701,
order_items: [
{ id: 7701, product_name: "Spot Free", quantity: 1, price: 99 },
{ id: 7702, product_name: "Wash", quantity: 1, price: 199 },
],
expected_price_breakdown: {
product_price: 100,
department_price: 90,
product_discount_percentage: 10,
category_discount_percentage: 0,
applied_discount_percentage: 10,
expected_price: 81,
},
},
},
];
if (includeHiddenFilteredFlag) {
flags.push({
id: "auto-hidden-order-reference-1",
source: "automatic",
severity: "yellow",
status: "active",
target_type: "order_field",
target_id: 9006,
field: "reference",
customer_number: 4001,
order_id: 9006,
order_item_id: null,
invoice_collection_id: 16891,
definition_key: "customer_rule_requires_reference",
fingerprint: "hidden-order-reference-fingerprint-1",
message_key: "invoice_period.flags.automatic.customer_rule_requires_reference",
message_params: {},
message: "Hidden booked order is missing a required reference.",
context: {
department_id: 1,
order_id: 9006,
invoice_collection_id: 16891,
order_items: [{ id: 7716, product_name: "Hidden Wash", quantity: 1, price: 75 }],
},
});
}
const resolvedManualFlagIdSet = new Set(resolvedManualFlagIds.map((id) => String(id)));
const resolvedAutomaticFingerprintSet = new Set(
resolvedAutomaticFingerprints.map((fingerprint) => String(fingerprint))
);
const activeFlags = flags.filter((flag) => {
if (flag.source === "manual") {
return !resolvedManualFlagIdSet.has(String(flag.id));
}
return !resolvedAutomaticFingerprintSet.has(String(flag.fingerprint || flag.id));
});
const manualFlagCount = activeFlags.filter((flag) => flag.source === "manual").length;
const automaticFlagCount = activeFlags.filter((flag) => flag.source === "automatic").length;
const flaggedMetadata = {
status_indicator: manualFlagCount > 0 ? "flag_red" : automaticFlagCount > 0 ? "flag_yellow" : "circle_red",
flag_counts: {
manual: manualFlagCount,
automatic: automaticFlagCount,
total: manualFlagCount + automaticFlagCount,
},
flags: activeFlags,
};
payload.types.all[0] = {
...payload.types.all[0],
...flaggedMetadata,
};
payload.types.fixed_pricing[0] = {
...payload.types.fixed_pricing[0],
...flaggedMetadata,
};
return payload;
}
function createPossibleDuplicatesPeriodPayload({ dateFrom = "2026-05-11" } = {}) {
const duplicateDate = dateFrom || "2026-05-11";
const duplicateTransaction = (id, customerNumber, customerName, amount, hour) => ({
id,
amount,
booked: false,
excluded: false,
reg_1: customerNumber === 7201 ? "EC 21233" : "EC21233",
date: `${duplicateDate}T${hour}:00:00.000Z`,
created_at: `${duplicateDate} ${hour}:00:00`,
invoice_collection_id: id + 1000,
queue_status: null,
queue_job_id: null,
customer_number: customerNumber,
customer_name: customerName,
});
const duplicateCustomer = (id, customerNumber, customerName, transaction) => ({
id,
customer_number: customerNumber,
customer_name: customerName,
requires_action: true,
transactions: [transaction],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
return {
types: {
all: [],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [
duplicateCustomer(
71,
7201,
"Pleno Vognmandsforretning",
duplicateTransaction(9701, 7201, "Pleno Vognmandsforretning", 1523, "10")
),
duplicateCustomer(
72,
7202,
"Estland Alle ApS",
duplicateTransaction(9702, 7202, "Estland Alle ApS", 399, "11")
),
duplicateCustomer(73, 7203, "Single Wash ApS", {
...duplicateTransaction(9703, 7203, "Single Wash ApS", 50, "12"),
reg_1: "SINGLE1",
}),
],
},
};
}
function createPeriodPayloadForChangedRange() {
return {
types: {
all: [
{
id: 21,
customer_number: 5001,
customer_name: "April Logistics",
requires_action: true,
transactions: [{ id: 9101, date: "2026-04-10T10:00:00.000Z", amount: 250, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
invoice_per_order: [
{
id: 22,
customer_number: 5002,
customer_name: "April Per Order",
requires_action: true,
transactions: [{ id: 9102, date: "2026-04-11T11:00:00.000Z", amount: 150, booked: false, excluded: false }],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createQueuedPeriodPayload() {
return {
types: {
all: [
{
id: 31,
customer_number: 6001,
customer_name: "Queued Fleet",
requires_action: false,
transactions: [
{
id: 9201,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: 14578,
queue_status: "QUEUED",
queue_job_id: 88,
},
],
queue: {
has_active_job: true,
statuses: ["QUEUED"],
invoice_collection_ids: [14578],
is_action_blocked: true,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createDraftBlockedPeriodPayload() {
return {
types: {
all: [
{
id: 41,
customer_number: 6101,
customer_name: "Drafted Fleet",
requires_action: false,
transactions: [
{
id: 9301,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: 16501,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
draft: {
has_valid_draft: true,
invoice_collection_ids: [16501],
is_action_blocked: true,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createStaleDraftPeriodPayload() {
return {
types: {
all: [
{
id: 42,
customer_number: 6102,
customer_name: "Deleted Draft Fleet",
requires_action: true,
transactions: [
{
id: 9302,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: 16502,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
draft: {
has_valid_draft: false,
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createAttributeStackPeriodPayload() {
const customer = (customerNumber, customerName, transactionId, amount = 210) => ({
id: customerNumber,
customer_number: customerNumber,
customer_name: customerName,
requires_action: true,
transactions: [
{
id: transactionId,
date: "2026-03-14T10:00:00.000Z",
amount,
booked: false,
excluded: false,
invoice_collection_id: transactionId + 1000,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
return {
types: {
all: [customer(4101, "Stacked Attributes Transport", 9401), customer(4102, "Single Attribute Logistics", 9402)],
invoice_per_order: [customer(4101, "Stacked Attributes Transport", 9401)],
fixed_pricing: [
customer(4101, "Stacked Attributes Transport", 9401),
customer(4102, "Single Attribute Logistics", 9402),
],
tank_cleaning: [customer(4101, "Stacked Attributes Transport", 9401)],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createParallelInvoicePeriodPayload() {
const readyCustomer = (customerNumber, invoiceCollectionId, name) => ({
id: customerNumber,
customer_number: customerNumber,
customer_name: name,
requires_action: true,
transactions: [
{
id: invoiceCollectionId - 1000,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: invoiceCollectionId,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
return {
types: {
all: [
readyCustomer(4001, 55501, "Acme Fleet"),
readyCustomer(4003, 55503, "Baltic Freight"),
readyCustomer(4004, 55504, "Open Haulage"),
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
function createParallelInvoiceTargetedPayload(customerNumbers) {
const completedCustomer = (customerNumber, name) => ({
id: customerNumber,
customer_number: customerNumber,
customer_name: name,
requires_action: false,
transactions: [
{
id: customerNumber + 100,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: true,
excluded: false,
invoice_collection_id: null,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
const customersByNumber = {
4001: completedCustomer(4001, "Acme Fleet"),
4003: completedCustomer(4003, "Baltic Freight"),
};
return {
types: {
all: customerNumbers.map((customerNumber) => customersByNumber[customerNumber]).filter(Boolean),
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
};
}
async function setupPeriodEndpoints(page, requests, options = {}) {
let initialRange = null;
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (!matchesApiPath(route.request().url(), "/superuser/invoicing/period")) {
await route.fallback();
return;
}
const dateFrom = url.searchParams.get("dateFrom");
const dateTo = url.searchParams.get("dateTo");
const isWarmRequest = url.searchParams.get("periodWarm") === "1";
if (!isWarmRequest) {
requests.push({
dateFrom,
dateTo,
});
}
if (!initialRange && !isWarmRequest) {
initialRange = { dateFrom, dateTo };
}
const didChangeRange = !initialRange || dateFrom !== initialRange.dateFrom || dateTo !== initialRange.dateTo;
const isDistributionFixtureRange =
dateTo === "2026-03-31" && ["2026-02-28", "2026-03-01", "2026-03-02", "2026-03-03"].includes(dateFrom);
let payload;
if (options.payloadFactory) {
payload = await options.payloadFactory({
dateFrom,
dateTo,
didChangeRange,
isDistributionFixtureRange,
url,
});
} else if (didChangeRange && !isDistributionFixtureRange) {
payload = createPeriodPayloadForChangedRange();
} else {
payload = createPeriodPayload();
}
await route.fulfill(
json(
payload?.__rawResponse ?? {
data: payload,
}
)
);
});
await page.route("**/superuser/invoicing/period/distribution/fixed-pricing**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
includes: {
collective_fixed_pricing_results: {
total_fixed_price: 1000,
total_original_price: 400,
total_department_totals: {
1: 220,
2: 180,
},
total_department_totals_parsed: {
Copenhagen: 220,
Odense: 180,
},
total_department_totals_relative_parsed: {
Copenhagen: 600,
Odense: 400,
},
},
},
})
);
});
await page.route("**/superuser/invoicing/period/distribution/v2/booked-department-75**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
data: {
customers: [
{
customer_number: 4001,
customer_name: "Acme Fleet",
meta: {
booked_department_75: {
booked_net_amount: 1000,
distributed_net_amount: 1000,
undistributed_net_amount: 0,
department_distribution: {
1: 700,
2: 300,
},
booked_groups: [
{
month: "2026-03",
source_category: "fixed_pricing",
booked_net_amount: 1000,
department_distribution: {
1: 700,
2: 300,
},
undistributed_net_amount: 0,
},
{
month: "2026-03",
source_category: "wash_subscriptions",
booked_net_amount: 600,
department_distribution: {
1: 500,
2: 100,
},
undistributed_net_amount: 0,
},
],
},
},
},
],
collective_results: {
booked_net_amount: 1600,
distributed_net_amount: 1600,
undistributed_net_amount: 0,
department_distribution: {
1: 1200,
2: 400,
},
},
warnings: [],
},
})
);
});
await page.route("**/superuser/invoicing/period/distribution/wash-subscriptions**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
includes: {
collective_subscription_results: {
total_subscription_price: 300,
subscription_price_department_distribution: {
1: 200,
2: 100,
},
subscription_price_department_distribution_parsed: {
Copenhagen: 200,
Odense: 100,
},
},
},
})
);
});
await page.route("**/departments**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (!(url.pathname === "/departments" || url.pathname === "/api/departments")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{ id: 1, name: "Copenhagen" },
{ id: 2, name: "Odense" },
],
})
);
});
}
async function openPeriodView(page, options = {}) {
const periodRequests = [];
const token = "superuser-period-e2e-token";
await suppressVueDevtoolsOverlay(page);
await seedAuthenticatedState(page, token);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
loginToken: token,
invoiceDistribution: true,
});
await setupPeriodEndpoints(page, periodRequests, options);
await page.goto("/superuser/invoices?activeTab=period", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
timeout: periodRouteReadyTimeout,
});
return { periodRequests };
}
async function setEntireMarchPeriod(page) {
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
await expect(dateInputs.first()).toBeVisible();
await dateInputs.nth(0).fill("2026-03-01");
await dateInputs.nth(0).dispatchEvent("change");
await dateInputs.nth(1).fill("2026-03-31");
await dateInputs.nth(1).dispatchEvent("change");
await expect(page.getByTestId("invoicing-period-view-selector-fixed_pricing")).toBeVisible();
}
async function selectPeriodView(page, viewName) {
await page.getByTestId(`invoicing-period-view-selector-${viewName}`).click();
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", viewName);
}
async function seedInvoicesPage(page, token = "superuser-period-e2e-token") {
await suppressVueDevtoolsOverlay(page);
await seedAuthenticatedState(page, token);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
loginToken: token,
invoiceDistribution: true,
});
}
async function routeFlaggedPeriodOrders(
page,
{ invoiceCollectionId = null, includeHiddenCollectionOrder = false } = {}
) {
await page.route("**/orders**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/orders")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{
id: 9001,
customer_id: 4001,
customer_name: "Acme Fleet",
cashier_id: 7,
cashier_name: "Copenhagen",
department_id: 1,
reference: "",
notes: "",
po: "",
reg_1: "AC4001",
reg_2: "",
reg_3: "",
created_at: "2026-03-10 10:00:00",
total_net_amount: 120,
invoice_collection_id: invoiceCollectionId,
invoice_collection: invoiceCollectionId
? {
id: invoiceCollectionId,
closed_at: "2026-03-10 10:30:00",
booked_invoice_id: 28368,
}
: null,
economic_invoice_module: null,
stripe_invoice_module: null,
error_message: null,
completed_at: null,
pending_handheld: false,
user_id: 11,
},
],
meta: {
pagination: {
limit: 20,
total: 1,
},
},
})
);
});
if (includeHiddenCollectionOrder && invoiceCollectionId) {
await page.route("**/collected-invoices**", async (route) => {
const url = new URL(route.request().url());
if (
route.request().method() !== "GET" ||
!matchesApiPath(route.request().url(), "/collected-invoices") ||
url.searchParams.get("id") !== String(invoiceCollectionId)
) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: {
id: invoiceCollectionId,
orders: [
{ id: 9001, invoice_collection_id: invoiceCollectionId },
{ id: 9006, invoice_collection_id: invoiceCollectionId },
],
},
})
);
});
}
}
async function routeFlaggedPeriodOrderItems(page) {
await page.route("**/order/items**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/order/items")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{
id: 7701,
order_id: 9001,
product_id: 101,
reference: "",
notes: "",
price: 99,
quantity: 1,
related_item_id: 0,
include_in_invoice: true,
product: {
id: 101,
name: "Spot Free",
price: 100,
},
},
{
id: 7702,
order_id: 9001,
product_id: 102,
reference: "",
notes: "",
price: 199,
quantity: 1,
related_item_id: 0,
include_in_invoice: true,
product: {
id: 102,
name: "Wash",
price: 199,
},
},
],
})
);
});
}
async function getBoundingBox(locator, label) {
await expect(locator).toBeVisible();
const box = await locator.boundingBox();
if (!box) {
throw new Error(`${label} did not produce a bounding box`);
}
return box;
}
function boxesOverlap(firstBox, secondBox, tolerance = 1) {
const horizontalOverlap =
Math.max(firstBox.x, secondBox.x) <
Math.min(firstBox.x + firstBox.width, secondBox.x + secondBox.width) - tolerance;
const verticalOverlap =
Math.max(firstBox.y, secondBox.y) <
Math.min(firstBox.y + firstBox.height, secondBox.y + secondBox.height) - tolerance;
return horizontalOverlap && verticalOverlap;
}
function expectBoxInside(innerBox, outerBox, label, tolerance = 1) {
expect(innerBox.x, `${label} left edge`).toBeGreaterThanOrEqual(outerBox.x - tolerance);
expect(innerBox.y, `${label} top edge`).toBeGreaterThanOrEqual(outerBox.y - tolerance);
expect(innerBox.x + innerBox.width, `${label} right edge`).toBeLessThanOrEqual(
outerBox.x + outerBox.width + tolerance
);
expect(innerBox.y + innerBox.height, `${label} bottom edge`).toBeLessThanOrEqual(
outerBox.y + outerBox.height + tolerance
);
}
test.describe("Invoicing period tab", () => {
test("@smoke @pr period view does not throw queue refresh errors on load", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
await openPeriodView(page);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible();
await page.waitForTimeout(250);
expect(pageErrors).not.toEqual(expect.arrayContaining([expect.stringContaining("reading 'value'")]));
});
test("@smoke period view loads selectors and displays all-customer list", async ({ page }) => {
const { periodRequests } = await openPeriodView(page);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-invoice_per_order")).toBeVisible();
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", "all");
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
expect(periodRequests.length).toBeGreaterThan(0);
});
test("@smoke period view opens Selvvask import and attaching view", async ({ page }) => {
const usageOrderRequests = [];
const fastLinkRequests = [];
const automationAcceptRequests = [];
const importUsageRequests = [];
let automation = {
id: 7101,
status: "suggested",
action: "attach_order",
confidence: 0.93,
source: "fuzzy",
reason: "Samme primære produkt og relaterede tillæg matcher en ordre fra samme dag. Ordre #7001.",
matched_order_id: 7001,
created_order_id: null,
candidate_order: {
id: 7001,
department_id: 1,
created_at: "2026-03-10 12:03:00",
},
proposed_order: null,
can_accept: true,
can_deny: true,
};
await openPeriodView(page);
await page.route("**/modules/xlvask/tasks/import-usage**", async (route) => {
const url = new URL(route.request().url());
importUsageRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
await route.fulfill(json({ data: "Usage logs imported" }));
});
await page.route("**/modules/xlvask/services/usage/orders**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (
route.request().method() === "POST" &&
path.endsWith("/modules/xlvask/services/usage/orders/8101/automation/accept")
) {
automationAcceptRequests.push(JSON.parse(route.request().postData() || "{}"));
automation = {
...automation,
status: "accepted",
can_accept: false,
can_deny: false,
};
await route.fulfill(json({ data: automation }));
return;
}
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
if (matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders/fast-link")) {
fastLinkRequests.push(route.request().url());
await route.fulfill(
json({
data: {
order_items: [
{
id: 9101,
product_id: 301,
product: { name: "Kassevogn/varevogn" },
quantity: 1,
price: 125,
},
],
potential_duplicates: [],
},
})
);
return;
}
if (!matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders")) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
usageOrderRequests.push({
filters: url.searchParams.get("filters") || "",
limit: url.searchParams.get("limit") || "",
});
await route.fulfill(
json({
data: [
{
id: 8101,
reg_1: "AB12345",
created_at: "2026-03-10 12:00:00",
customer_id: 4001,
customer_number: 4001,
customer_name: "DEKRA AMU Center Hovedstaden A/S",
department_id: 1,
lane: 1,
wash_id: "wash-selvvask-1",
duplicates: [],
fast_link_key: "temporary_cache_selfwash8101",
total_net_amount: 125,
xlvask_primary_product_name: "Kassevogn/varevogn",
automation,
},
{
id: 8102,
reg_1: "CD67890",
created_at: "2026-03-10 12:15:00",
customer_id: 4002,
customer_number: 4002,
customer_name: "Self Wash Transport",
department_id: 2,
lane: 2,
wash_id: "wash-selvvask-2",
duplicates: [],
fast_link_key: null,
total_net_amount: 88,
xlvask_primary_product_name: "Varebil",
automation: {
id: 7102,
status: "failed",
action: "attach_order",
error: "No safe match",
can_accept: false,
can_deny: false,
},
},
],
meta: {
pagination: {
page: 1,
per_page: 10000,
total: 2,
},
},
})
);
});
await setEntireMarchPeriod(page);
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (0/2)");
await expect(page.getByTestId("invoicing-period-view-selector-progress-self_wash")).toBeVisible();
await page.getByTestId("invoicing-period-view-selector-self_wash").click();
await expect(page.getByTestId("invoicing-period-self-wash-view")).toBeVisible();
const selfWashView = page.getByTestId("invoicing-period-self-wash-view");
await expect(selfWashView.locator("input[type='date']")).toHaveCount(0);
await expect(selfWashView.locator("input[placeholder='Søg i transaktioner']")).toHaveCount(0);
await expect(selfWashView.getByText("Per side")).toHaveCount(0);
await expect(selfWashView.getByText("Rækkefølge")).toHaveCount(0);
await expect(selfWashView.getByText("Dato fra")).toHaveCount(0);
await expect(selfWashView.getByText("Dato til")).toHaveCount(0);
await expect(selfWashView.getByText("Vis kun ikke tilknyttede vaske")).toHaveCount(0);
await expect(page.getByRole("heading", { name: /Selvvask/ })).toBeVisible();
await expect(page.getByText("AB12345")).toBeVisible();
await expect(page.getByText("CD67890")).toBeVisible();
await expect(selfWashView.locator(".xlvask-usage-price").first()).toContainText("125");
await expect(selfWashView.getByText("Kunne ikke behandles")).toHaveCount(0);
expect(fastLinkRequests).toHaveLength(0);
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Foreslået: Tilknyt ordre #7001");
await expect(selfWashView.locator(".xlvask-usage-card-header").first()).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
.toBeLessThanOrEqual(1);
await page.getByText("AB12345").click();
await expect(page.getByTestId("xlvask-automation-accept-8101")).toBeVisible();
await page.getByTestId("xlvask-automation-accept-8101").click();
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Accepteret #7001");
expect(automationAcceptRequests).toEqual([{ suggestion_id: 7101 }]);
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (1/2)");
await expect.poll(() => usageOrderRequests.length).toBeGreaterThan(0);
expect(usageOrderRequests.some((request) => request.limit === "10000")).toBe(true);
expect(
usageOrderRequests.some(
(request) =>
request.filters.includes("StartTime-date_from:2026-03-01") &&
request.filters.includes("StartTime-date_to:2026-03-31")
)
).toBe(true);
await selfWashView.getByRole("button", { name: /import/i }).click();
await expect.poll(() => importUsageRequests.length).toBe(1);
expect(importUsageRequests[0]).toEqual({
dateFrom: "2026-03-01",
dateTo: "2026-03-31",
});
});
test("@smoke period view shows flags and saves automatic flag decisions", async ({ page }) => {
const automaticStatusRequests = [];
const manualStatusRequests = [];
const resolvedManualFlagIds = new Set();
const resolvedAutomaticFingerprints = new Set();
await openPeriodView(page, {
payloadFactory: () =>
createFlaggedPeriodPayload({
resolvedManualFlagIds: Array.from(resolvedManualFlagIds),
resolvedAutomaticFingerprints: Array.from(resolvedAutomaticFingerprints),
flaggedTransactionBooked: true,
flaggedCustomerRequiresAction: false,
}),
});
await page.route("**/*", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() === "PATCH" && /\/superuser\/invoicing\/period\/flags\/\d+\/status$/.test(path)) {
manualStatusRequests.push({
path,
payload: JSON.parse(request.postData() || "{}"),
});
const flagId = Number(path.match(/\/flags\/(\d+)\/status$/)?.[1] || 0);
if (flagId > 0) {
resolvedManualFlagIds.add(flagId);
}
await route.fulfill(
json({
data: {
id: 501,
source: "manual",
status: "resolved",
},
})
);
return;
}
if (request.method() !== "POST" || !path.endsWith("/superuser/invoicing/period/flags/automatic/status")) {
await route.fallback();
return;
}
const payload = JSON.parse(request.postData() || "{}");
automaticStatusRequests.push(payload);
if (payload.fingerprint) {
resolvedAutomaticFingerprints.add(String(payload.fingerprint));
}
await route.fulfill(
json({
data: {
id: 9001,
source: "automatic",
status: "ignored",
},
})
);
});
await routeFlaggedPeriodOrders(page);
await routeFlaggedPeriodOrderItems(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
const customerRow = page.getByTestId("invoicing-period-customer-4001");
await expect(customerRow.locator(".color-indicator i.fa-flag").first()).toHaveClass(/has-text-danger/);
await expect(customerRow.getByText("Manual red flag for customer review.")).toBeVisible();
await expect(customerRow.getByText(/Alle bogført|All booked/i)).toHaveCount(0);
await expect(customerRow.getByText(/Spot Free/)).toHaveCount(0);
const manualFlag = page.getByTestId("invoice-period-flag-501");
await manualFlag.locator(".invoice-period-flag-row__icon").hover();
const manualFlagTooltip = page.getByTestId("invoice-period-flag-created-tooltip-501");
await expect(manualFlagTooltip).toContainText("Oprettet");
await expect(manualFlagTooltip).toContainText("Af");
await expect(manualFlagTooltip).toContainText("Jeppe");
await manualFlag.hover();
await manualFlag.getByTitle(/Resolved|Løst/i).click();
await page.locator(".swal2-textarea").fill("Handled manually");
await page.locator(".swal2-confirm").click();
await expect(manualFlag).toHaveCount(0);
await expect(customerRow.locator(".color-indicator i.fa-flag").first()).toHaveClass(/has-text-warning/);
await customerRow.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
const orderFlag = page.getByTestId("invoice-period-flag-auto-order-reference-1");
await expect(orderFlag).toBeVisible();
await expect(orderFlag).toContainText("Ordren mangler en påkrævet reference.");
const orderFlagToken = orderFlag.locator(".invoice-period-flag-token", { hasText: /^Ordre$|^Order$/i });
await expect(orderFlagToken).toBeVisible();
await orderFlagToken.hover();
await expect(page.getByText("Ordrelinjer").last()).toBeVisible();
await expect(page.getByText("Spot Free").last()).toBeVisible();
const orderPopupPromise = page.waitForEvent("popup");
await orderFlagToken.click();
const orderPopup = await orderPopupPromise;
await expect(orderPopup).toHaveURL(/\/admin\/1\/modules\/pos\/orders\/9001/);
await orderPopup.close();
await orderFlag.hover();
await orderFlag.getByTitle(/Ignored|Ignoreret/i).click();
await page.locator(".swal2-textarea").fill("Accepted reference warning");
await page.locator(".swal2-confirm").click();
await expect(orderFlag).toHaveCount(0);
if ((await page.getByTestId("invoicing-period-customer-expanded-4001").count()) === 0) {
await customerRow.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
}
const flaggedOrderRow = page
.getByTestId("pos-order-expand-9001")
.locator("xpath=ancestor::tr | ancestor::div[contains(@class, 'pos-orders-mobile-card')]");
await expect(flaggedOrderRow.locator(".color-indicator i.fa-flag").first()).toHaveClass(/has-text-warning/);
await page.getByTestId("pos-order-expand-9001").click();
const orderItemFlagIndicator = page.getByTestId("order-content-item-flag-indicator-7701").first();
await expect(orderItemFlagIndicator).toBeVisible();
await expect(orderItemFlagIndicator).toHaveClass(/has-text-warning/);
await expect(orderItemFlagIndicator.locator("i")).toHaveClass(/fa-flag/);
const automaticFlag = page.getByTestId("invoice-period-flag-auto-price-1");
await expect(automaticFlag).toBeVisible();
await automaticFlag.hover();
await automaticFlag.getByTitle(/Ignored|Ignoreret/i).click();
await page.locator(".swal2-textarea").fill("Accepted for this period");
await page.locator(".swal2-confirm").click();
await expect(automaticFlag).toHaveCount(0);
await expect(page.getByTestId("order-content-item-flag-indicator-7701")).toHaveCount(0);
await expect(customerRow.locator(".color-indicator i.fa-flag")).toHaveCount(0);
await expect(customerRow.locator(".color-indicator i.fa-circle").first()).toHaveClass(/has-text-success/);
await expect
.poll(() => manualStatusRequests.map((request) => request.payload))
.toEqual([
{
status: "resolved",
reason: "Handled manually",
},
]);
await expect
.poll(() => automaticStatusRequests)
.toEqual([
{
fingerprint: "order-reference-fingerprint-1",
status: "ignored",
target_type: "order_field",
target_id: 9001,
field: "reference",
definition_key: "customer_rule_requires_reference",
reason: "Accepted reference warning",
},
{
fingerprint: "price-fingerprint-1",
status: "ignored",
target_type: "order_item_field",
target_id: 7701,
field: "price",
definition_key: "price_mismatch",
reason: "Accepted for this period",
},
]);
});
test("@smoke period expanded booked collection shows hidden flag reasons", async ({ page }) => {
await openPeriodView(page, {
payloadFactory: () =>
createFlaggedPeriodPayload({
resolvedManualFlagIds: [501],
resolvedAutomaticFingerprints: ["order-reference-fingerprint-1", "price-fingerprint-1"],
flaggedTransactionBooked: true,
flaggedCustomerRequiresAction: false,
includeHiddenFilteredFlag: true,
}),
});
await routeFlaggedPeriodOrders(page, {
invoiceCollectionId: 16891,
includeHiddenCollectionOrder: true,
});
await routeFlaggedPeriodOrderItems(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
const customerRow = page.getByTestId("invoicing-period-customer-4001");
await expect(customerRow.locator(".color-indicator i.fa-flag").first()).toHaveClass(/has-text-warning/);
await expect(customerRow.getByText(/Alle bogført|All booked/i)).toHaveCount(0);
await customerRow.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
await expect(page.getByTestId("pos-order-invoice-collection-summary-16891")).toContainText(
/Faktura samling ID: 16891|Invoice collection ID: 16891/i
);
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toContainText(
/Vis(?:er)? 1 vaske|Showing 1 wash/i
);
});
test("@smoke period customer unfolding is single-open and action wheel shows active state", async ({ page }) => {
await page.setViewportSize({ width: 1900, height: 900 });
await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
const firstCustomer = page.getByTestId("invoicing-period-customer-4001");
const secondCustomer = page.getByTestId("invoicing-period-customer-4002");
await firstCustomer.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-expanded-4002")).toHaveCount(0);
await secondCustomer.getByText("Nordic Transport").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toHaveCount(0);
await expect(page.getByTestId("invoicing-period-customer-expanded-4002")).toBeVisible();
const firstWheel = firstCustomer.locator(".action-settings-wheel-trigger").first();
const secondWheel = secondCustomer.locator(".action-settings-wheel-trigger").first();
await firstWheel.click();
await expect(firstWheel).toHaveClass(/action-settings-wheel-trigger--active/);
await expect(firstWheel).toHaveAttribute("aria-expanded", "true");
const sectionIds = await firstWheel
.locator("xpath=ancestor::div[contains(@class, 'dropdown')]")
.getByTestId("action-settings-wheel-sections")
.locator("[data-testid^='action-settings-wheel-section-']")
.evaluateAll((sections) => sections.map((section) => section.getAttribute("data-testid")));
expect(sectionIds.at(-1)).toBe("action-settings-wheel-section-invoice-period-flags");
const isFlatLayout = (await page.getByTestId("action-settings-wheel-flyout").count()) === 0;
if (isFlatLayout) {
const customerFlagAction = firstCustomer.locator("button.dropdown-item-action").filter({ hasText: /^Kunde$/ });
await expect(customerFlagAction).toBeVisible();
} else {
await firstCustomer.getByTestId("action-settings-wheel-section-invoice-period-flags").hover();
const flagSubmenu = page.getByTestId("action-settings-wheel-submenu-invoice-period-flags");
const customerFlagAction = flagSubmenu.locator("button.dropdown-item-action", { hasText: "Kunde" });
await expect(customerFlagAction).toBeVisible();
await expect(flagSubmenu).not.toContainText(/Dette m.*l/);
}
await page.keyboard.press("Escape");
await expect(firstWheel).not.toHaveClass(/action-settings-wheel-trigger--active/);
await expect(firstWheel).toHaveAttribute("aria-expanded", "false");
await secondWheel.click();
await expect(firstWheel).not.toHaveClass(/action-settings-wheel-trigger--active/);
await expect(firstWheel).toHaveAttribute("aria-expanded", "false");
await expect(secondWheel).toHaveClass(/action-settings-wheel-trigger--active/);
await expect(secondWheel).toHaveAttribute("aria-expanded", "true");
});
test("@smoke period expanded order filters are hidden until requested", async ({ page }) => {
await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
const firstCustomer = page.getByTestId("invoicing-period-customer-4001");
await expect(firstCustomer).toBeVisible();
await firstCustomer.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
const filterToggle = page.getByTestId("invoice-orders-toggle-filters");
await expect(filterToggle).toBeVisible();
await expect(filterToggle).toHaveAttribute("aria-expanded", "false");
await expect(page.getByTestId("table-labeled-pagination-filters")).toHaveCount(0);
await filterToggle.click();
await expect(filterToggle).toHaveAttribute("aria-expanded", "true");
await expect(page.getByTestId("table-labeled-pagination-filters")).toBeVisible();
await filterToggle.click();
await expect(filterToggle).toHaveAttribute("aria-expanded", "false");
await expect(page.getByTestId("table-labeled-pagination-filters")).toHaveCount(0);
});
test("@smoke period possible duplicates are grouped by license plate and date", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
await page.clock.setFixedTime(new Date("2026-05-11T10:00:00.000Z"));
await openPeriodView(page, { payloadFactory: createPossibleDuplicatesPeriodPayload });
await page.getByTestId("invoicing-period-view-selector-possible_duplicates").click({
position: { x: 12, y: 12 },
});
await page.waitForTimeout(100);
expect(pageErrors).toEqual([]);
await expect(page.getByTestId("invoicing-period-view-selector-possible_duplicates")).toHaveClass(
/is-selected-view/
);
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute(
"data-current-view",
"possible_duplicates"
);
const duplicateSelector = page.getByTestId("invoicing-period-view-selector-possible_duplicates");
await expect(duplicateSelector).toContainText(/Mulige|Possible/i);
await expect(duplicateSelector).toContainText("(0/1)");
const duplicateGroup = page.getByTestId("invoicing-period-duplicate-group-EC21233-2026-05-11");
await expect(duplicateGroup).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-7201")).toHaveCount(0);
await expect(page.getByTestId("invoicing-period-customer-7202")).toHaveCount(0);
await expect(duplicateGroup).toContainText("EC21233");
await expect(duplicateGroup).toContainText("2 kunder");
await expect(duplicateGroup).toContainText("2 vaskelog");
await expect(duplicateGroup).not.toContainText("EC21233 - 11/05/2026");
await expect(duplicateGroup).toContainText("Pleno Vognmandsforretning #7201");
await expect(duplicateGroup).toContainText("Estland Alle ApS #7202");
await expect(page.getByTestId("invoicing-period-duplicate-group-SINGLE1-2026-05-11")).toHaveCount(0);
const plateTag = page.getByTestId("invoicing-period-duplicate-plate-tag-EC21233-2026-05-11");
const dateTag = page.getByTestId("invoicing-period-duplicate-date-tag-EC21233-2026-05-11");
const plateBox = await plateTag.boundingBox();
const dateBox = await dateTag.boundingBox();
expect(plateBox).not.toBeNull();
expect(dateBox).not.toBeNull();
expect(Math.abs((plateBox?.x ?? 0) - (dateBox?.x ?? 0))).toBeLessThan(4);
expect(dateBox?.y ?? 0).toBeGreaterThan(plateBox?.y ?? 0);
await duplicateGroup.click();
const expandedGroup = page.getByTestId("invoicing-period-duplicate-group-expanded-EC21233-2026-05-11");
await expect(expandedGroup.locator("[data-testid='pagination-search-input']")).toHaveCount(0);
await expect(expandedGroup.locator("[data-testid='pagination-reload-actions']")).toHaveCount(0);
await expect(expandedGroup.locator("nav.pagination")).toHaveCount(0);
await expect(expandedGroup.getByText(/Side\s+\d+\s+af/i)).toHaveCount(0);
const comparison = page.getByTestId("invoicing-period-duplicate-comparison-EC21233-2026-05-11");
await expect(comparison).toBeVisible();
await expect(comparison.getByTestId("invoicing-period-duplicate-comparison-row-9701")).toContainText(
"Pleno Vognmandsforretning #7201"
);
await expect(comparison.getByTestId("invoicing-period-duplicate-comparison-row-9702")).toContainText(
"Estland Alle ApS #7202"
);
await expect(comparison.getByText("EC21233")).toHaveCount(2);
});
test("@smoke period customer attribute tags stack vertically without resizing cards", async ({ page }) => {
await openPeriodView(page, { payloadFactory: createAttributeStackPeriodPayload });
await page.getByTestId("invoicing-period-view-selector-all").click();
const multiAttributeCard = page.getByTestId("invoicing-period-customer-4101");
const singleAttributeCard = page.getByTestId("invoicing-period-customer-4102");
const attributeStack = page.getByTestId("invoicing-period-customer-attributes-4101");
const tagLocators = ["invoice_per_order", "fixed_pricing", "tank_cleaning"].map((viewKey) =>
page.getByTestId(`invoicing-period-customer-attribute-4101-${viewKey}`)
);
const multiCardBox = await getBoundingBox(multiAttributeCard, "multi-attribute customer card");
const singleCardBox = await getBoundingBox(singleAttributeCard, "single-attribute customer card");
const stackBox = await getBoundingBox(attributeStack, "customer attribute stack");
const tagBoxes = [];
for (const [index, tagLocator] of tagLocators.entries()) {
const tagBox = await getBoundingBox(tagLocator, `customer attribute tag ${index + 1}`);
const fontSize = await tagLocator.evaluate((element) => {
return Number.parseFloat(window.getComputedStyle(element).fontSize || "0");
});
tagBoxes.push(tagBox);
expectBoxInside(tagBox, stackBox, `customer attribute tag ${index + 1}`);
expect(fontSize, `customer attribute tag ${index + 1} font size`).toBeGreaterThanOrEqual(10);
}
expect(Math.abs(multiCardBox.height - singleCardBox.height)).toBeLessThanOrEqual(3);
tagBoxes.slice(1).forEach((tagBox, index) => {
expect(Math.abs(tagBox.x - tagBoxes[0].x)).toBeLessThanOrEqual(1);
expect(tagBox.y).toBeGreaterThan(tagBoxes[index].y);
});
const controlLocators = [
multiAttributeCard.getByText(/1\s+(Transaktion|Transaction|order|orders|ordre|Vaskelog)/i).first(),
multiAttributeCard.getByText(/210/).first(),
page.getByTestId("invoicing-period-customer-invoice-4101"),
multiAttributeCard.locator(".action-settings-wheel-trigger").first(),
];
for (const [index, controlLocator] of controlLocators.entries()) {
const controlBox = await getBoundingBox(controlLocator, `customer row control ${index + 1}`);
expect(boxesOverlap(stackBox, controlBox), `attribute stack overlaps row control ${index + 1}`).toBe(false);
}
});
test("@smoke period view selector switch updates visible customer set", async ({ page }) => {
await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-invoice_per_order").click();
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute(
"data-current-view",
"invoice_per_order"
);
await expect(page.getByTestId("invoicing-period-customer-7001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4001")).toHaveCount(0);
await expect(page).toHaveURL(/activeTab=period/);
});
test("@smoke period distribution calculations handle department 75 payloads in browser", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const module = await import(
"/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/invoicingBillingPeriodDistributionCalculations.js"
);
const subscriptionDistribution = {
total_subscription_price: 300,
subscription_price_department_distribution: {
1: 200,
2: 100,
},
};
const bookedDepartment75Distribution = {
customers: [
{
meta: {
booked_department_75: {
booked_groups: [
{
source_category: "fixed_pricing",
booked_net_amount: 1000,
department_distribution: {
1: 700,
2: 300,
},
},
{
source_category: "wash_subscriptions",
booked_net_amount: 600,
department_distribution: {
1: 500,
2: 100,
},
},
],
},
},
},
],
};
return {
subscriptionBookedCopenhagen: module.getDepartmentRelativeBookedAmount(
{ id: 1, name: "Copenhagen" },
subscriptionDistribution,
600
),
fixedPricingBookedCopenhagen: module.getBookedDepartment75SourceDepartmentAmount(
{ id: 1, name: "Copenhagen" },
bookedDepartment75Distribution,
"fixed_pricing"
),
washSubscriptionBookedOdense: module.getBookedDepartment75SourceDepartmentAmount(
{ id: 2, name: "Odense" },
bookedDepartment75Distribution,
"wash_subscriptions"
),
};
});
expect(result).toEqual({
subscriptionBookedCopenhagen: 400,
fixedPricingBookedCopenhagen: 700,
washSubscriptionBookedOdense: 100,
});
});
test("@smoke period all view shows combined department 75 distribution", async ({ page }) => {
await openPeriodView(page);
await setEntireMarchPeriod(page);
await selectPeriodView(page, "all");
const combinedTable = page.getByTestId("department-75-combined-distribution-table");
await expect(combinedTable).toBeVisible();
const combinedTableContainer = page.getByTestId("department-75-combined-distribution-table-container");
await expect(combinedTableContainer).toBeVisible();
const copenhagenCells = combinedTable.locator("tr", { hasText: "Copenhagen" }).locator("td");
const odenseCells = combinedTable.locator("tr", { hasText: "Odense" }).locator("td");
const totalCells = combinedTable.locator("tr", { hasText: "Total" }).locator("td");
await expect(copenhagenCells.nth(1)).toContainText("220");
await expect(copenhagenCells.nth(2)).toContainText("600");
await expect(copenhagenCells.nth(3)).toContainText("200");
await expect(copenhagenCells.nth(4)).toContainText("800");
await expect(copenhagenCells.nth(5)).toContainText("700");
await expect(copenhagenCells.nth(6)).toContainText("500");
await expect(copenhagenCells.nth(7)).toContainText("1.200");
await expect(odenseCells.nth(5)).toContainText("300");
await expect(odenseCells.nth(6)).toContainText("100");
await expect(odenseCells.nth(7)).toContainText("400");
await expect(totalCells.nth(4)).toContainText("1.300");
await expect(totalCells.nth(7)).toContainText("1.600");
const isDesktop = page.viewportSize().width >= 1024;
if (isDesktop) {
await expect
.poll(() =>
page.evaluate(() => {
const tableContainer = document.querySelector(
'[data-testid="department-75-combined-distribution-table-container"]'
);
const selector = document.querySelector('[data-testid="invoicing-period-view-selector-all"]');
if (!tableContainer || !selector) {
return false;
}
const tableContainerRect = tableContainer.getBoundingClientRect();
const selectorRect = selector.getBoundingClientRect();
return (
tableContainer.scrollWidth - tableContainer.clientWidth <= 2 &&
tableContainerRect.right <= selectorRect.left - 8
);
})
)
.toBe(true);
}
});
test("@smoke period subscription distribution uses e-conomic booked department 75 amounts", async ({ page }) => {
await openPeriodView(page);
await setEntireMarchPeriod(page);
await selectPeriodView(page, "vehicle_subscriptions");
const subscriptionTable = page.locator("table").filter({ hasText: "Abonnementspris" });
await expect(subscriptionTable).toBeVisible();
await expect(subscriptionTable.locator("tr", { hasText: "Copenhagen" }).locator("td").nth(3)).toContainText("500");
await expect(subscriptionTable.locator("tr", { hasText: "Odense" }).locator("td").nth(3)).toContainText("100");
});
test("@smoke period fixed pricing uses e-conomic booked department 75 amounts", async ({ page }) => {
await openPeriodView(page);
await setEntireMarchPeriod(page);
await selectPeriodView(page, "fixed_pricing");
const fixedPricingTable = page.locator("table").filter({ hasText: "Original pris" });
await expect(fixedPricingTable).toBeVisible();
const copenhagenBookedCell = fixedPricingTable.locator("tr", { hasText: "Copenhagen" }).locator("td").nth(5);
const odenseBookedCell = fixedPricingTable.locator("tr", { hasText: "Odense" }).locator("td").nth(5);
await expect(copenhagenBookedCell).toContainText("700");
await expect(odenseBookedCell).toContainText("300");
await expect(copenhagenBookedCell).not.toContainText("550");
await expect(odenseBookedCell).not.toContainText("450");
});
test("@smoke period view invoices required-action customers without opening collection tab", async ({ page }) => {
await openPeriodView(page);
const orderLookups = [];
const economicInvoiceRequests = [];
const popupUrls = [];
page.on("popup", (popup) => {
popupUrls.push(popup.url());
});
await page.route("**/order**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/order")) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const orderId = Number(url.searchParams.get("id") || 0);
orderLookups.push(orderId);
await route.fulfill(
json({
success: true,
data: {
id: orderId,
invoice_collection_id: 55501,
},
})
);
});
await page.route("**/collected-invoices/economic**", async (route) => {
if (matchesApiPath(route.request().url(), "/collected-invoices/economic/queue/status")) {
await route.fulfill(
json({
data: {
job: {
id: 77001,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Done",
result: {},
},
},
})
);
return;
}
if (
route.request().method() !== "POST" ||
!matchesApiPath(route.request().url(), "/collected-invoices/economic")
) {
await route.fallback();
return;
}
const payload = JSON.parse(route.request().postData() || "{}");
economicInvoiceRequests.push(payload);
await route.fulfill(
json({
data: {
job: {
id: 77001,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
result: null,
},
},
})
);
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-invoice-4001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4002")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-invoice-4002")).toHaveCount(0);
await page.getByTestId("invoicing-period-customer-invoice-4001").click();
await expect.poll(() => orderLookups).toEqual([9001]);
await expect.poll(() => economicInvoiceRequests.length).toBe(1);
expect(economicInvoiceRequests[0]).toEqual({
id: 55501,
send_as_is: false,
});
await page.waitForTimeout(250);
expect(popupUrls).toEqual([]);
await expect(page).toHaveURL(/activeTab=period/);
});
test("@smoke period view warns and splits selected multi-month invoice collections", async ({ page }) => {
const splitRequests = [];
const economicInvoiceRequests = [];
await openPeriodView(page, {
payloadFactory: () => ({
types: {
all: [
{
id: 31,
customer_number: 4301,
customer_name: "Multi Month Fleet",
requires_action: true,
transactions: [
{
id: 8801,
date: "2026-03-28T10:00:00.000Z",
amount: 120,
booked: false,
excluded: false,
invoice_collection_id: 88001,
},
{
id: 8802,
date: "2026-04-02T10:00:00.000Z",
amount: 180,
booked: false,
excluded: false,
invoice_collection_id: 88001,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
}),
});
await page.route("**/collected-invoices/split-by-month**", async (route) => {
if (
route.request().method() !== "POST" ||
!matchesApiPath(route.request().url(), "/collected-invoices/split-by-month")
) {
await route.fallback();
return;
}
const payload = JSON.parse(route.request().postData() || "{}");
splitRequests.push(payload);
await route.fulfill(
json({
data: {
preview: false,
processed_count: 1,
changed_count: 1,
skipped_count: 0,
},
})
);
});
await page.route("**/collected-invoices/economic**", async (route) => {
if (
route.request().method() === "POST" &&
matchesApiPath(route.request().url(), "/collected-invoices/economic")
) {
economicInvoiceRequests.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill(json({ data: {} }));
return;
}
await route.fallback();
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-4301")).toBeVisible();
await page.getByTestId("invoicing-period-customer-invoice-4301").click();
await expect(
page.getByRole("heading", { name: /Orders from multiple months|Ordrer fra flere måneder/i })
).toBeVisible();
await page.getByRole("button", { name: /Split by month|Opdel efter måned/i }).click();
await expect.poll(() => splitRequests.length).toBe(1);
expect(splitRequests[0]).toEqual({
dateFrom: "2026-03-28",
dateTo: "2026-04-02",
invoice_collection_ids: [88001],
preview: false,
});
expect(economicInvoiceRequests).toEqual([]);
await expect(page.getByText(/Monthly split completed|Månedsopdeling fuldført/i)).toBeVisible();
});
test("@smoke period view invoices multiple customers with page refresh and independent loading", async ({ page }) => {
const token = "superuser-period-parallel-token";
const periodRequests = [];
const economicInvoiceRequests = [];
const statusResolvers = new Map();
await suppressVueDevtoolsOverlay(page);
await seedAuthenticatedState(page, token);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
loginToken: token,
invoiceDistribution: true,
});
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/superuser/invoicing/period")) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const customerNumbers =
url.searchParams
.get("customerNumbers")
?.split(",")
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0) ?? [];
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
customerNumbers: url.searchParams.get("customerNumbers"),
});
await route.fulfill(
json({
data:
customerNumbers.length > 0
? createParallelInvoiceTargetedPayload(customerNumbers)
: createParallelInvoicePeriodPayload(),
})
);
});
await page.route("**/collected-invoices/economic/queue/status**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const jobId = Number(url.searchParams.get("job_id") || 0);
await new Promise((resolve) => {
statusResolvers.set(jobId, resolve);
});
await route.fulfill(
json({
data: {
job: {
id: jobId,
status: "COMPLETED",
progress_percent: 100,
progress_message: "Done",
result: {},
},
},
})
);
});
await page.route("**/collected-invoices/economic**", async (route) => {
if (
route.request().method() !== "POST" ||
!matchesApiPath(route.request().url(), "/collected-invoices/economic")
) {
await route.fallback();
return;
}
const payload = JSON.parse(route.request().postData() || "{}");
economicInvoiceRequests.push(payload);
await route.fulfill(
json({
data: {
job: {
id: payload.id === 55501 ? 77001 : 77003,
status: "QUEUED",
progress_percent: 0,
progress_message: "Queued",
result: null,
},
},
})
);
});
await page.route("**/departments**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (!(url.pathname === "/departments" || url.pathname === "/api/departments")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{ id: 1, name: "Copenhagen" },
{ id: 2, name: "Odense" },
],
})
);
});
await page.goto("/superuser/invoices?activeTab=period");
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("invoicing-period-view-selector-all").click();
const visibleCustomerOrder = () =>
page
.locator(
[
"[data-testid^='invoicing-period-customer-']",
":not([data-testid^='invoicing-period-customer-invoice-'])",
":not([data-testid^='invoicing-period-customer-queue-'])",
":not([data-testid^='invoicing-period-customer-draft-'])",
].join("")
)
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")?.replace("invoicing-period-customer-", ""))
);
const fullRequestCountBeforeInvoiceClicks = periodRequests.filter((request) => !request.customerNumbers).length;
await expect.poll(visibleCustomerOrder).toEqual(["4001", "4003", "4004"]);
await page.getByTestId("invoicing-period-customer-invoice-4001").click();
await page.getByTestId("invoicing-period-customer-invoice-4003").click();
await expect.poll(() => economicInvoiceRequests.map((request) => request.id).sort()).toEqual([55501, 55503]);
expect(periodRequests.filter((request) => !request.customerNumbers)).toHaveLength(
fullRequestCountBeforeInvoiceClicks
);
expect(periodRequests.filter((request) => request.customerNumbers)).toHaveLength(0);
await expect(page.getByTestId("invoicing-period-customer-invoice-4001")).toBeDisabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-4001")).toHaveClass(/is-loading/);
await expect(page.getByTestId("invoicing-period-customer-invoice-4003")).toBeDisabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-4003")).toHaveClass(/is-loading/);
await expect(page.getByTestId("invoicing-period-customer-invoice-4004")).toBeEnabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-4004")).not.toHaveClass(/is-loading/);
await expect.poll(() => statusResolvers.has(77001)).toBeTruthy();
statusResolvers.get(77001)?.();
await expect
.poll(() => periodRequests.filter((request) => !request.customerNumbers).length)
.toBeGreaterThan(fullRequestCountBeforeInvoiceClicks);
const fullRequestCountAfterFirstCompletion = periodRequests.filter((request) => !request.customerNumbers).length;
await expect(page.getByTestId("invoicing-period-customer-invoice-4003")).toBeDisabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-4003")).toHaveClass(/is-loading/);
await expect(page.getByTestId("invoicing-period-customer-invoice-4004")).toBeEnabled();
await expect.poll(visibleCustomerOrder).toEqual(["4001", "4003", "4004"]);
await expect.poll(() => statusResolvers.has(77003)).toBeTruthy();
statusResolvers.get(77003)?.();
await expect
.poll(() => periodRequests.filter((request) => !request.customerNumbers).length)
.toBeGreaterThan(fullRequestCountAfterFirstCompletion);
await expect.poll(visibleCustomerOrder).toEqual(["4001", "4003", "4004"]);
expect(periodRequests.filter((request) => request.customerNumbers)).toHaveLength(0);
});
test("@smoke period view date changes trigger a refreshed period query", async ({ page }) => {
let didDelayDateRefresh = false;
let releaseDateRefresh;
const { periodRequests } = await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/superuser/invoicing/period")) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
if (url.searchParams.get("dateFrom") !== "2026-04-02" || url.searchParams.get("periodWarm") === "1") {
await route.fallback();
return;
}
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
if (!didDelayDateRefresh) {
didDelayDateRefresh = true;
await new Promise((resolve) => {
releaseDateRefresh = resolve;
});
}
await route.fulfill(json({ data: createPeriodPayloadForChangedRange() }));
});
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
await expect(dateInputs.first()).toBeVisible();
await dateInputs.nth(0).fill("2026-04-02");
await dateInputs.nth(0).dispatchEvent("change");
await expect(page.getByTestId("invoicing-period-view-selector-all").locator("progress").first()).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-4001")).toBeVisible();
releaseDateRefresh?.();
await dateInputs.nth(1).fill("2026-04-30");
await dateInputs.nth(1).dispatchEvent("change");
const firstRange = periodRequests[0];
await expect
.poll(() =>
periodRequests.some(
(request) => request.dateFrom !== firstRange?.dateFrom || request.dateTo !== firstRange?.dateTo
)
)
.toBeTruthy();
await expect(page.getByTestId("invoicing-period-customer-5001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-all").locator("progress").first()).toHaveCount(0);
await expect(page).toHaveURL(/activeTab=period/);
});
test("@smoke period partial month warning selects the whole calendar month", async ({ page }) => {
const { periodRequests } = await openPeriodView(page);
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
await expect(dateInputs.first()).toBeVisible();
await dateInputs.nth(0).fill("2026-05-04");
await dateInputs.nth(0).dispatchEvent("change");
await dateInputs.nth(1).fill("2026-05-05");
await dateInputs.nth(1).dispatchEvent("change");
const requestCountBeforeClick = periodRequests.length;
await page.getByTestId("date-period-set-entire-month").click();
await expect(dateInputs.nth(0)).toHaveValue("2026-05-01");
await expect(dateInputs.nth(1)).toHaveValue("2026-05-31");
await expect.poll(() => periodRequests.length).toBeGreaterThan(requestCountBeforeClick);
await expect
.poll(() => periodRequests.at(-1))
.toEqual({
dateFrom: "2026-05-01",
dateTo: "2026-05-31",
});
});
test("@smoke period month shortcuts select whole calendar months", async ({ page }, testInfo) => {
const isCompact = isCompactProject(testInfo);
await page.clock.setFixedTime(new Date("2026-05-04T10:00:00.000Z"));
const { periodRequests } = await openPeriodView(page);
const initialRequestCount = periodRequests.length;
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
if (isCompact) {
const select = page.getByTestId("date-period-shortcuts");
const label = await select
.locator("option")
.filter({ hasText: /sidste måned|last month/i })
.first()
.textContent();
await select.selectOption({ label: label.trim() });
} else {
await page.getByRole("button", { name: /last month|sidste måned/i }).click();
}
await expect(dateInputs.nth(0)).toHaveValue("2026-04-01");
await expect(dateInputs.nth(1)).toHaveValue("2026-04-30");
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
await expect
.poll(() => periodRequests.at(-1))
.toEqual({
dateFrom: "2026-04-01",
dateTo: "2026-04-30",
});
});
test("@smoke period view reload button triggers a fresh period query", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
});
const { periodRequests } = await openPeriodView(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
const initialRequestCount = periodRequests.length;
await page.getByTestId("invoicing-period-reload-button").click();
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
await page.waitForTimeout(250);
await expect(page.getByTestId("invoicing-period-view-all")).toHaveAttribute("data-current-view", "all");
await expect(page).toHaveURL(/activeTab=period/);
expect(pageErrors).not.toEqual(expect.arrayContaining([expect.stringContaining("reading 'types'")]));
});
test("@smoke period page keeps cached cards visible during refresh, search, and pagination", async ({ page }) => {
const periodRequests = [];
let delayNextPeriodResponse = false;
let releaseDelayedResponse = null;
const pageCustomer = (customerNumber, customerName, transactionId) => ({
id: customerNumber,
customer_number: customerNumber,
customer_name: customerName,
requires_action: true,
transactions: [
{
id: transactionId,
date: "2026-04-14T10:00:00.000Z",
amount: 210,
booked: false,
excluded: false,
invoice_collection_id: transactionId + 1000,
queue_status: null,
queue_job_id: null,
},
],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
});
const paginatedPeriodResponse = ({ search, pageNumber }) => {
const isSearch = String(search || "")
.toLowerCase()
.includes("beta");
const customer = isSearch
? pageCustomer(9102, "Beta Search", 9912)
: pageNumber === 2
? pageCustomer(9103, "Gamma Page", 9913)
: pageCustomer(9101, "Alpha Cached", 9911);
const total = isSearch ? 1 : 30;
return {
__rawResponse: {
data: {
dateFrom: "2026-04-01 00:00:00",
dateTo: "2026-04-30 23:59:59",
types: {
all: [customer],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
type_counts: {
all: {
requires_action: total,
draft: 0,
manual_flags: 0,
automatic_flags: 0,
completed: 0,
total,
},
},
type_totals: {
all: {
total: isSearch ? 1200 : 9000,
booked: isSearch ? 400 : 6000,
not_booked: isSearch ? 800 : 3000,
},
},
},
meta: {
pagination: {
page: pageNumber,
per_page: 1,
total,
search: search || "",
filters: {
includeRequiresAction: true,
includeBooked: true,
},
order: null,
},
},
},
};
};
await openPeriodView(page, {
payloadFactory: async ({ url }) => {
const pageNumber = Number(url.searchParams.get("page") || 1);
const search = url.searchParams.get("search") || "";
const isWarmRequest = url.searchParams.get("periodWarm") === "1";
if (!isWarmRequest) {
periodRequests.push({
page: pageNumber,
search,
limit: url.searchParams.get("limit"),
periodView: url.searchParams.get("periodView"),
});
}
if (delayNextPeriodResponse && !isWarmRequest) {
delayNextPeriodResponse = false;
await new Promise((resolve) => {
releaseDelayedResponse = resolve;
});
}
return paginatedPeriodResponse({ search, pageNumber });
},
});
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-9101")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-all")).toContainText("(0/30)");
await page.getByTestId("invoicing-period-flag-tabs").getByText("Filters").first().click();
await expect(page.getByTestId("invoicing-period-limit-select")).toHaveValue("100");
await expect.poll(() => periodRequests.some((request) => request.limit === "100")).toBeTruthy();
const statistics = page.getByTestId("invoicing-period-statistics");
await expect(statistics.getByTestId("invoicing-period-stat-total")).toContainText("9.000,00");
await expect(statistics.getByTestId("invoicing-period-stat-booked")).toContainText("6.000,00");
await expect(statistics.getByTestId("invoicing-period-stat-not-booked")).toContainText("3.000,00");
const periodPagination = page.getByTestId("invoicing-period-pagination-navigation");
await periodPagination.locator(".pagination-next").click();
await expect.poll(() => periodRequests.some((request) => request.page === 2)).toBeTruthy();
await expect(page.getByTestId("invoicing-period-customer-9103")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-9101")).toHaveCount(0);
delayNextPeriodResponse = true;
await periodPagination.locator(".pagination-previous").click();
await expect(page.getByTestId("invoicing-period-customer-9101")).toBeVisible();
await expect(page.getByTestId("invoicing-period-refreshing")).toBeVisible();
releaseDelayedResponse?.();
await expect(page.getByTestId("invoicing-period-refreshing")).toHaveCount(0);
await page.getByTestId("invoicing-period-search-input").fill("Beta");
await expect.poll(() => periodRequests.some((request) => request.search === "Beta")).toBeTruthy();
await expect(page.getByTestId("invoicing-period-customer-9102")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-9101")).toHaveCount(0);
await expect(page).toHaveURL(/periodSearch=Beta/);
await expect(page).toHaveURL(/periodLimit=100/);
await page.getByTestId("invoicing-period-limit-select").selectOption("all");
await expect.poll(() => periodRequests.some((request) => request.limit === "all")).toBeTruthy();
await expect(page).toHaveURL(/periodLimit=all/);
});
test("@smoke period monthly split posts selected range and refreshes period data", async ({ page }) => {
const { periodRequests } = await openPeriodView(page);
const splitRequests = [];
const initialRequestCount = periodRequests.length;
await page.route("**/collected-invoices/split-by-month**", async (route) => {
if (
route.request().method() !== "POST" ||
!matchesApiPath(route.request().url(), "/collected-invoices/split-by-month")
) {
await route.fallback();
return;
}
const payload = JSON.parse(route.request().postData() || "{}");
splitRequests.push(payload);
if (payload.preview) {
await route.fulfill(
json({
data: {
preview: true,
processed_count: 2,
changed_count: 1,
skipped_count: 1,
changed: [
{
invoice_collection_id: 101,
months: [
{
month: "2026-03",
order_count: 2,
will_create_collection: false,
target_invoice_collection_id: 101,
},
{
month: "2026-04",
order_count: 1,
will_create_collection: true,
target_invoice_collection_id: null,
},
],
},
],
skipped: [
{
invoice_collection_id: 202,
message: "Invoice collection already belongs to one month",
},
],
},
})
);
return;
}
await route.fulfill(
json({
data: {
preview: false,
processed_count: 2,
changed_count: 1,
skipped_count: 1,
},
})
);
});
await page.getByTestId("invoicing-period-split-by-month-button").click();
await expect(page.getByText(/Preview monthly split|Forhåndsvis månedsopdeling/i)).toBeVisible();
await expect(
page.locator(".swal2-html-container").filter({ hasText: /(?:Collection|Fakturasamling|fakturasamling)?\s*#101/i })
).toBeVisible();
await expect(page.locator(".swal2-html-container td").filter({ hasText: /^2026-04$/ })).toBeVisible();
await expect(page.getByText(/Create new collection|Opret ny fakturasamling/i)).toBeVisible();
await page.getByRole("button", { name: /apply split|udfør opdeling/i }).click();
await expect.poll(() => splitRequests.length).toBe(2);
expect(splitRequests[0]).toEqual({
dateFrom: periodRequests[0]?.dateFrom,
dateTo: periodRequests[0]?.dateTo,
preview: true,
});
expect(splitRequests[1]).toEqual({
dateFrom: periodRequests[0]?.dateFrom,
dateTo: periodRequests[0]?.dateTo,
preview: false,
});
await expect(page.getByText(/Processed 2 invoice collections|Behandlede 2 fakturasamlinger/i)).toBeVisible();
await page.getByRole("button", { name: "OK" }).click();
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
});
test("@smoke period view shows a queued CTA when backend queue metadata blocks invoicing", async ({ page }) => {
const periodRequests = [];
await seedInvoicesPage(page);
await setupPeriodEndpoints(page, periodRequests);
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
await route.fulfill(
json({
data: createQueuedPeriodPayload(),
})
);
});
await page.goto("/superuser/invoices?activeTab=period");
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-6001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeDisabled();
await expect(page.getByTestId("invoicing-period-customer-invoice-6001")).toHaveCount(0);
expect(periodRequests.length).toBeGreaterThan(0);
});
test("@smoke period view shows a disabled Kladde CTA when backend draft metadata blocks invoicing", async ({
page,
}) => {
await openPeriodView(page, { payloadFactory: createDraftBlockedPeriodPayload });
const draftedOrder = {
id: 9301,
customer_id: 6101,
customer_name: "Drafted Fleet",
cashier_id: 7,
cashier_name: "Jeppe",
department_id: 1,
reg_1: "EC21233",
reg_2: "",
reg_3: "",
reference: "EC21233",
notes: "",
po: "",
created_at: "2026-04-14 10:00:00",
total_net_amount: 210,
invoice_collection_id: 16501,
invoice_collection: {
id: 16501,
closed_at: "2026-04-14",
booked_invoice_id: null,
},
economic_invoice_module: null,
stripe_invoice_module: null,
error_message: null,
completed_at: null,
pending_handheld: false,
user_id: 41,
};
await page.route("**/orders**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/orders")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [draftedOrder],
meta: {
pagination: {
limit: 20,
total: 1,
},
},
})
);
});
await page.route("**/collected-invoices/16501**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
data: {
id: 16501,
orders: [draftedOrder],
},
})
);
});
await page.getByTestId("invoicing-period-view-selector-all").click();
const allSelector = page.getByTestId("invoicing-period-view-selector-all");
await expect(allSelector).toContainText("(0/1)");
const draftProgressSegment = page.getByTestId("invoicing-period-view-selector-progress-draft-all");
await expect(draftProgressSegment).toBeVisible();
await expect(draftProgressSegment).toHaveCSS("background-color", "rgb(246, 195, 68)");
const customerRow = page.getByTestId("invoicing-period-customer-6101");
await expect(customerRow).toBeVisible();
await expect(customerRow.locator(".color-indicator i.fa-circle").first()).toHaveClass(/has-text-warning/);
const draftTag = page.getByTestId("invoicing-period-customer-draft-6101");
await expect(draftTag).toBeVisible();
await expect(draftTag).toHaveClass(/tag/);
await expect(draftTag).toHaveClass(/is-warning/);
await expect(draftTag).toHaveAttribute("aria-disabled", "true");
await expect(draftTag.locator("i")).toHaveClass(/fa-file-alt/);
await expect(draftTag).toContainText("Kladde");
await expect(page.getByTestId("invoicing-period-customer-invoice-6101")).toHaveCount(0);
await customerRow.getByText("Drafted Fleet").click();
const collectionTotal = page.getByTestId("pos-order-invoice-collection-total-16501");
await expect(collectionTotal).toContainText("210");
const isDesktop = page.viewportSize().width >= 1024;
if (isDesktop) {
const amountHeader = page.getByRole("columnheader", { name: /Beløb|Amount/i }).last();
const amountHeaderBox = await getBoundingBox(amountHeader, "orders amount column header");
const collectionTotalBox = await getBoundingBox(collectionTotal, "invoice collection total");
expect(Math.abs(collectionTotalBox.x - amountHeaderBox.x)).toBeLessThanOrEqual(2);
expect(Math.abs(collectionTotalBox.width - amountHeaderBox.width)).toBeLessThanOrEqual(3);
}
});
test("@smoke period view restores Fakturer nu when draft metadata is stale or deleted", async ({ page }) => {
await openPeriodView(page, { payloadFactory: createStaleDraftPeriodPayload });
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-6102")).toBeVisible();
await expect(page.getByTestId("invoicing-period-customer-draft-6102")).toHaveCount(0);
const invoiceButton = page.getByTestId("invoicing-period-customer-invoice-6102");
await expect(invoiceButton).toBeVisible();
await expect(invoiceButton).toBeEnabled();
await expect(invoiceButton).toContainText(/Fakturer nu|Invoice now/i);
});
test("@smoke period view refreshes queue state when the Period route becomes active again", async ({ page }) => {
const periodRequests = [];
let requestCount = 0;
let returnQueuedPayload = false;
await seedInvoicesPage(page);
await setupPeriodEndpoints(page, periodRequests);
await page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
requestCount += 1;
const url = new URL(route.request().url());
periodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
await route.fulfill(
json({
data: returnQueuedPayload ? createQueuedPeriodPayload() : createPeriodPayload(),
})
);
});
await page.goto("/superuser/invoices?activeTab=overview");
await expect(page).toHaveURL(/activeTab=overview/);
returnQueuedPayload = true;
await page.goto("/superuser/invoices?activeTab=period");
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
timeout: periodRouteReadyTimeout,
});
await expect.poll(() => requestCount, { timeout: periodRouteReadyTimeout }).toBeGreaterThan(0);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
});
});