From 7b6807600a32636fb9847d8a93738fc759f3447e Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 10:10:34 +0200 Subject: [PATCH 1/8] feat(invoicing): one sequence behind every Invoice action `support/invoicing.ts` is the whole of invoicing: prepare the selection, fill in the host's own defaults, post the invoice with the session's client, stamp the entries with the line ids that come back, and land on the draft. Every entry point runs it and nothing else, so "invoice this" means the same four steps from a task row, a bulk bar, a task page, a project header or the unbilled time page. `prepareInvoice` now takes the selection rather than a list of entry ids, so a caller names whichever of the three shapes its screen knows. The contact is read for the currency it settles in, because the host compares that with the company setting when it decides whether an exchange rate is required. Three facts outlive the screen that caused them, so they live in `stores/invoicing.ts`: - one module-wide lock, because the sequence is four requests long and a second click elsewhere would race the same hours onto two invoices; - an invoice the host created whose entries were not stamped, which is the one failure that leaves work behind: the time still reads as unbilled, so `InvoiceRetryBanner` offers the idempotent confirm again and the module refuses to start another invoice until it lands; - whether the caller may invoice at all, learned from the first 403, since the settings endpoint does not say. `InvoiceNumberModal` is mounted once rather than by each screen that can invoice: a company that numbers its invoices by hand is the only one ever asked, and backing out of that question cancels the invoice rather than creating one with a blank number. --- resources/js/api/billing.ts | 70 ++- .../js/components/InvoiceNumberModal.vue | 81 ++++ .../js/components/InvoiceRetryBanner.vue | 91 ++++ resources/js/messages/billing.ts | 101 ++-- resources/js/stores/invoicing.ts | 118 +++++ resources/js/support/invoicing.ts | 459 ++++++++++++++++++ resources/js/types/billing.ts | 42 +- 7 files changed, 876 insertions(+), 86 deletions(-) create mode 100644 resources/js/components/InvoiceNumberModal.vue create mode 100644 resources/js/components/InvoiceRetryBanner.vue create mode 100644 resources/js/stores/invoicing.ts create mode 100644 resources/js/support/invoicing.ts diff --git a/resources/js/api/billing.ts b/resources/js/api/billing.ts index d934516..baee174 100644 --- a/resources/js/api/billing.ts +++ b/resources/js/api/billing.ts @@ -2,7 +2,7 @@ import type { AxiosInstance } from 'axios' import type { Wrapped } from '@/types/api' import type { BillingCustomer, - BillingGrouping, + BillingSelection, CompanyInvoiceDefaults, ConfirmItem, CreatedInvoice, @@ -16,7 +16,7 @@ import type { const BASE = '/api/v1/tasks-projects' -/** The module endpoints the wizard talks to. */ +/** The module endpoints the invoicing flow talks to. */ export const BILLING_API = { customers: `${BASE}/billing/customers`, unbilled: `${BASE}/billing/unbilled`, @@ -33,16 +33,13 @@ export const BILLING_API = { */ export const HOST_BILLING_API = { bootstrap: '/api/v1/bootstrap', - customers: '/api/v1/customers', + customer: (customerId: number): string => `/api/v1/customers/${customerId}`, invoices: '/api/v1/invoices', invoiceTemplates: '/api/v1/invoices/templates', nextNumber: '/api/v1/next-number', exchangeRate: (currencyId: number): string => `/api/v1/currencies/${currencyId}/exchange-rate`, } as const -/** How many contacts the name lookup asks for. */ -export const CUSTOMER_LOOKUP_LIMIT = 200 - export interface UnbilledRange { /** `Y-m-d`, inclusive. */ from?: string @@ -75,24 +72,46 @@ export async function fetchUnbilledTime( return data.data } -/** The invoice body for a selection, plus the entries behind each line. */ +/** + * The invoice body for a selection, plus the entries behind each line. + * + * The selection arrives in whichever of the three shapes the calling screen + * knows and leaves as the one key the endpoint expects, because the rules + * refuse a body that names two of them. The grouping rides along only when the + * caller chose one, so the server's own default stays the default. + */ export async function prepareInvoice( client: AxiosInstance, - entryIds: number[], - grouping: BillingGrouping, + selection: BillingSelection, ): Promise { - const { data } = await client.post>(BILLING_API.prepare, { - entry_ids: entryIds, - grouping, - }) + const { data } = await client.post>( + BILLING_API.prepare, + prepareBody(selection), + ) return data.data } +/** The one selection key the request carries, plus the grouping when set. */ +function prepareBody(selection: BillingSelection): Record { + const body: Record = + 'taskIds' in selection + ? { task_ids: selection.taskIds } + : 'projectId' in selection + ? { project_id: selection.projectId } + : { entry_ids: selection.entryIds } + + if (selection.grouping !== undefined) { + body.grouping = selection.grouping + } + + return body +} + /** * Stamp the entries with the ids the host handed back. * - * Idempotent, so a wizard that created the invoice and then lost the stamp can + * Idempotent, so a flow that created the invoice and then lost the stamp can * offer the same call again rather than a second invoice. */ export async function confirmInvoice( @@ -108,16 +127,23 @@ export async function confirmInvoice( return data?.stamped ?? 0 } -/** The company's contacts, for turning a customer id into a name. */ -export async function listBillingCustomers( +/** + * One contact, read for the currency it settles in. + * + * The host's invoice endpoint compares the *contact's* currency with the + * company setting to decide whether an exchange rate is required, so the + * answer has to come from the contact rather than from the currency the time + * happened to be logged in. + */ +export async function fetchBillingCustomer( client: AxiosInstance, - limit = CUSTOMER_LOOKUP_LIMIT, -): Promise { - const { data } = await client.get>(HOST_BILLING_API.customers, { - params: { limit }, - }) + customerId: number, +): Promise { + const { data } = await client.get>( + HOST_BILLING_API.customer(customerId), + ) - return data.data ?? [] + return data?.data ?? null } /** Create the draft invoice with the session's own client. */ diff --git a/resources/js/components/InvoiceNumberModal.vue b/resources/js/components/InvoiceNumberModal.vue new file mode 100644 index 0000000..a3fccc1 --- /dev/null +++ b/resources/js/components/InvoiceNumberModal.vue @@ -0,0 +1,81 @@ + + + diff --git a/resources/js/components/InvoiceRetryBanner.vue b/resources/js/components/InvoiceRetryBanner.vue new file mode 100644 index 0000000..0f65299 --- /dev/null +++ b/resources/js/components/InvoiceRetryBanner.vue @@ -0,0 +1,91 @@ + + + diff --git a/resources/js/messages/billing.ts b/resources/js/messages/billing.ts index b84d26b..017ee3b 100644 --- a/resources/js/messages/billing.ts +++ b/resources/js/messages/billing.ts @@ -1,5 +1,5 @@ /** - * Every string the billing wizard renders. + * Every string invoicing renders, wherever it is started from. * * Kept beside the slice that owns it rather than in `messages.ts`, so two * slices of the module never edit the same catalogue. The host merges each @@ -10,34 +10,62 @@ export const billingMessages = { en: { tasks_projects: { billing: { - title: 'Invoice time', - subtitle: 'Turn unbilled hours into a draft invoice.', - invoice_time: 'Invoice time', - unbilled: 'Unbilled', - view_unbilled: 'Invoice this time', - steps: { - customer: 'Customer', - entries: 'Entries', - preview: 'Preview', - create: 'Create', + title: 'Unbilled time', + subtitle: 'Time that has not reached an invoice yet, by customer.', + back: 'Back to customers', + create: 'Create invoice', + busy: 'Creating the invoice', + + // What the sequence says when it cannot finish. + prepare_failed: 'Unable to prepare the invoice.', + create_failed: 'Unable to create the invoice.', + rate_failed: 'Unable to read the exchange rate; the invoice was created without one.', + forbidden: 'You are not allowed to invoice time.', + nothing_to_invoice: 'No unbilled billable time on the selected tasks.', + mixed_customers: 'Select tasks of one customer. This selection spans {count} customers.', + mixed_selection: 'One invoice covers one customer in one currency. Narrow the selection.', + pending_stamp: + 'Finish marking the last invoice as billed before creating another one.', + created: 'Invoice {number} was created.', + stamped: '{count} time entries were marked as invoiced.', + stamp_failed: 'Unable to mark the time as invoiced.', + stamp_failed_notice: + 'Invoice {number} was created, but its time is not marked as invoiced yet.', + stamp_unmatched: + 'Invoice {number} was created, but its lines could not be matched back to the time behind them.', + + number: { + title: 'Invoice number', + description: + 'This company numbers its invoices by hand, so the draft needs a number before it can be created.', + label: 'Number', + save: 'Create invoice', }, - back: 'Back', - next: 'Continue', - start_over: 'Start over', + + retry: { + title: 'The invoice was created, but the time is not marked yet', + description: + 'Invoice {number} exists. Its time entries still count as unbilled until they are marked, which is safe to run again.', + action: 'Retry stamping', + open_invoice: 'Open the invoice', + dismiss: 'Forget this invoice', + dismiss_confirm: + 'Forget this invoice? Its time stays unbilled and can reach a second invoice.', + }, + customer: { - title: 'Who are you invoicing?', + title: 'Who has time waiting?', description: 'Customers with billable time that has not reached an invoice yet.', entries: '{count} entries', empty_title: 'Nothing to invoice', empty_description: 'Billable time appears here once it has been logged against a task that belongs to a customer.', load_failed: 'Unable to load the customers with unbilled time.', - names_failed: 'Unable to load the customer names; ids are shown instead.', - unnamed: 'Customer #{id}', from: 'From', to: 'To', clear_range: 'Clear dates', }, + entries: { title: 'Which time goes on the invoice?', grouping: 'Group lines by', @@ -64,45 +92,6 @@ export const billingMessages = { load_failed: 'Unable to load the unbilled time.', none_selected: 'Select at least one entry.', }, - preview: { - title: 'Check the invoice', - lines: 'Invoice lines', - columns: { - description: 'Description', - quantity: 'Hours', - price: 'Rate', - total: 'Amount', - }, - sub_total: 'Subtotal', - total: 'Total', - invoice_date: 'Invoice date', - due_date: 'Due date', - invoice_number: 'Invoice number', - invoice_number_auto: 'Generated by the company number format.', - template: 'Template', - exchange_rate: 'Exchange rate', - exchange_rate_help: '1 {currency} in the company currency.', - prepare_failed: 'Unable to prepare the invoice.', - templates_failed: 'Unable to load the invoice templates.', - number_failed: 'Unable to read the next invoice number. Type one in.', - rate_failed: 'Unable to read the exchange rate. Type one in.', - create: 'Create invoice', - invalid: 'The invoice was refused. Fix the fields below and try again.', - }, - create: { - creating: 'Creating the invoice', - stamping: 'Marking the time as invoiced', - created_title: 'Invoice {number} created', - created_description: '{count} entries were marked as invoiced.', - view_invoice: 'Open the invoice', - invoice_more: 'Invoice more time', - failed: 'Unable to create the invoice.', - stamp_failed_title: 'The invoice was created, but the time is not marked yet', - stamp_failed_description: - 'Invoice {number} exists. The time entries still count as unbilled until they are stamped, which is safe to run again.', - retry_stamp: 'Retry stamping', - stamped: 'The time entries were marked as invoiced.', - }, }, }, }, diff --git a/resources/js/stores/invoicing.ts b/resources/js/stores/invoicing.ts new file mode 100644 index 0000000..ca09d66 --- /dev/null +++ b/resources/js/stores/invoicing.ts @@ -0,0 +1,118 @@ +import { reactive } from 'vue' +import type { ConfirmItem } from '@/types/billing' + +/** + * What the invoicing sequence has in flight, shared by every screen that can + * start one. + * + * A module bundle has no Pinia, so this is a plain reactive singleton. Three + * facts live here rather than in a component, because all three outlive the + * screen that caused them: + * + * - `busy` is one lock for the whole module. Invoicing is four requests long, + * and a second click on another row while the first is still running would + * race the same time entries onto two invoices. + * - `pending` is an invoice the host created whose entries were not stamped. + * It is the one state the user has to resolve, because the invoice exists + * and the time still reads as unbilled, so it is kept until the retry works + * rather than lost with the page. + * - `allowed` is what a 403 taught us. The module settings endpoint does not + * say whether the caller may invoice, so the first refusal does, and the + * actions stop offering themselves for the rest of the session. + */ + +/** An invoice that exists, with the stamp that did not land on its entries. */ +export interface PendingStamp { + invoiceId: number + /** For the banner, so it names the invoice the user is looking at. */ + invoiceNumber: string + items: ConfirmItem[] +} + +/** A number the sequence is waiting for the user to type. */ +interface NumberPrompt { + suggested: string + resolve: (value: string | null) => void +} + +interface InvoicingState { + /** True while a prepare-create-confirm sequence is running. */ + busy: boolean + pending: PendingStamp | null + /** False once the server has refused the ability once. */ + allowed: boolean + prompt: NumberPrompt | null +} + +export const invoicingStore = reactive({ + busy: false, + pending: null, + allowed: true, + prompt: null, +}) + +/** Take the module-wide lock, or say that someone else holds it. */ +export function lockInvoicing(): boolean { + if (invoicingStore.busy) { + return false + } + + invoicingStore.busy = true + + return true +} + +export function unlockInvoicing(): void { + invoicingStore.busy = false +} + +/** Remember an invoice whose entries are still unstamped. */ +export function holdStamp(pending: PendingStamp): void { + invoicingStore.pending = pending +} + +export function clearStamp(): void { + invoicingStore.pending = null +} + +/** The server refused the ability, so stop offering the action. */ +export function denyInvoicing(): void { + invoicingStore.allowed = false +} + +/** + * Ask the user for the invoice number and wait for the answer. + * + * The modal that answers is mounted once in the company layout rather than by + * every screen that can invoice, so the sequence can ask from anywhere without + * each caller carrying a dialog of its own. A second ask while one is open + * cancels the first, which cannot happen while `busy` holds but keeps the + * promise from being dropped if it ever did. + */ +export function askInvoiceNumber(suggested: string): Promise { + answerInvoiceNumber(null) + + return new Promise((resolve) => { + invoicingStore.prompt = { suggested, resolve } + }) +} + +/** Hand the sequence the number, or null when the user backed out. */ +export function answerInvoiceNumber(value: string | null): void { + const prompt = invoicingStore.prompt + + if (prompt === null) { + return + } + + invoicingStore.prompt = null + prompt.resolve(value) +} + +/** Forget the previous company: invoices and abilities belong to one. */ +export function resetInvoicing(): void { + answerInvoiceNumber(null) + invoicingStore.busy = false + invoicingStore.pending = null + invoicingStore.allowed = true +} diff --git a/resources/js/support/invoicing.ts b/resources/js/support/invoicing.ts new file mode 100644 index 0000000..791925b --- /dev/null +++ b/resources/js/support/invoicing.ts @@ -0,0 +1,459 @@ +import type { AxiosInstance } from 'axios' +import type { Router } from 'vue-router' +import { + confirmInvoice, + createInvoice, + fetchBillingCustomer, + fetchCompanyInvoiceDefaults, + fetchExchangeRate, + fetchNextInvoiceNumber, + listInvoiceTemplates, + prepareInvoice, +} from '@/api/billing' +import { + askInvoiceNumber, + clearStamp, + denyInvoicing, + holdStamp, + invoicingStore, + lockInvoicing, + unlockInvoicing, +} from '@/stores/invoicing' +import { bumpTaskVersion } from '@/stores/tasks' +import { errorMessage } from '@/support/errors' +import { toDateString } from '@/support/format' +import { errorCode, errorStatus } from '@/support/http' +import type { Translate } from '@/support/i18n' +import type { Notify } from '@/support/page' +import type { + BillingSelection, + CompanyInvoiceDefaults, + ConfirmItem, + CreatedInvoice, + InvoicePayload, + PreparedInvoice, +} from '@/types/billing' + +/** + * Turning a selection of work into a draft invoice, in one sequence. + * + * Every entry point in the module, a task row, the bulk bar, a task page, a + * project header and the unbilled time page, runs this and nothing else, so + * "invoice this" means the same four steps wherever it is pressed: + * + * 1. `billing/prepare` turns the selection into the body the host takes. + * 2. The host's own defaults fill in what its create form would have: the due + * date, the template, the number and an exchange rate when the contact does + * not settle in the company currency. + * 3. The host's `POST /api/v1/invoices` writes the invoice, with the session's + * own client, so the module never touches the host invoice tables. + * 4. `billing/confirm` stamps the entries with the line ids that came back, + * and the user lands on the host's edit page for the draft. + * + * Nothing here throws at a caller: every refusal becomes a notification, and + * the one failure that leaves work behind, a created invoice whose entries were + * not stamped, is parked in the store for `InvoiceRetryBanner` to finish. + */ + +/** Where the host mounts the invoice screens. */ +const INVOICES = '/admin/invoices' + +/** Flip to trace the sequence in the console while working on it. */ +const DEBUG = false + +export interface InvoicingDeps { + client: AxiosInstance + /** The host router, which module pages receive as a prop. */ + router: Router + notify: Notify + t: Translate +} + +/** + * Invoice a selection and open the draft. + * + * Answers whether an invoice was created and stamped, so a caller that wants + * to refresh something of its own knows whether anything changed. The task + * lists refresh themselves: the sequence bumps the shared task version. + */ +export async function invoiceTasks( + deps: InvoicingDeps, + selection: BillingSelection, +): Promise { + const { notify, t } = deps + + // An invoice that exists but whose time still reads as unbilled has to be + // finished before another is started, or the same hours reach two invoices. + if (invoicingStore.pending !== null) { + notify('warning', t('tasks_projects.billing.pending_stamp')) + + return false + } + + if (!lockInvoicing()) { + return false + } + + try { + return await run(deps, selection) + } finally { + unlockInvoicing() + } +} + +/** + * Stamp the entries of an invoice that was created but never confirmed. + * + * `billing/confirm` is idempotent, so this is always safe to press again, and + * it is the only way back from a half-finished invoice that does not risk a + * second one. + */ +export async function retryStamp( + client: AxiosInstance, + notify: Notify, + t: Translate, +): Promise { + const pending = invoicingStore.pending + + if (pending === null || !lockInvoicing()) { + return false + } + + try { + const stamped = await confirmInvoice(client, pending.invoiceId, pending.items) + + debug('stamped on retry', stamped) + clearStamp() + bumpTaskVersion() + notify('success', t('tasks_projects.billing.stamped', { count: stamped })) + + return true + } catch (error: unknown) { + notify('error', errorMessage(error, t('tasks_projects.billing.stamp_failed'))) + + return false + } finally { + unlockInvoicing() + } +} + +/** Where the host shows an invoice that exists, for links out of the module. */ +export function invoiceViewPath(invoiceId: number): string { + return `${INVOICES}/${invoiceId}/view` +} + +async function run(deps: InvoicingDeps, selection: BillingSelection): Promise { + const { client, notify, t } = deps + + let prepared: PreparedInvoice + + try { + prepared = await prepareInvoice(client, selection) + } catch (error: unknown) { + reportPrepareFailure(deps, error) + + return false + } + + debug('prepared', prepared) + + if (!Array.isArray(prepared.items) || prepared.items.length === 0) { + notify('warning', t('tasks_projects.billing.nothing_to_invoice')) + + return false + } + + // All three are the host's own answers and none of them is fatal: a company + // whose bootstrap or template list cannot be read still gets an invoice, + // with the same fields its create form would have left blank. + const [defaults, templates, customer] = await Promise.all([ + fetchCompanyInvoiceDefaults(client).catch((): null => null), + listInvoiceTemplates(client).catch((): [] => []), + fetchBillingCustomer(client, prepared.customer_id).catch((): null => null), + ]) + + const currencyId = customer?.currency_id ?? customer?.currency?.id ?? prepared.currency_id + const homeCurrencyId = defaults?.currency?.id ?? null + const foreign = homeCurrencyId !== null && currencyId !== null && currencyId !== homeCurrencyId + + const invoiceNumber = await resolveNumber(deps, defaults, prepared.customer_id) + + if (invoiceNumber === null) { + return false + } + + let exchangeRate: number | null = null + + if (foreign && currencyId !== null) { + exchangeRate = await fetchExchangeRate(client, currencyId).catch((): null => null) + + if (exchangeRate === null) { + notify('warning', t('tasks_projects.billing.rate_failed')) + } + } + + const payload = invoicePayload(prepared, { + invoiceNumber, + currencyId, + exchangeRate, + dueDate: defaultDueDate(prepared.invoice_date, defaults), + templateName: defaults?.defaultTemplate ?? templates[0]?.name ?? '', + }) + + debug('creating', payload) + + let invoice: CreatedInvoice + + try { + invoice = await createInvoice(client, payload) + } catch (error: unknown) { + notify('error', errorMessage(error, t('tasks_projects.billing.create_failed'))) + + return false + } + + debug('created', invoice) + + const stamped = await stamp(deps, invoice, prepared) + + bumpTaskVersion() + + if (!stamped) { + return false + } + + notify('success', t('tasks_projects.billing.created', { number: invoice.invoice_number })) + await openInvoice(deps.router, invoice.id) + + return true +} + +/** + * Say why `prepare` refused, in the words of the screen that asked. + * + * The three answers worth naming are the ones a person can act on: a selection + * spanning two customers, a selection with no money in it, and an ability the + * caller does not have. Everything else keeps the server's own message. + */ +function reportPrepareFailure(deps: InvoicingDeps, error: unknown): void { + const { notify, t } = deps + const code = errorCode(error) + + if (code === 'mixed_billing_selection') { + const customers = customerCount(error) + + // The same refusal covers two customers and two currencies, and only the + // first names ids, so the counted sentence is used only when it is true. + notify( + 'error', + customers > 1 + ? t('tasks_projects.billing.mixed_customers', { count: customers }) + : errorMessage(error, t('tasks_projects.billing.mixed_selection')), + ) + + return + } + + if (code === 'nothing_to_invoice') { + notify('warning', t('tasks_projects.billing.nothing_to_invoice')) + + return + } + + if (errorStatus(error) === 403) { + denyInvoicing() + notify('error', t('tasks_projects.billing.forbidden')) + + return + } + + notify('error', errorMessage(error, t('tasks_projects.billing.prepare_failed'))) +} + +/** How many customers the refused selection spanned, as the body reported. */ +function customerCount(error: unknown): number { + if (typeof error !== 'object' || error === null) { + return 0 + } + + const data = (error as { response?: { data?: unknown } }).response?.data + + if (typeof data !== 'object' || data === null) { + return 0 + } + + const ids = (data as { customer_ids?: unknown }).customer_ids + + return Array.isArray(ids) ? ids.length : 0 +} + +/** + * The number the invoice will carry. + * + * A company that lets the host number its invoices gets the next one without + * being asked. One that numbers them by hand, or a host that could not answer, + * is asked, with whatever the endpoint did say filled in. Backing out of that + * question is a cancelled invoice, not an invoice with a blank number. + */ +async function resolveNumber( + deps: InvoicingDeps, + defaults: CompanyInvoiceDefaults | null, + customerId: number, +): Promise { + const suggested = await fetchNextInvoiceNumber(deps.client, customerId).catch((): null => null) + + if (defaults?.autoGenerateNumber !== false && suggested !== null) { + return suggested + } + + return askInvoiceNumber(suggested ?? '') +} + +/** The due date the host's own form would have filled in, or none. */ +function defaultDueDate(invoiceDate: string, defaults: CompanyInvoiceDefaults | null): string | null { + if (defaults === null || !defaults.setDueDateAutomatically) { + return null + } + + const due = new Date(`${invoiceDate}T00:00:00`) + + if (Number.isNaN(due.getTime())) { + return null + } + + due.setDate(due.getDate() + defaults.dueDateDays) + + return toDateString(due) +} + +interface InvoiceFields { + invoiceNumber: string + currencyId: number | null + exchangeRate: number | null + dueDate: string | null + templateName: string +} + +/** + * The body the host invoice endpoint takes. + * + * Only the keys it validates or stores: the lines arrive with their zeroed + * discount and tax fields so the host's item writer never reaches for a + * missing index, and the totals are the module's arithmetic, which the host + * recomputes from the same lines before it saves anything. + */ +function invoicePayload(payload: PreparedInvoice, fields: InvoiceFields): InvoicePayload { + return { + invoice_date: payload.invoice_date, + due_date: fields.dueDate, + customer_id: payload.customer_id, + invoice_number: fields.invoiceNumber, + // The host stores the contact's currency whatever is sent, and reads this + // only to decide whether the rate applies, so the contact's is what goes. + currency_id: fields.currencyId, + exchange_rate: fields.exchangeRate, + discount: payload.discount, + discount_type: payload.discount_type, + discount_val: payload.discount_val, + tax: payload.tax, + sub_total: payload.sub_total, + total: payload.total, + tax_included: false, + notes: payload.notes, + template_name: fields.templateName, + items: payload.items.map((item) => ({ ...item })), + taxes: [], + } +} + +/** + * Hand the created line ids back to the module. + * + * `groups[i]` was produced alongside `items[i]`, and the host writes the lines + * in the order they were posted, so zipping them positionally pairs each line + * with the entries behind it. A failure here leaves a live invoice and unbilled + * time, which the banner offers to fix: the call is idempotent. + */ +async function stamp( + deps: InvoicingDeps, + invoice: CreatedInvoice, + payload: PreparedInvoice, +): Promise { + const { client, notify, t } = deps + const items = confirmItems(invoice, payload) + + if (items.length === 0) { + // Nothing to retry with: the host answered without the line ids, so the + // invoice is real and the time behind it can only be matched by hand. + notify('error', t('tasks_projects.billing.stamp_unmatched', { number: invoice.invoice_number })) + await openInvoice(deps.router, invoice.id) + + return false + } + + try { + const stamped = await confirmInvoice(client, invoice.id, items) + + debug('stamped', stamped) + + return true + } catch (error: unknown) { + holdStamp({ invoiceId: invoice.id, invoiceNumber: invoice.invoice_number, items }) + notify( + 'error', + errorMessage( + error, + t('tasks_projects.billing.stamp_failed_notice', { number: invoice.invoice_number }), + ), + ) + + return false + } +} + +/** Each created line paired with the entries that produced it. */ +function confirmItems(invoice: CreatedInvoice, payload: PreparedInvoice): ConfirmItem[] { + const lines = Array.isArray(invoice.items) ? invoice.items : [] + const groups = Array.isArray(payload.groups) ? payload.groups : [] + const items: ConfirmItem[] = [] + + groups.forEach((group, index) => { + const line = lines[index] + + if (line && typeof line.id === 'number' && group.entry_ids.length > 0) { + items.push({ invoice_item_id: line.id, entry_ids: group.entry_ids }) + } + }) + + return items +} + +/** + * Land on the host's edit page, or its view page when the guard refuses. + * + * Editing an invoice is its own host ability, and the module's own one does + * not imply it, so a caller who may invoice but not edit still gets taken to + * the invoice rather than left on the screen they pressed. + */ +async function openInvoice(router: Router, invoiceId: number): Promise { + if (await push(router, `${INVOICES}/${invoiceId}/edit`)) { + return + } + + await push(router, invoiceViewPath(invoiceId)) +} + +/** Whether the navigation actually landed. */ +async function push(router: Router, path: string): Promise { + try { + return !(await router.push(path)) + } catch { + return false + } +} + +function debug(label: string, value: unknown): void { + if (DEBUG) { + console.debug(`[tasks-projects] invoicing: ${label}`, value) + } +} diff --git a/resources/js/types/billing.ts b/resources/js/types/billing.ts index 07c798b..358c648 100644 --- a/resources/js/types/billing.ts +++ b/resources/js/types/billing.ts @@ -1,11 +1,11 @@ /** - * Everything the billing wizard passes between the module and the host. + * Everything the invoicing flow passes between the module and the host. * * Money is integer minor units on both sides of the boundary and `quantity` is * decimal hours, which is what the host's own invoice form posts. The shapes * under "module" come from `billing/*`; the ones under "host" are the host's * own invoice, template, number and currency endpoints, typed here only as far - * as the wizard reads them. + * as the flow reads them. */ import type { Customer } from '@/types/api' @@ -13,6 +13,32 @@ import type { Customer } from '@/types/api' /** How a selection is collapsed into invoice lines. */ export type BillingGrouping = 'task' | 'project' | 'member' | 'summary' +/** + * What to invoice, in exactly one of the three shapes the endpoint takes. + * + * The screens name whichever they know: the unbilled time page ticks entries + * off, a row, a task page or a bulk selection names tasks, and a project + * header names a project. They are mutually exclusive, which is what the + * request rules enforce, so the union is spelled out rather than left as one + * object with three optional keys. + */ +export interface EntryIdSelection { + entryIds: number[] + grouping?: BillingGrouping +} + +export interface TaskIdSelection { + taskIds: number[] + grouping?: BillingGrouping +} + +export interface ProjectSelection { + projectId: number + grouping?: BillingGrouping +} + +export type BillingSelection = EntryIdSelection | TaskIdSelection | ProjectSelection + /** A customer with time waiting to be invoiced, in one currency. */ export interface UnbilledCustomer { customer_id: number @@ -114,7 +140,7 @@ export interface InvoicePayloadItem extends PreparedItem { * The body posted to the host's `POST /api/v1/invoices`. * * Only the keys the host validates or stores: it recomputes the totals from - * the lines, so what is sent here is the preview's arithmetic offered for + * the lines, so what is sent here is the module's arithmetic offered for * checking rather than a figure the host trusts. */ export interface InvoicePayload { @@ -137,7 +163,7 @@ export interface InvoicePayload { taxes: unknown[] } -/** The invoice the host answers with, as far as the wizard reads it. */ +/** The invoice the host answers with, as far as the module reads it. */ export interface CreatedInvoice { id: number invoice_number: string @@ -170,10 +196,10 @@ export interface BillingCustomer extends Customer { /** * The company's own invoice defaults, read from the host bootstrap payload. * - * A module bundle cannot reach the host's company store, so the wizard asks - * the same endpoint the shell does and keeps only the settings the preview - * step needs: the home currency, the due-date rule, whether numbers generate - * themselves and which template the user last defaulted to. + * A module bundle cannot reach the host's company store, so the module asks + * the same endpoint the shell does and keeps only the settings a draft needs: + * the home currency, the due-date rule, whether numbers generate themselves + * and which template the user last defaulted to. */ export interface CompanyInvoiceDefaults { currency: CurrencyFormat | null From 20d10793e8176a43e1c2a272fef834c9da2cac99 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 10:10:45 +0200 Subject: [PATCH 2/8] feat(ui): invoice from the row, the selection, the task and the project The placeholders the UI slice left behind now do the thing they promised. A task row and the task page invoice one task, the bulk bar invoices the selection as one invoice, and the project overview card and the project header invoice everything unbilled on a project. Each of them is a call to the same sequence, so none of them has an opinion of its own: the selection travels as task ids or a project id and the server decides what is still billable. Sending the bulk selection together rather than looping is the point of it, because that is what turns two customers into one clear message instead of two invoices. The actions show themselves only when they would mean something. A task with nothing billable left, or one already on an invoice, keeps the entry in the menu but refuses it with a title saying which of the two it is, so the menu never changes shape as work is logged. Everything hides once the server has refused the ability once. The retry banner rides on the Tasks screen, the task page and the project page, which is where someone will be standing when a stamp fails, and the task list and board refresh themselves through the shared task version. --- resources/js/components/BulkActionBar.vue | 45 +++++---- resources/js/components/TaskList.vue | 97 +++++++++++++++++-- resources/js/messages/projects.ts | 1 - resources/js/messages/tasks.ts | 3 +- resources/js/pages/ProjectDetailPage.vue | 66 +++++++++++++ resources/js/pages/TaskPage.vue | 52 +++++++++- resources/js/pages/TasksPage.vue | 3 + .../js/pages/project/ProjectOverviewTab.vue | 67 ++++++++++--- .../js/pages/project/ProjectTasksTab.vue | 4 + resources/js/pages/tasks/TasksListView.vue | 4 + 10 files changed, 296 insertions(+), 46 deletions(-) diff --git a/resources/js/components/BulkActionBar.vue b/resources/js/components/BulkActionBar.vue index 8151d1c..fce0df8 100644 --- a/resources/js/components/BulkActionBar.vue +++ b/resources/js/components/BulkActionBar.vue @@ -4,17 +4,25 @@ import { useTranslate } from '@/support/i18n' import type { SelectOption } from '@/types/board' import type { TaskStatus } from '@/types/task-status' -const props = defineProps<{ - /** How many tasks the selection holds. The bar hides at zero. */ - count: number - statuses: TaskStatus[] - /** True while a bulk request is in flight. */ - busy: boolean -}>() +const props = withDefaults( + defineProps<{ + /** How many tasks the selection holds. The bar hides at zero. */ + count: number + statuses: TaskStatus[] + /** True while a bulk request is in flight. */ + busy: boolean + /** True while the invoicing sequence is running, anywhere in the module. */ + invoicing?: boolean + /** False once the server has refused the ability to invoice. */ + canInvoice?: boolean + }>(), + { invoicing: false, canInvoice: true }, +) const emit = defineEmits<{ (event: 'status', statusId: number): void (event: 'delete'): void + (event: 'invoice'): void (event: 'clear'): void (event: 'select-page'): void }>() @@ -63,16 +71,19 @@ watch(status, (option) => { {{ t('tasks_projects.tasks.bulk.delete') }} - - - - - {{ t('tasks_projects.tasks.bulk.invoice') }} - - + + + {{ t('tasks_projects.tasks.bulk.invoice') }} +