Fix invoicing period invoice action

This commit is contained in:
Jeppe Bundgaard
2026-05-11 03:35:36 +02:00
parent c9b34ba36c
commit 10a52521d5
3 changed files with 291 additions and 60 deletions
@@ -42,7 +42,9 @@ const doesCustomerHaveTransactionsDifferentDays = (customer: any) => {
return false;
}
const datesSet = new Set(customer.transactions.map((transaction: any) => new Date(transaction.date).toISOString().split("T")[0]));
const datesSet = new Set(
customer.transactions.map((transaction: any) => new Date(transaction.date).toISOString().split("T")[0])
);
return datesSet.size > 1;
};
@@ -57,9 +59,9 @@ const onFilterChanged = (filters: any) => {
tmpFilters.value = filters;
};
const customersInCurrentView = computed(() => (
view.variables.sharedVariables.value?.types?.[view.computed.componentName.value] ?? []
));
const customersInCurrentView = computed(
() => view.variables.sharedVariables.value?.types?.[view.computed.componentName.value] ?? []
);
const getTransactionIds = (customer: any) => {
if (!customer.transactions || customer.transactions.length === 0) {
@@ -83,23 +85,41 @@ const getExcludedTransactionIds = (customer: any) => {
const transactionIdsInvoiceCollectionCache = ref<{ [key: number]: number | null }>({});
const queueInvoiceCollections = (invoiceCollectionIds: number[]) => {
const uniqueInvoiceCollectionIds = Array.from(
new Set(
invoiceCollectionIds.filter(
(invoiceCollectionId) => Number.isInteger(invoiceCollectionId) && invoiceCollectionId > 0
)
)
);
if (uniqueInvoiceCollectionIds.length === 0) {
return;
}
invoiceQueue.addInvoiceCollectionsToQueue(uniqueInvoiceCollectionIds);
invoiceQueue.processInvoiceCollectionQueue();
};
const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
if (transactionIds.length === 0) {
if (customer?.meta?.fixed_pricing) {
const month = dates.variables.start.value.getMonth() + 1;
const year = dates.variables.start.value.getFullYear();
await SessionUser.objects.collectedOrderInvoices.functions.createVehicleSubscriptionInvoice(customer.customer_number, month, year)
await SessionUser.objects.collectedOrderInvoices.functions
.createVehicleSubscriptionInvoice(customer.customer_number, month, year)
.then((response: any) => {
const newInvoiceCollectionId = response?.data?.data?.id;
if (!newInvoiceCollectionId) {
return;
}
SessionUser.objects.collectedOrderInvoices.functions.add_fixed_pricing(newInvoiceCollectionId)
SessionUser.objects.collectedOrderInvoices.functions
.add_fixed_pricing(newInvoiceCollectionId)
.then(() => {
invoiceQueue.addInvoiceCollectionsToQueue([newInvoiceCollectionId]);
invoiceQueue.processInvoiceCollectionQueue();
queueInvoiceCollections([newInvoiceCollectionId]);
})
.catch((error: any) => {
console.error("Error adding fixed pricing to invoice collection:", error);
@@ -112,17 +132,18 @@ const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
const month = dates.variables.start.value.getMonth() + 1;
const year = dates.variables.start.value.getFullYear();
await SessionUser.objects.collectedOrderInvoices.functions.createVehicleSubscriptionInvoice(customer.customer_number, month, year)
await SessionUser.objects.collectedOrderInvoices.functions
.createVehicleSubscriptionInvoice(customer.customer_number, month, year)
.then((response: any) => {
const newInvoiceCollectionId = response?.data?.data?.id;
if (!newInvoiceCollectionId) {
return;
}
SessionUser.objects.collectedOrderInvoices.functions.add_vehicle_subscriptions(newInvoiceCollectionId)
SessionUser.objects.collectedOrderInvoices.functions
.add_vehicle_subscriptions(newInvoiceCollectionId)
.then(() => {
invoiceQueue.addInvoiceCollectionsToQueue([newInvoiceCollectionId]);
invoiceQueue.processInvoiceCollectionQueue();
queueInvoiceCollections([newInvoiceCollectionId]);
})
.catch((error: any) => {
console.error("Error adding vehicle subscriptions to invoice collection:", error);
@@ -136,21 +157,23 @@ const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
return;
}
fetchMissingInvoiceCollections(transactionIds).then(() => {
const invoiceCollectionIds = transactionIds
.map((transactionId) => transactionIdsInvoiceCollectionCache.value[transactionId])
.filter((invoiceCollectionId): invoiceCollectionId is number => invoiceCollectionId !== null);
const uniqueInvoiceCollectionIds = Array.from(new Set(invoiceCollectionIds));
if (uniqueInvoiceCollectionIds.length > 0) {
invoiceQueue.addInvoiceCollectionsToQueue(uniqueInvoiceCollectionIds);
invoiceQueue.processInvoiceCollectionQueue();
}
});
await fetchMissingInvoiceCollections(customer, transactionIds);
queueInvoiceCollections(getInvoiceCollectionIdsForTransactionIds(customer, transactionIds));
};
const fetchMissingInvoiceCollections = async (transactionIds: number[]) => {
const uncachedTransactionIds = transactionIds.filter((transactionId) => !(transactionId in transactionIdsInvoiceCollectionCache.value));
const getTransactionById = (customer: any, transactionId: number) => {
return Array.isArray(customer?.transactions)
? customer.transactions.find((transaction: any) => Number(transaction?.id) === Number(transactionId))
: null;
};
const fetchMissingInvoiceCollections = async (customer: any, transactionIds: number[]) => {
const uncachedTransactionIds = transactionIds.filter(
(transactionId) =>
!(transactionId in transactionIdsInvoiceCollectionCache.value) &&
getTransactionInvoiceCollectionId(getTransactionById(customer, transactionId) ?? { id: transactionId }) === null
);
if (uncachedTransactionIds.length === 0) {
return Promise.resolve();
}
@@ -174,32 +197,51 @@ const getTransactionInvoiceCollectionId = (transaction: any) => {
: null;
};
const getInvoiceCollectionIdsForTransactionIds = (customer: any, transactionIds: number[]) => {
return transactionIds
.map((transactionId) =>
getTransactionInvoiceCollectionId(getTransactionById(customer, transactionId) ?? { id: transactionId })
)
.filter((invoiceCollectionId): invoiceCollectionId is number => invoiceCollectionId !== null);
};
const isInvoiceCollectionQueuedLocally = (invoiceCollectionId: number | null | undefined) => {
if (!invoiceCollectionId) {
return false;
}
return invoiceQueue.invoiceCollectionQueueInProgress.value.includes(invoiceCollectionId)
|| invoiceQueue.invoiceCollectionQueue.value.includes(invoiceCollectionId);
return (
invoiceQueue.invoiceCollectionQueueInProgress.value.includes(invoiceCollectionId) ||
invoiceQueue.invoiceCollectionQueue.value.includes(invoiceCollectionId)
);
};
const getCustomerActionableTransactions = (customer: any) => {
return getTransactionsInView(customer).filter((transaction: any) => transaction?.booked !== true && transaction?.excluded !== true);
return getTransactionsInView(customer).filter(
(transaction: any) => transaction?.booked !== true && transaction?.excluded !== true
);
};
const getLocalQueuedInvoiceCollectionIdsForCustomer = (customer: any) => {
return Array.from(new Set(
getCustomerActionableTransactions(customer)
.map((transaction: any) => getTransactionInvoiceCollectionId(transaction))
.filter((invoiceCollectionId): invoiceCollectionId is number => (
Number.isInteger(invoiceCollectionId) && (invoiceCollectionId ?? 0) > 0 && isInvoiceCollectionQueuedLocally(invoiceCollectionId)
))
));
return Array.from(
new Set(
getCustomerActionableTransactions(customer)
.map((transaction: any) => getTransactionInvoiceCollectionId(transaction))
.filter(
(invoiceCollectionId): invoiceCollectionId is number =>
Number.isInteger(invoiceCollectionId) &&
(invoiceCollectionId ?? 0) > 0 &&
isInvoiceCollectionQueuedLocally(invoiceCollectionId)
)
)
);
};
const getCustomerQueueStatuses = (customer: any) => {
const backendStatuses = Array.isArray(customer?.queue?.statuses)
? Array.from(new Set(customer.queue.statuses.filter((status: string) => typeof status === "string" && status.length > 0)))
? Array.from(
new Set(customer.queue.statuses.filter((status: string) => typeof status === "string" && status.length > 0))
)
: [];
if (backendStatuses.length > 0) {
@@ -212,12 +254,12 @@ const getCustomerQueueStatuses = (customer: any) => {
}
const statuses = [];
const hasProcessing = queuedInvoiceCollectionIds.some((invoiceCollectionId) => (
const hasProcessing = queuedInvoiceCollectionIds.some((invoiceCollectionId) =>
invoiceQueue.invoiceCollectionQueueInProgress.value.includes(invoiceCollectionId)
));
const hasQueued = queuedInvoiceCollectionIds.some((invoiceCollectionId) => (
);
const hasQueued = queuedInvoiceCollectionIds.some((invoiceCollectionId) =>
invoiceQueue.invoiceCollectionQueue.value.includes(invoiceCollectionId)
));
);
if (hasProcessing) {
statuses.push(ECONOMIC_QUEUE_STATUS.PROCESSING);
@@ -241,7 +283,11 @@ const isCustomerQueueBlocked = (customer: any) => {
return actionableTransactions.every((transaction: any) => {
const invoiceCollectionId = getTransactionInvoiceCollectionId(transaction);
return Number.isInteger(invoiceCollectionId) && (invoiceCollectionId ?? 0) > 0 && isInvoiceCollectionQueuedLocally(invoiceCollectionId);
return (
Number.isInteger(invoiceCollectionId) &&
(invoiceCollectionId ?? 0) > 0 &&
isInvoiceCollectionQueuedLocally(invoiceCollectionId)
);
});
};
@@ -269,8 +315,10 @@ const isAllCustomerTransactionsBooked = (customer: any, transactionIds: number[]
.filter((transaction: any) => transaction.excluded === true)
.map((transaction: any) => transaction.id);
return transactionIds.every((transactionId) => bookedTransactionIds.includes(transactionId))
|| transactionIds.every((transactionId) => excludedTransactionIds.includes(transactionId));
return (
transactionIds.every((transactionId) => bookedTransactionIds.includes(transactionId)) ||
transactionIds.every((transactionId) => excludedTransactionIds.includes(transactionId))
);
};
const getTransactionQueryParameters = () => {
@@ -286,15 +334,16 @@ const getTransactionQueryParameters = () => {
<div data-testid="invoicing-period-view-all" :data-current-view="view.variables.currentView.value">
<InvoicingBillingPeriodStatistics />
<InvoicingBillingPeriodFilters @filterChanged="(filters) => onFilterChanged(filters)" />
<div style="min-height: 300px;"><!--Spacer--></div>
<div style="min-height: 300px"><!--Spacer--></div>
<div class="columns is-multiline is-mobile">
<template
v-for="customer in customersInCurrentView"
:key="customer.customer_number"
>
<template v-for="customer in customersInCurrentView" :key="customer.customer_number">
<div class="column is-12" v-show="tmpFilters.isCustomerVisible(customer) ?? true">
<WhiteBox class="mb-2" :has-border="true" :data-testid="`invoicing-period-customer-${customer.customer_number}`">
<WhiteBox
class="mb-2"
:has-border="true"
:data-testid="`invoicing-period-customer-${customer.customer_number}`"
>
<div class="columns is-vcentered is-clickable" @click="onClickCustomer(customer)">
<div class="column">
<ColorIndicator
@@ -329,22 +378,61 @@ const getTransactionQueryParameters = () => {
<div class="column is-narrow">
<p v-if="customer.transactions.length !== 0">
{{ view.functions.filterExcluded(customer.transactions).length }}
{{ view.functions.filterExcluded(customer.transactions).length > 1 ? SessionUser.objects.orders.meta.labels.multiple : SessionUser.objects.orders.meta.labels.single }}
{{
view.functions.filterExcluded(customer.transactions).length > 1
? SessionUser.objects.orders.meta.labels.multiple
: SessionUser.objects.orders.meta.labels.single
}}
</p>
<p v-else>
{{ SessionUser.objects.global.language.none }} {{ SessionUser.objects.orders.meta.labels.multiple }}
</p>
<p v-else>{{ SessionUser.objects.global.language.none }} {{ SessionUser.objects.orders.meta.labels.multiple }}</p>
</div>
<div class="column is-narrow">
<p>{{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: true, includeVehicleSubscriptions: true, includeTransactions: true })) }}</p>
<p>
{{
SessionUser.functions.currency.toLocal(
view.functions.getCustomerViewTotalNetAmount(customer, {
includeFixedPricing: true,
includeVehicleSubscriptions: true,
includeTransactions: true,
})
)
}}
</p>
<p
v-if="view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: true, includeVehicleSubscriptions: true, includeTransactions: true }) !== view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: false, includeVehicleSubscriptions: false, includeTransactions: true })"
v-if="
view.functions.getCustomerViewTotalNetAmount(customer, {
includeFixedPricing: true,
includeVehicleSubscriptions: true,
includeTransactions: true,
}) !==
view.functions.getCustomerViewTotalNetAmount(customer, {
includeFixedPricing: false,
includeVehicleSubscriptions: false,
includeTransactions: true,
})
"
class="has-text-grey-light is-size-7"
>
({{ SessionUser.functions.currency.toLocal(view.functions.getCustomerViewTotalNetAmount(customer, { includeFixedPricing: false, includeVehicleSubscriptions: false, includeTransactions: true })) }})
({{
SessionUser.functions.currency.toLocal(
view.functions.getCustomerViewTotalNetAmount(customer, {
includeFixedPricing: false,
includeVehicleSubscriptions: false,
includeTransactions: true,
})
)
}})
</p>
</div>
<div
class="column is-narrow"
v-if="isAllCustomerTransactionsBooked(customer, getCustomerTransactionsInView(customer)) && !customer.requires_action && !isCustomerQueueBlocked(customer)"
v-if="
isAllCustomerTransactionsBooked(customer, getCustomerTransactionsInView(customer)) &&
!customer.requires_action &&
!isCustomerQueueBlocked(customer)
"
>
<span class="tag is-success is-light is-small">
<span class="icon is-small">
@@ -369,8 +457,9 @@ const getTransactionQueryParameters = () => {
<div class="column is-narrow" v-else>
<button
v-if="tmpFilters.displayRequiresAction && customer.requires_action"
type="button"
class="button is-small is-dark"
@click.stop="onClickInvoiceNow(customer, getTransactionIds(customer))"
@click.prevent.stop="onClickInvoiceNow(customer, getTransactionIds(customer))"
:data-testid="`invoicing-period-customer-invoice-${customer.customer_number}`"
>
<span class="icon is-small">
@@ -433,5 +522,4 @@ const getTransactionQueryParameters = () => {
</div>
</template>
<style scoped>
</style>
<style scoped></style>
+83 -1
View File
@@ -626,14 +626,96 @@ test.describe("Invoicing period tab", () => {
await expect(odenseBookedCell).not.toContainText("450");
});
test("@smoke period view shows invoice action only for customers requiring action", async ({ page }) => {
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/);
});
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { computed, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { sharedVariablesRef, currentViewRef, startDateRef, endDateRef, queueRef, inProgressRef } = vi.hoisted(() => {
const { ref } = require("vue");
@@ -120,6 +120,8 @@ vi.mock(
);
import InvoicingBillingPeriodViewAll from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { invoiceQueue } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
const mountView = () =>
mount(InvoicingBillingPeriodViewAll, {
@@ -143,6 +145,10 @@ describe("Invoicing period queue state", () => {
currentViewRef.value = "all";
queueRef.value = [];
inProgressRef.value = [];
SessionUser.objects.orders.get.multiple.mockReset();
SessionUser.objects.orders.get.multiple.mockResolvedValue([]);
invoiceQueue.addInvoiceCollectionsToQueue.mockReset();
invoiceQueue.processInvoiceCollectionQueue.mockReset();
sharedVariablesRef.value = {
types: {
all: [
@@ -209,6 +215,10 @@ describe("Invoicing period queue state", () => {
};
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a disabled queued CTA when the backend marks the customer as action-blocked", async () => {
const wrapper = mountView();
await nextTick();
@@ -226,4 +236,55 @@ describe("Invoicing period queue state", () => {
expect(wrapper.find("[data-testid='invoicing-period-customer-queue-1002']").exists()).toBe(false);
expect(wrapper.get("[data-testid='invoicing-period-customer-invoice-1002']").text()).toContain("Invoice now");
});
it("queues period invoice collections without opening the invoice collection page", async () => {
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
sharedVariablesRef.value = {
types: {
all: [
{
id: 3,
customer_number: 1003,
customer_name: "Ready Customer",
requires_action: true,
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
transactions: [
{
id: 6001,
amount: 75,
booked: false,
excluded: false,
invoice_collection_id: 3001,
date: "2026-04-13T10:00:00.000Z",
},
{
id: 6002,
amount: 125,
booked: false,
excluded: false,
invoice_collection_id: 3001,
date: "2026-04-14T10:00:00.000Z",
},
],
},
],
},
};
const wrapper = mountView();
await nextTick();
await wrapper.get("[data-testid='invoicing-period-customer-invoice-1003']").trigger("click");
await nextTick();
expect(SessionUser.objects.orders.get.multiple).not.toHaveBeenCalled();
expect(invoiceQueue.addInvoiceCollectionsToQueue).toHaveBeenCalledWith([3001]);
expect(invoiceQueue.processInvoiceCollectionQueue).toHaveBeenCalledTimes(1);
expect(windowOpenSpy).not.toHaveBeenCalled();
});
});