The earlier change only relabeled is_monthly_draft rows. But a per-event invoice created from hours is status 'scheduled' with scheduled_send_at = NULL and is_monthly_draft = false — it never auto-ships (the scheduler only picks rows with scheduled_send_at <= now), yet it still read "Scheduled" on the customer panel + lists. Add a shared isDraftInvoice() helper (scheduled && no send date, or a monthly/manual accumulator) and use it for the badge in the Bills list, the invoice detail header, and the customer profile's invoice panel. A scheduled invoice WITH a future send date keeps "Scheduled".
421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
/**
|
|
* Admin → Invoices API client. Hits /api/admin/invoices/*.
|
|
*/
|
|
import { api } from '../config/api';
|
|
import type { QuoteLineItem } from './quotes.service';
|
|
|
|
// `'skipped'` is the post-fact status of a monthly draft that aged out
|
|
// of its period with zero line items (migration 128 monthly pass).
|
|
// Carried in the row for audit-trail continuity; never visible to the
|
|
// customer, never counted in tax/dashboard aggregates.
|
|
export type InvoiceStatus = 'scheduled' | 'sent' | 'paid' | 'overdue' | 'cancelled' | 'pending_delivery' | 'skipped';
|
|
/**
|
|
* Document discriminator. `'invoice'` is the default; `'storno'` rows
|
|
* are Stornorechnungen (cancellation invoices) generated by the
|
|
* cancel/reissue flow. Both share the table + sequence, distinguished
|
|
* here so the UI can render the correct title/badge and gate actions
|
|
* (a Storno can't be cancelled, paid, or reissued).
|
|
*/
|
|
export type InvoiceKind = 'invoice' | 'storno';
|
|
export type InvoiceSort =
|
|
| 'newest' | 'oldest'
|
|
| 'issue_asc' | 'issue_desc'
|
|
| 'due_asc' | 'due_desc'
|
|
| 'value_asc' | 'value_desc'
|
|
| 'customer_asc' | 'customer_desc';
|
|
|
|
export type InvoiceQrFormat = 'swiss' | 'epc' | 'none';
|
|
|
|
export interface InvoiceSummary {
|
|
id: number;
|
|
/** 'invoice' (default) or 'storno' (cancellation document). Drives
|
|
* badge rendering + action gating in the admin list and detail
|
|
* views. */
|
|
kind: InvoiceKind;
|
|
invoiceNumber: string;
|
|
/** Cross-document lineage UUID (migration 140). See the equivalent
|
|
* field on QuoteSummary. */
|
|
dealUuid: string | null;
|
|
customerAccountId: number;
|
|
customer: {
|
|
email: string | null;
|
|
displayName: string | null;
|
|
firstName: string | null;
|
|
lastName: string | null;
|
|
companyName: string | null;
|
|
/** Computed server-side from password_hash. true = admin-only
|
|
* customer (no portal access). Drives the Passive badge that
|
|
* appears next to the customer label across editor + lists. */
|
|
isPassive?: boolean;
|
|
};
|
|
sourceQuoteId: number | null;
|
|
/** Migration 130 — set by contractService.convertToInvoiceOnly when
|
|
* the invoice originates from a contract. */
|
|
sourceContractId?: number | null;
|
|
/** Human-readable quote number of the source quote (joined-in on read).
|
|
* Surfaced on the invoice detail view so the link reads
|
|
* "From quote LBM-Q-2026-0006" rather than "From quote #6". */
|
|
sourceQuoteNumber?: string | null;
|
|
/** Human-readable contract number of the source contract (joined-in
|
|
* on read). Surfaced on the invoice detail view so the link reads
|
|
* "From contract LBM-C-2026-0010" rather than "From contract #10". */
|
|
sourceContractNumber?: string | null;
|
|
eventId: number | null;
|
|
/** Inline event snapshot (migration 123). Free-text label for the
|
|
* event the invoice relates to. Persists independently of the
|
|
* events FK so renames don't retroactively change historical
|
|
* documents. Shown next to the invoice number on the customer
|
|
* portal and as the "Event" column on the admin list. */
|
|
eventName: string | null;
|
|
/** Event date snapshot. */
|
|
eventDate: string | null;
|
|
/** Event start time, "HH:MM". */
|
|
eventTimeStart: string | null;
|
|
/** Event end time, "HH:MM". */
|
|
eventTimeEnd: string | null;
|
|
language: string;
|
|
currency: string;
|
|
issueDate: string;
|
|
dueDate: string;
|
|
installmentIndex: number;
|
|
installmentTotal: number;
|
|
installmentLabel: string | null;
|
|
installmentTrigger: string | null;
|
|
status: InvoiceStatus;
|
|
scheduledSendAt: string | null;
|
|
sentAt: string | null;
|
|
totalAmountMinor: number;
|
|
paidAmountMinor: number;
|
|
reminderLevel: number;
|
|
lateFeeAmountMinor: number;
|
|
/** True when the invoice was attached from a historical PDF
|
|
* (migration 111). Hide line-item editing on these rows; the
|
|
* uploaded PDF is the source of truth. */
|
|
isImported?: boolean;
|
|
/** True for the running monthly/manual accumulator draft
|
|
* (migration 128). Carries status 'scheduled' but never auto-sends
|
|
* (manual) — shown with a "Draft" badge in the list. */
|
|
isMonthlyDraft?: boolean;
|
|
}
|
|
|
|
/**
|
|
* A "scheduled" invoice with no send date is HELD — the scheduler only
|
|
* picks up rows whose `scheduled_send_at <= now`, so a null send date
|
|
* means it never auto-ships and is waiting on the admin (Send now /
|
|
* Trigger invoice now). Those, plus monthly/manual accumulators, read as
|
|
* "Draft" everywhere instead of the misleading "Scheduled". A scheduled
|
|
* invoice WITH a future send date is genuinely scheduled and keeps that
|
|
* label.
|
|
*/
|
|
export function isDraftInvoice(inv: Pick<InvoiceSummary, 'status' | 'scheduledSendAt' | 'isMonthlyDraft'>): boolean {
|
|
if (inv.isMonthlyDraft) return true;
|
|
return inv.status === 'scheduled' && !inv.scheduledSendAt;
|
|
}
|
|
|
|
export interface InvoiceDetail extends InvoiceSummary {
|
|
netAmountMinor: number;
|
|
vatRate: number | null;
|
|
vatAmountMinor: number;
|
|
shippingAmountMinor: number;
|
|
paidAt: string | null;
|
|
paymentMethod: string | null;
|
|
paymentReference: string | null;
|
|
lastReminderSentAt: string | null;
|
|
ccPdfEmail: string | null;
|
|
qrFormat: InvoiceQrFormat | null;
|
|
pdfPath: string | null;
|
|
businessBankAccountId: number | null;
|
|
/** Selected payment-term template id (migration 113). When set
|
|
* the renderer uses this template's snapshot for the
|
|
* Zahlungsbedingungen block; otherwise it falls back to the
|
|
* source quote's snapshot or the global crm_invoices_* defaults. */
|
|
paymentTermTemplateId?: number | null;
|
|
/** Migration 124 — split payment-term picker. The editor prefers
|
|
* these over the legacy single FK; null on legacy rows. Both must
|
|
* be set together for the new path to engage server-side. */
|
|
paymentNetDaysTemplateId?: number | null;
|
|
paymentTimingTemplateId?: number | null;
|
|
/** Effective Skonto percentage resolved server-side from the
|
|
* invoice's payment-term snapshot (with legacy + global fallback).
|
|
* null when no Skonto is configured — used by the BillDetail
|
|
* "Record payment" dialog to decide whether to show the
|
|
* "Paid with Skonto" checkbox. Migration 126. */
|
|
skontoPercent?: number | null;
|
|
/** Per-invoice Skonto opt-out — admin ticked "Disable Skonto" in
|
|
* the editor. When true, `skontoPercent` is suppressed regardless
|
|
* of what the snapshot / global default offers. Migration 126. */
|
|
skontoDisabled?: boolean;
|
|
/** Set when this invoice was created via Cancel & reissue —
|
|
* points at the cancelled original. Migration 114. */
|
|
replacesInvoiceId?: number | null;
|
|
/** On a Storno row (`kind='storno'`): points at the original
|
|
* invoice this Storno reverses. Drives the "Storno zu Rechnung
|
|
* R-XXXX" reference rendering in the admin detail view. */
|
|
cancelsInvoiceId?: number | null;
|
|
/** Human invoice_number of the row referenced by `cancelsInvoiceId`
|
|
* (joined server-side). Used so the lineage banner can display
|
|
* "R-2026-0007" instead of the bare row id "#7". */
|
|
cancelsInvoiceNumber?: string | null;
|
|
/** On a CANCELLED original: points at the Storno that cancelled
|
|
* it. Drives the "Cancelled by Storno S-XXXX" banner shown on
|
|
* the cancelled invoice's detail view. */
|
|
cancellationStornoId?: number | null;
|
|
/** Human invoice_number of the row referenced by
|
|
* `cancellationStornoId` (joined server-side). */
|
|
cancellationStornoNumber?: string | null;
|
|
}
|
|
|
|
export interface InvoicePayment {
|
|
id: number;
|
|
amountMinor: number;
|
|
paidAt: string;
|
|
paymentMethod: string | null;
|
|
reference: string | null;
|
|
notes: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface InvoiceWithLineItems {
|
|
invoice: InvoiceDetail;
|
|
lineItems: QuoteLineItem[];
|
|
payments: InvoicePayment[];
|
|
/** Migration 140 + commit #4. Always present on create; single
|
|
* invoice = one-element array, multi-installment spawn = N. */
|
|
invoiceIds?: number[];
|
|
}
|
|
|
|
export interface InvoiceCreatePayload {
|
|
customerAccountId: number;
|
|
sourceQuoteId?: number;
|
|
eventId?: number;
|
|
language?: string;
|
|
currency?: string;
|
|
issueDate?: string;
|
|
dueDate?: string;
|
|
scheduledSendAt?: string;
|
|
installmentIndex?: number;
|
|
installmentTotal?: number;
|
|
installmentLabel?: string;
|
|
installmentTrigger?: string;
|
|
vatRate?: number;
|
|
/** Migration 130 — snapshot of the chosen output VAT code (null = custom rate). */
|
|
vatCode?: string | null;
|
|
shippingAmountMinor?: number;
|
|
ccPdfEmail?: string;
|
|
// null = explicitly clear the per-invoice override (back to the
|
|
// business-profile default for the currency). undefined = no
|
|
// change. number = pin this specific bank account.
|
|
businessBankAccountId?: number | null;
|
|
qrFormat?: InvoiceQrFormat;
|
|
paymentTermTemplateId?: number | null;
|
|
// Migration 124 — split payment-term picker. Both required by the
|
|
// editor in tandem; backend composes the merged snapshot.
|
|
paymentNetDaysTemplateId?: number | null;
|
|
paymentTimingTemplateId?: number | null;
|
|
// Per-invoice Skonto opt-out (migration 126).
|
|
skontoDisabled?: boolean;
|
|
// Ad-hoc installments (commit #6 of the deal_uuid PR). When set
|
|
// with ≥2 rows, backend spawns N invoices via
|
|
// spawnInstallmentInvoices and returns invoiceIds[]. Single row or
|
|
// omitted → single invoice.
|
|
installments?: import('./quotes.service').PaymentTermInstallment[];
|
|
// Inline event snapshot (migration 123). All optional — standalone
|
|
// invoices may have none of these.
|
|
eventName?: string;
|
|
eventDate?: string;
|
|
eventTimeStart?: string;
|
|
eventTimeEnd?: string;
|
|
lineItems: QuoteLineItem[];
|
|
}
|
|
|
|
export interface InvoiceListResponse {
|
|
invoices: InvoiceSummary[];
|
|
pagination: { total: number; page: number; pageSize: number; totalPages: number };
|
|
}
|
|
|
|
export const billsService = {
|
|
async list(params: {
|
|
status?: InvoiceStatus[];
|
|
customerAccountId?: number;
|
|
sourceQuoteId?: number;
|
|
unpaidOnly?: boolean;
|
|
q?: string;
|
|
sort?: InvoiceSort;
|
|
page?: number;
|
|
pageSize?: number;
|
|
/** Include the running monthly/manual accumulator drafts that the
|
|
* main list hides by default (migration 128). Only the Bills list
|
|
* opts in; pickers/sub-lists leave it off. */
|
|
includeDrafts?: boolean;
|
|
} = {}): Promise<InvoiceListResponse> {
|
|
const { data } = await api.get('/admin/invoices', {
|
|
params: {
|
|
...params,
|
|
status: params.status?.join(','),
|
|
},
|
|
});
|
|
return data.data || data;
|
|
},
|
|
|
|
async get(id: number): Promise<InvoiceWithLineItems> {
|
|
const { data } = await api.get(`/admin/invoices/${id}`);
|
|
return data.data || data;
|
|
},
|
|
|
|
async create(payload: InvoiceCreatePayload): Promise<InvoiceWithLineItems> {
|
|
const { data } = await api.post('/admin/invoices', payload);
|
|
return data.data || data;
|
|
},
|
|
|
|
async update(id: number, payload: Partial<InvoiceCreatePayload>): Promise<InvoiceWithLineItems> {
|
|
const { data } = await api.put(`/admin/invoices/${id}`, payload);
|
|
return data.data || data;
|
|
},
|
|
|
|
async send(id: number): Promise<{ sent: true }> {
|
|
const { data } = await api.post(`/admin/invoices/${id}/send`);
|
|
return data.data || data;
|
|
},
|
|
|
|
async markPaid(id: number, payload: {
|
|
amountMinor: number;
|
|
paidAt?: string;
|
|
paymentMethod?: string;
|
|
reference?: string;
|
|
notes?: string;
|
|
/** Migration 126 — when true the payment-log row records the
|
|
* Skonto discount amount and the invoice is treated as fully
|
|
* settled even though paid_amount_minor < total_amount_minor. */
|
|
skontoApplied?: boolean;
|
|
}): Promise<{ paidTotalMinor: number; status: InvoiceStatus }> {
|
|
const { data } = await api.post(`/admin/invoices/${id}/mark-paid`, payload);
|
|
return data.data || data;
|
|
},
|
|
|
|
async sendReminder(id: number, level?: 1 | 2): Promise<{ level: number; lateFeeMinor: number }> {
|
|
const { data } = await api.post(`/admin/invoices/${id}/send-reminder`, { level });
|
|
return data.data || data;
|
|
},
|
|
|
|
/** Cancel an invoice. For draft (`scheduled`) invoices this is a
|
|
* silent soft-cancel; for issued invoices (`sent`/`overdue`/`paid`)
|
|
* the backend generates a Stornorechnung and emails it to the
|
|
* customer automatically. `stornoId` is non-null in the issued
|
|
* case so callers can navigate to the cancellation document. */
|
|
async cancel(id: number): Promise<{ cancelled: true; stornoId: number | null }> {
|
|
const { data } = await api.post(`/admin/invoices/${id}/cancel`);
|
|
return data.data || data;
|
|
},
|
|
|
|
/** Release a pending_delivery invoice — photographer has delivered
|
|
* the photos and is ready to collect the final installment. The
|
|
* email fires immediately. */
|
|
async releaseForDelivery(id: number): Promise<{ sent: true }> {
|
|
const { data } = await api.post(`/admin/invoices/${id}/release-for-delivery`);
|
|
return data.data || data;
|
|
},
|
|
|
|
/** Fire the admin payment-check email immediately, bypassing the
|
|
* 24h throttle. Useful for testing the flow without waiting for
|
|
* the scheduled reminder window to elapse. */
|
|
async testPaymentCheck(id: number): Promise<{ token: string; sent: true }> {
|
|
const { data } = await api.post(`/admin/invoices/${id}/test-payment-check`);
|
|
return data.data || data;
|
|
},
|
|
|
|
/** Cancel & reissue — atomically cancels this invoice (via Storno
|
|
* when the original was already issued) and creates a fresh
|
|
* scheduled duplicate linked via `replacesInvoiceId`. Returns the
|
|
* new invoice's id, the original's id (`replaces`), and the
|
|
* generated Storno's id (`stornoId`, null when the original was
|
|
* already cancelled and no Storno needed to be created). Caller
|
|
* typically navigates to /admin/clients/bills/:id/edit to adjust
|
|
* before sending. */
|
|
async reissue(id: number): Promise<{ id: number; replaces: number; stornoId: number | null }> {
|
|
const { data } = await api.post(`/admin/invoices/${id}/reissue`);
|
|
return data.data || data;
|
|
},
|
|
|
|
async pdfUrl(id: number): Promise<string> {
|
|
const res = await api.get(`/admin/invoices/${id}/pdf`, { responseType: 'blob' });
|
|
return URL.createObjectURL(res.data);
|
|
},
|
|
|
|
async previewPdfUrl(payload: InvoiceCreatePayload): Promise<string> {
|
|
const res = await api.post('/admin/invoices/preview', payload, { responseType: 'blob' });
|
|
return URL.createObjectURL(res.data);
|
|
},
|
|
|
|
/**
|
|
* Attach a historical invoice PDF (from a previous billing system)
|
|
* to a customer's account. The backend creates a minimal invoice
|
|
* row whose imported_pdf_path points at the uploaded file —
|
|
* customer + admin PDF endpoints stream the original document.
|
|
*/
|
|
async importHistorical(payload: {
|
|
customerAccountId: number;
|
|
invoiceNumber: string;
|
|
eventName?: string;
|
|
eventDate?: string;
|
|
issueDate: string;
|
|
dueDate?: string;
|
|
totalAmountMinor: number;
|
|
currency?: string;
|
|
status?: 'sent' | 'paid' | 'overdue';
|
|
paidAmountMinor?: number;
|
|
language?: string;
|
|
file: File;
|
|
}): Promise<InvoiceWithLineItems> {
|
|
const form = new FormData();
|
|
form.append('pdf', payload.file);
|
|
form.append('customerAccountId', String(payload.customerAccountId));
|
|
form.append('invoiceNumber', payload.invoiceNumber);
|
|
if (payload.eventName) form.append('eventName', payload.eventName);
|
|
if (payload.eventDate) form.append('eventDate', payload.eventDate);
|
|
form.append('issueDate', payload.issueDate);
|
|
if (payload.dueDate) form.append('dueDate', payload.dueDate);
|
|
form.append('totalAmountMinor', String(payload.totalAmountMinor));
|
|
if (payload.currency) form.append('currency', payload.currency);
|
|
if (payload.status) form.append('status', payload.status);
|
|
if (payload.paidAmountMinor != null) form.append('paidAmountMinor', String(payload.paidAmountMinor));
|
|
if (payload.language) form.append('language', payload.language);
|
|
const { data } = await api.post('/admin/invoices/import', form, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
return data.data || data;
|
|
},
|
|
};
|
|
|
|
export interface CrmOverviewStats {
|
|
currency: string;
|
|
quotes: {
|
|
draft: number; sent: number; accepted: number;
|
|
declined: number; expired: number; converted: number;
|
|
};
|
|
invoices: {
|
|
scheduled: number; sent: number; paid: number;
|
|
overdue: number; cancelled: number;
|
|
};
|
|
revenue: {
|
|
monthMinor: number;
|
|
quarterMinor: number;
|
|
yearMinor: number;
|
|
/** Revenue since Jan 1 of the current year (calendar YTD). The
|
|
* dashboard's "year" tile toggles between this and yearMinor. */
|
|
calendarYearMinor: number;
|
|
};
|
|
outstanding: {
|
|
totalMinor: number;
|
|
invoiceCount: number;
|
|
};
|
|
generatedAt: string;
|
|
}
|
|
|
|
/** CRM headline metrics — quote / invoice counts, rolling revenue
|
|
* windows (30 / 90 / 365 days), outstanding payment total. Drives
|
|
* the /admin/clients/overview tab. */
|
|
export async function fetchCrmOverview(): Promise<CrmOverviewStats> {
|
|
const { data } = await api.get('/admin/dashboard/crm-stats');
|
|
return data;
|
|
}
|