Files
picpeak/frontend/src/services/bills.service.ts
T
Luca e4367e028a fix(invoices): badge held (unsent, no send date) invoices as "Draft"
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".
2026-06-29 19:39:40 +02:00

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;
}