feat(accounting): re-bill proof attachment, CRM panel & hours↔re-bills cross-add (#979)

Closes #866.

Three features, all behind the `incomingInvoices` feature flag:

1. Attach the stored supplier proof PDF to the client-invoice email when a
   captured invoice is re-billed/passed through, as a SEPARATE attachment so
   invoice immutability holds. Global default (off), per-customer tri-state
   override, and per-file selection in a new Send dialog. A missing proof at
   issue time stamps inbound_documents.proof_attach_error rather than silently
   dropping, and never blocks the send. Proof filename is a configurable
   template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens.

2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid
   with status derived from the linked invoice lifecycle rather than a
   duplicated column.

3. Cross-add dialog rolling open hours and open re-bills into one invoice,
   symmetric from both entry points. The two stay distinct, contiguous line
   groups — never merged into shared line items.

Migration 169 is additive, hasColumn-guarded and idempotent.

Review (two rounds) closed two concerns:

- Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a
  covering invoice was cancelled, so a Storno'd re-bill showed as Open in the
  new panel while every billing path filters on that column being NULL — the
  supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now
  detaches the linkage on both invoice-cancel paths, with a regression test on
  the issued-cancel path.

- Permission gating: the new controls rendered on data presence alone while
  their endpoints require accounting.view / accounting.manage / customers.edit.
  Now gated at both the query and render layers.

Known follow-up: two cross-add counter queries are gated on a permission their
endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) —
degrades safely, one line each.
This commit is contained in:
Luca
2026-08-03 22:03:31 +02:00
committed by GitHub
parent d66425c8ee
commit 165cebdb5c
25 changed files with 1645 additions and 99 deletions
@@ -0,0 +1,65 @@
/**
* Cross-add dialog (issue #866, Feature 3).
*
* Shown when the admin bills ONE category (hours or re-bills) for a customer
* who also has open items in the OTHER category. Offers to roll both into the
* same invoice. Hours and re-bills are never merged into shared line items —
* they stay as distinct, contiguous groups on the invoice.
*/
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../common';
interface Props {
open: boolean;
/** The category the admin clicked "create invoice" on. */
primary: 'hours' | 'rebills';
/** How many OPEN items exist in the OTHER category. */
otherCount: number;
busy?: boolean;
/** includeOther = true → combine both; false → bill only the primary. */
onConfirm: (includeOther: boolean) => void;
onClose: () => void;
}
export const CrossAddInvoiceDialog: React.FC<Props> = ({ open, primary, otherCount, busy, onConfirm, onClose }) => {
const { t } = useTranslation();
// Escape closes without billing (mirrors the explicit Cancel below).
useEffect(() => {
if (!open) return undefined;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, busy, onClose]);
if (!open) return null;
const other = primary === 'hours' ? 'rebills' : 'hours';
const otherLabel = other === 'hours'
? t('crossAdd.hours', 'open hours')
: t('crossAdd.rebills', 'open re-bills');
const primaryOnlyLabel = primary === 'hours'
? t('crossAdd.hoursOnly', 'Just the hours')
: t('crossAdd.rebillsOnly', 'Just the re-bills');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => !busy && onClose()}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-md mx-4 p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold mb-2 text-lg text-neutral-900 dark:text-neutral-100">
{t('crossAdd.title', 'Add other open items?')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('crossAdd.body',
'This customer also has {{count}} {{label}}. Add them to the same invoice? They stay as a separate group — hours and re-bills are never mixed into one line.',
{ count: otherCount, label: otherLabel })}
</p>
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-between gap-2">
<Button variant="ghost" disabled={busy} onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<div className="flex flex-col-reverse sm:flex-row gap-2">
<Button variant="outline" disabled={busy} onClick={() => onConfirm(false)}>{primaryOnlyLabel}</Button>
<Button disabled={busy} onClick={() => onConfirm(true)}>{t('crossAdd.addBoth', 'Add both')}</Button>
</div>
</div>
</div>
</div>
);
};
@@ -11,16 +11,21 @@
* Lives as a separate component so CustomerDetailPage doesn't need to
* know about CRM types; the panels handle their own data fetching.
*/
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { FileText, Plus, Receipt, ScrollText } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { FileText, Plus, Receipt, ScrollText, Repeat2, AlertTriangle } from 'lucide-react';
import { Card, Button, Loading } from '../common';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { usePermission } from '../../hooks/usePermission';
import { quotesService } from '../../services/quotes.service';
import { billsService, isDraftInvoice } from '../../services/bills.service';
import { contractsService } from '../../services/contracts.service';
import { accountingService, type CustomerRebillItem } from '../../services/accounting.service';
import { customerAdminService } from '../../services/customerAdmin.service';
import { CrossAddInvoiceDialog } from './CrossAddInvoiceDialog';
import { formatMoney } from './LineItemsTable';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -36,6 +41,7 @@ export const CustomerCrmPanels: React.FC<Props> = ({ customerAccountId }) => {
{flags.quotes && <QuotesPanel customerAccountId={customerAccountId} />}
{flags.contracts && <ContractsPanel customerAccountId={customerAccountId} />}
{flags.bills && <InvoicesPanel customerAccountId={customerAccountId} />}
{flags.incomingInvoices && <RebillsPanel customerAccountId={customerAccountId} />}
</>
);
};
@@ -225,3 +231,167 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
</Card>
);
};
// ── Re-bills / passthrough panel (issue #866, Feature 2) ─────────────────────
// History-only, mirrors the Hours section pattern: grouped by derived status
// (Open / Sent / Paid) with a "Create invoice from re-bills" button up top for
// the open pool. When the customer also has open hours, the button opens the
// cross-add dialog first (Feature 3).
const REBILL_STATUS_ORDER: Array<CustomerRebillItem['status']> = ['open', 'sent', 'paid'];
const RebillsPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { flags } = useFeatureFlags();
const { format: fmtDate } = useLocalizedDate();
const navigate = useNavigate();
const qc = useQueryClient();
// Permission gating (#866 review). The endpoints require, respectively:
// view the panel → accounting.view
// "Create invoice from re-bills" (billPendingRebills) → accounting.manage
// cross-add "Add both" (billCombined) → customers.edit
const canView = usePermission('accounting.view');
const canManage = usePermission('accounting.manage');
const canCombine = usePermission('customers.edit');
const [crossAddOpen, setCrossAddOpen] = useState(false);
const [busy, setBusy] = useState(false);
const { data: items = [], isLoading } = useQuery({
queryKey: ['customer-rebills', customerAccountId],
queryFn: () => accountingService.listCustomerRebills(customerAccountId),
enabled: canView,
staleTime: 30_000,
});
// Open hours count for the cross-add offer — only when hours logging is on
// AND the admin can actually create the combined invoice.
const { data: openHours = 0 } = useQuery({
queryKey: ['customer-open-hours-count', customerAccountId],
queryFn: async () => (await customerAdminService.listHourEntries(customerAccountId, 'unbilled')).length,
enabled: !!flags.hoursLogging && canCombine,
staleTime: 30_000,
});
const openItems = items.filter((r) => r.status === 'open');
const onSuccess = (invoiceId: number, msg: string) => {
toast.success(msg);
qc.invalidateQueries({ queryKey: ['customer-rebills', customerAccountId] });
qc.invalidateQueries({ queryKey: ['customer-invoices', customerAccountId] });
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerAccountId] });
qc.invalidateQueries({ queryKey: ['customer-open-hours-count', customerAccountId] });
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
};
const runBill = async (includeHours: boolean) => {
setBusy(true);
try {
if (includeHours) {
const { invoiceId } = await customerAdminService.billCombined(customerAccountId, { includeHours: true, includeRebills: true });
onSuccess(invoiceId, t('rebills.toast.billedCombined', 'Invoice created from re-bills and hours.'));
} else {
const { invoiceId } = await accountingService.billPendingRebills(customerAccountId);
onSuccess(invoiceId, t('rebills.toast.billed', 'Invoice created from re-bills.'));
}
setCrossAddOpen(false);
} catch (e: any) {
toast.error(e?.response?.data?.error || t('rebills.toast.billFailed', 'Failed to create invoice'));
} finally {
setBusy(false);
}
};
const handleCreateInvoice = () => {
// Offer to fold in open hours only when the customer has both AND the admin
// can create the combined invoice (customers.edit); otherwise bill the
// re-bills directly.
if (openHours > 0 && canCombine) setCrossAddOpen(true);
else runBill(false);
};
// No accounting.view → don't render an empty card (query is disabled too).
if (!canView) return null;
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Repeat2 className="w-5 h-5" /> {t('customers.detail.rebillsSection', 'Re-bills & passthrough')}
</h2>
{openItems.length > 0 && canManage && (
<Button size="sm" disabled={busy} onClick={handleCreateInvoice}>
<Plus className="w-4 h-4 mr-1" />{t('rebills.createInvoice', 'Create invoice from re-bills')}
</Button>
)}
</div>
{isLoading ? <Loading /> : items.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.detail.noRebills', 'No re-billed or passed-through supplier invoices for this customer yet.')}
</p>
) : (
<div className="space-y-4">
{REBILL_STATUS_ORDER.map((status) => {
const group = items.filter((r) => r.status === status);
if (group.length === 0) return null;
return (
<div key={status}>
<h3 className="text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-1">
{t(`rebills.status.${status}`, status)} · {group.length}
</h3>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{group.map((r) => (
<li key={r.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm text-neutral-900 dark:text-neutral-100 truncate">
{r.supplierName || t('rebills.unknownSupplier', 'Supplier')}
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
{r.mode === 'passthrough' ? t('rebills.mode.passthrough', 'Passthrough') : t('rebills.mode.rebill', 'Re-bill')}
</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate">
{r.date ? fmtDate(r.date) : ''}
{r.eventName ? ` · ${r.eventName}` : ''}
{r.invoiceNumber ? (
<>
{' · '}
<Link to={`/admin/clients/bills/${r.invoiceId}`} className="hover:underline font-mono">{r.invoiceNumber}</Link>
</>
) : ''}
</div>
{r.proofAttachError && (
<div className="mt-0.5 flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3 h-3 shrink-0" />
{t('rebills.proofError', 'Proof not attached: {{err}}', { err: r.proofAttachError })}
</div>
)}
</div>
<div className="text-right shrink-0">
<div className="text-sm tabular-nums text-neutral-900 dark:text-neutral-100">
{formatMoney(r.rebilledMinor / 100, r.currency)}
</div>
{r.rebilledMinor !== r.costMinor && (
<div className="text-xs text-neutral-400 dark:text-neutral-500 tabular-nums">
{t('rebills.costLabel', 'cost {{amount}}', { amount: formatMoney(r.costMinor / 100, r.currency) })}
</div>
)}
</div>
</li>
))}
</ul>
</div>
);
})}
</div>
)}
<CrossAddInvoiceDialog
open={crossAddOpen}
primary="rebills"
otherCount={openHours}
busy={busy}
onConfirm={runBill}
onClose={() => setCrossAddOpen(false)}
/>
</Card>
);
};
+64 -17
View File
@@ -22,10 +22,14 @@ import { Button, Card, LocalizedDateInput, TimeField } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
import { accountingService } from '../../services/accounting.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { usePermission } from '../../hooks/usePermission';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
import { ProjectSelect } from './ProjectSelect';
import { CrossAddInvoiceDialog } from './CrossAddInvoiceDialog';
export interface HoursSectionProps {
customerId: number;
@@ -49,6 +53,9 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
const { t } = useTranslation();
const qc = useQueryClient();
const navigate = useNavigate();
const { flags } = useFeatureFlags();
// Billing hours (and the combined path) go through customers.edit (#866 review).
const canBill = usePermission('customers.edit');
const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate();
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState('09:00');
@@ -159,20 +166,51 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
errorMessage: 'Failed to delete entry',
});
const billMutation = useMutation({
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId),
onSuccess: ({ invoiceId }) => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.hours.toast.billed', 'Hours billed'));
// Open the new scheduled invoice so the admin can add other line
// items in addition to the hours before it ships.
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
},
// Open re-bills count for the cross-add offer (#866) — only when the
// incoming-invoices feature is on and the admin can create the invoice.
const { data: openRebills = 0 } = useQuery({
queryKey: ['customer-open-rebills-count', customerId],
queryFn: async () => (await accountingService.listCustomerRebills(customerId)).filter((r) => r.status === 'open').length,
enabled: !!flags.incomingInvoices && canBill,
staleTime: 30_000,
});
const [crossAddOpen, setCrossAddOpen] = useState(false);
const [billBusy, setBillBusy] = useState(false);
const onBilled = (invoiceId: number, msg: string) => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
qc.invalidateQueries({ queryKey: ['customer-rebills', customerId] });
qc.invalidateQueries({ queryKey: ['customer-open-rebills-count', customerId] });
toast.success(msg);
// Open the new scheduled invoice so the admin can add other line
// items in addition to the hours before it ships.
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
};
const runBill = async (includeRebills: boolean) => {
setBillBusy(true);
try {
if (includeRebills) {
const { invoiceId } = await customerAdminService.billCombined(customerId, { includeHours: true, includeRebills: true });
onBilled(invoiceId, t('customers.hours.toast.billedCombined', 'Invoice created from hours and re-bills.'));
} else {
const { invoiceId } = await customerAdminService.billUnbilledHourEntries(customerId);
onBilled(invoiceId, t('customers.hours.toast.billed', 'Hours billed'));
}
setCrossAddOpen(false);
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
} finally {
setBillBusy(false);
}
};
const handleBillHours = () => {
// Offer to fold in open re-bills when the customer has both (Feature 3).
if (openRebills > 0) setCrossAddOpen(true);
else runBill(false);
};
// Single pass — both the count and the money total live behind the
// same filter. Memoised so a parent re-render (e.g. the
@@ -383,7 +421,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
{/* Bill-these-hours button for per-event customers only. Stays
visible in compact mode so the customer-detail page can
still trigger the on-demand billing action. */}
{!isMonthly && unbilledCount > 0 && (
{!isMonthly && unbilledCount > 0 && canBill && (
<div className="mb-4 flex items-center justify-between bg-blue-50 dark:bg-blue-900/20 rounded p-3">
<span className="text-sm">
{t('customers.hours.unbilledCount',
@@ -395,15 +433,24 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
</span>
<Button
variant="primary"
disabled={billMutation.isPending}
isLoading={billMutation.isPending}
onClick={() => billMutation.mutate()}
disabled={billBusy}
isLoading={billBusy}
onClick={handleBillHours}
>
{t('customers.hours.billButton', 'Create draft invoice')}
</Button>
</div>
)}
<CrossAddInvoiceDialog
open={crossAddOpen}
primary="hours"
otherCount={openRebills}
busy={billBusy}
onConfirm={runBill}
onClose={() => setCrossAddOpen(false)}
/>
{/* Entry list table. */}
{isLoading ? (
<p className="text-sm text-muted-theme">{t('common.loading', 'Loading…')}</p>
@@ -11,6 +11,7 @@ import { Save } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { accountingService } from '../../../services/accounting.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { businessProfileService } from '../../../services/businessProfile.service';
import { vatCodesService } from '../../../services/vatCodes.service';
import { sortedCountryOptions } from '../../../constants/countries';
@@ -23,6 +24,7 @@ const inputCls = 'w-full max-w-xs rounded-md border border-neutral-300 dark:bord
export const AccountingTab: React.FC = () => {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const { flags } = useFeatureFlags();
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
const { data: outputVatCodes = [] } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
// VAT label + default hourly rate live on business_profile, surfaced here so
@@ -33,6 +35,8 @@ export const AccountingTab: React.FC = () => {
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
const [hourlyMajor, setHourlyMajor] = useState<number>(NaN);
const [requireProof, setRequireProof] = useState(false);
const [rebillAttachProof, setRebillAttachProof] = useState(false);
const [rebillProofNameFormat, setRebillProofNameFormat] = useState('');
const [vatRegistered, setVatRegistered] = useState(false);
const [reclaimCountries, setReclaimCountries] = useState<string[]>([]);
const [defaultOutputVatCode, setDefaultOutputVatCode] = useState('');
@@ -43,6 +47,8 @@ export const AccountingTab: React.FC = () => {
setKmMajor(data.accounting_km_rate_minor / 100);
setPerDiemMajor(data.accounting_per_diem_rate_minor / 100);
setRequireProof(data.accounting_require_proof);
setRebillAttachProof(data.accounting_rebill_attach_proof);
setRebillProofNameFormat(data.crm_rebill_proof_filename_format || '');
setVatRegistered(data.accounting_vat_registered);
setReclaimCountries(data.accounting_vat_reclaim_countries || []);
setDefaultOutputVatCode(data.accounting_default_output_vat_code || '');
@@ -66,6 +72,8 @@ export const AccountingTab: React.FC = () => {
accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0,
accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
accounting_require_proof: requireProof,
accounting_rebill_attach_proof: rebillAttachProof,
crm_rebill_proof_filename_format: rebillProofNameFormat.trim(),
accounting_vat_registered: vatRegistered,
accounting_vat_reclaim_countries: reclaimCountries,
accounting_default_output_vat_code: defaultOutputVatCode,
@@ -112,6 +120,20 @@ export const AccountingTab: React.FC = () => {
<input type="checkbox" checked={requireProof} onChange={(e) => setRequireProof(e.target.checked)} className="rounded border-neutral-300" />
{t('settings.accounting.requireProof', 'Require a proof file on every expense')}
</label>
{flags.incomingInvoices && (
<div>
<label className="flex items-start gap-2 text-sm text-neutral-800 dark:text-neutral-200">
<input type="checkbox" checked={rebillAttachProof} onChange={(e) => setRebillAttachProof(e.target.checked)} className="mt-0.5 rounded border-neutral-300" />
<span>{t('settings.accounting.rebillAttachProof', 'Attach the supplier proof to re-billed invoices by default')}</span>
</label>
<p className="mt-1 ml-6 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.rebillAttachProofHint', 'When a captured supplier invoice is re-billed or passed through, attach its stored PDF to the client-invoice email as a separate proof. This is the default — a per-customer override and a per-file choice in the Send dialog can change it each time.')}</p>
<div className="mt-3 ml-6">
<label className={labelCls}>{t('settings.accounting.rebillProofNameFormat', 'Proof filename format')}</label>
<Input value={rebillProofNameFormat} onChange={(e) => setRebillProofNameFormat(e.target.value)} placeholder="Beleg-{INVOICE}" className="max-w-xs" />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.rebillProofNameFormatHint', 'Filename for the attached proof PDF. Tokens: {INVOICE}, {SUPPLIER}, {YEAR}, {MONTH}, {SEQ} (or {SEQ:03d}). Leave blank for the default “Beleg-{INVOICE}”. When several proofs ride one invoice, an index is appended automatically. Keep a prefix like “Beleg-” so the proof isnt named identically to the invoice PDF.')}</p>
</div>
</div>
)}
<p className="text-xs text-amber-600 dark:text-amber-400">{t('settings.accounting.disclaimer', 'Rates and VAT/tax treatment are guidance only — verify with your Treuhaender.')}</p>
</CardContent></Card>
+59 -5
View File
@@ -2042,7 +2042,11 @@
"hourlyRate": "Standard-Stundensatz",
"hourlyRatePlaceholder": "z. B. 120.00",
"hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen."
}
},
"rebillAttachProof": "Lieferantenbeleg bei Weiterverrechnungen standardmäßig anhängen",
"rebillAttachProofHint": "Wenn eine erfasste Lieferantenrechnung weiterverrechnet oder durchlaufend berechnet wird, wird ihr gespeichertes PDF der Rechnungs-E-Mail als separater Beleg beigefügt. Dies ist die Voreinstellung — eine kundenspezifische Einstellung und eine Auswahl pro Datei im Senden-Dialog können sie jederzeit ändern.",
"rebillProofNameFormat": "Dateiname-Format für Belege",
"rebillProofNameFormatHint": "Dateiname des angehängten Beleg-PDFs. Platzhalter: {INVOICE}, {SUPPLIER}, {YEAR}, {MONTH}, {SEQ} (oder {SEQ:03d}). Leer lassen für den Standard „Beleg-{INVOICE}“. Bei mehreren Belegen pro Rechnung wird automatisch ein Index angehängt. Behalten Sie ein Präfix wie „Beleg-“, damit der Beleg nicht genauso heißt wie das Rechnungs-PDF."
},
"slideshow": {
"title": "Diashow",
@@ -4119,7 +4123,8 @@
"toast": {
"created": "Eintrag erfasst",
"deleted": "Eintrag gelöscht",
"billed": "Stunden verrechnet"
"billed": "Stunden verrechnet",
"billedCombined": "Rechnung aus Stunden und Weiterverrechnungen erstellt."
},
"noRate": {
"title": "Kein Stundensatz hinterlegt",
@@ -4272,7 +4277,9 @@
"contractsSection": "Verträge",
"noContracts": "Noch keine Verträge für diesen Kunden.",
"billsSection": "Rechnungen",
"noBills": "Noch keine Rechnungen für diesen Kunden."
"noBills": "Noch keine Rechnungen für diesen Kunden.",
"rebillsSection": "Weiterverrechnungen & Durchlaufposten",
"noRebills": "Noch keine weiterverrechneten oder durchlaufenden Lieferantenrechnungen für diesen Kunden."
},
"billing": {
"section": "Abrechnungsrhythmus",
@@ -4297,7 +4304,12 @@
"title": "Offen für die Rechnung dieses Monats",
"titleManual": "Offen wird auf manuelle Auslösung versendet",
"periodRange": "{{number}} · {{from}} {{to}}"
}
},
"rebillAttachProof": "Lieferantenbeleg bei Weiterverrechnungen anhängen",
"rebillAttachProofInherit": "Mandanten-Standard verwenden",
"rebillAttachProofOn": "Immer anhängen",
"rebillAttachProofOff": "Nie anhängen",
"rebillAttachProofHint": "Überschreibt die globale Voreinstellung für diesen Kunden. Der Senden-Dialog erlaubt weiterhin die Auswahl einzelner Belege bei jedem Versand."
},
"reactivate": {
"button": "Reaktivieren",
@@ -4483,7 +4495,8 @@
"pendingCount_other": "{{count}} Posten",
"billPending": "Verrechnen",
"bundledToast": "{{count}} Weiterverrechnung zu einer Rechnung gebündelt.",
"bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt."
"bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt.",
"amountRequired": "Rechnungsbetrag vor der Weiterverrechnung eingeben (0 ist erlaubt)."
},
"expense": {
"kind": "Art",
@@ -5213,6 +5226,18 @@
"swiss": "Swiss QR-Bill",
"epc": "EPC QR (SEPA)",
"profileDefault": "Standard aus Geschäftsprofil verwenden"
},
"send": {
"title": "Rechnung senden",
"proofIntro": "Diese Rechnung verrechnet erfasste Lieferantenrechnungen weiter. Wählen Sie, welche Lieferantenbelege der E-Mail beigefügt werden — die Rechnung als PDF wird immer angehängt.",
"proofsLabel": "Lieferantenbelege",
"selectAll": "Alle auswählen",
"selectNone": "Keine",
"unknownSupplier": "Lieferant",
"modePassthrough": "durchlaufend",
"modeRebill": "Weiterverrechnung",
"noProofFile": "Keine gespeicherte Belegdatei",
"sendWithCount": "Mit {{count}} Beleg(en) senden"
}
},
"businessProfile": {
@@ -5784,5 +5809,34 @@
"syncBusy": "Es läuft bereits eine Synchronisierung.",
"syncFailed": "Synchronisierung fehlgeschlagen.",
"actionFailed": "Aktion fehlgeschlagen."
},
"rebills": {
"createInvoice": "Rechnung aus Weiterverrechnungen erstellen",
"unknownSupplier": "Lieferant",
"costLabel": "Kosten {{amount}}",
"proofError": "Beleg nicht angehängt: {{err}}",
"status": {
"open": "Offen",
"sent": "Versendet",
"paid": "Bezahlt"
},
"mode": {
"passthrough": "Durchlaufend",
"rebill": "Weiterverrechnung"
},
"toast": {
"billed": "Rechnung aus Weiterverrechnungen erstellt.",
"billedCombined": "Rechnung aus Weiterverrechnungen und Stunden erstellt.",
"billFailed": "Rechnung konnte nicht erstellt werden"
}
},
"crossAdd": {
"title": "Weitere offene Posten hinzufügen?",
"body": "Dieser Kunde hat außerdem {{count}} {{label}}. Zur selben Rechnung hinzufügen? Sie bleiben eine separate Gruppe — Stunden und Weiterverrechnungen werden nie in einer Position vermischt.",
"addBoth": "Beide hinzufügen",
"hours": "offene Stunden",
"rebills": "offene Weiterverrechnungen",
"hoursOnly": "Nur die Stunden",
"rebillsOnly": "Nur die Weiterverrechnungen"
}
}
+59 -5
View File
@@ -1587,7 +1587,11 @@
"hourlyRate": "Default hourly rate",
"hourlyRatePlaceholder": "e.g. 120.00",
"hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate."
}
},
"rebillAttachProof": "Attach the supplier proof to re-billed invoices by default",
"rebillAttachProofHint": "When a captured supplier invoice is re-billed or passed through, attach its stored PDF to the client-invoice email as a separate proof. This is the default — a per-customer override and a per-file choice in the Send dialog can change it each time.",
"rebillProofNameFormat": "Proof filename format",
"rebillProofNameFormatHint": "Filename for the attached proof PDF. Tokens: {INVOICE}, {SUPPLIER}, {YEAR}, {MONTH}, {SEQ} (or {SEQ:03d}). Leave blank for the default “Beleg-{INVOICE}”. When several proofs ride one invoice, an index is appended automatically. Keep a prefix like “Beleg-” so the proof isnt named identically to the invoice PDF."
},
"slideshow": {
"title": "Slideshow",
@@ -4119,7 +4123,8 @@
"toast": {
"created": "Entry logged",
"deleted": "Entry deleted",
"billed": "Hours billed"
"billed": "Hours billed",
"billedCombined": "Invoice created from hours and re-bills."
},
"noRate": {
"title": "No hourly rate configured",
@@ -4272,7 +4277,9 @@
"noContracts": "No contracts for this customer yet.",
"billsSection": "Invoices",
"noBills": "No invoices for this customer yet.",
"manageEvents": "Manage galleries"
"manageEvents": "Manage galleries",
"rebillsSection": "Re-bills & passthrough",
"noRebills": "No re-billed or passed-through supplier invoices for this customer yet."
},
"billing": {
"section": "Billing cadence",
@@ -4297,7 +4304,12 @@
"title": "Pending in this month's bill",
"titleManual": "Pending — ships on manual trigger",
"periodRange": "{{number}} · {{from}} {{to}}"
}
},
"rebillAttachProof": "Attach supplier proof to re-billed invoices",
"rebillAttachProofInherit": "Use tenant default",
"rebillAttachProofOn": "Always attach",
"rebillAttachProofOff": "Never attach",
"rebillAttachProofHint": "Overrides the global default for this customer. The Send dialog still lets you pick individual proofs each time an invoice goes out."
},
"reactivate": {
"button": "Reactivate",
@@ -4483,7 +4495,8 @@
"pendingCount_other": "{{count}} items",
"billPending": "Bill these",
"bundledToast": "Bundled {{count}} re-bill into one invoice.",
"bundledToast_other": "Bundled {{count}} re-bills into one invoice."
"bundledToast_other": "Bundled {{count}} re-bills into one invoice.",
"amountRequired": "Enter the invoice amount before re-billing (0 is allowed)."
},
"expense": {
"kind": "Type",
@@ -5211,6 +5224,18 @@
"overdue": "Overdue",
"cancelled": "Cancelled",
"skipped": "Skipped (empty month)"
},
"send": {
"title": "Send invoice",
"proofIntro": "This invoice re-bills captured supplier invoices. Choose which supplier proofs to attach to the email — the invoice PDF is always attached.",
"proofsLabel": "Supplier proofs",
"selectAll": "Select all",
"selectNone": "None",
"unknownSupplier": "Supplier",
"modePassthrough": "passthrough",
"modeRebill": "re-bill",
"noProofFile": "No stored proof file",
"sendWithCount": "Send with {{count}} proof(s)"
}
},
"businessProfile": {
@@ -5782,5 +5807,34 @@
"syncBusy": "A sync is already running.",
"syncFailed": "Sync failed.",
"actionFailed": "Action failed."
},
"rebills": {
"createInvoice": "Create invoice from re-bills",
"unknownSupplier": "Supplier",
"costLabel": "cost {{amount}}",
"proofError": "Proof not attached: {{err}}",
"status": {
"open": "Open",
"sent": "Sent",
"paid": "Paid"
},
"mode": {
"passthrough": "Passthrough",
"rebill": "Re-bill"
},
"toast": {
"billed": "Invoice created from re-bills.",
"billedCombined": "Invoice created from re-bills and hours.",
"billFailed": "Failed to create invoice"
}
},
"crossAdd": {
"title": "Add other open items?",
"body": "This customer also has {{count}} {{label}}. Add them to the same invoice? They stay as a separate group — hours and re-bills are never mixed into one line.",
"addBoth": "Add both",
"hours": "open hours",
"rebills": "open re-bills",
"hoursOnly": "Just the hours",
"rebillsOnly": "Just the re-bills"
}
}
@@ -41,7 +41,7 @@ type EditableFields =
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts'
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled' | 'rebillAttachProof';
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
// formatter. It honors the admin's `general_date_format` setting AND
@@ -145,6 +145,9 @@ export const CustomerDetailPage: React.FC = () => {
billingCadence: customer.billingCadence ?? 'per_event',
billingCycleDay: customer.billingCycleDay ?? 1,
skontoDisabled: customer.skontoDisabled ?? false,
// Tri-state (null = inherit global). Kept as-is so the select can show
// "Inherit" distinctly from an explicit on/off (#866).
rebillAttachProof: customer.rebillAttachProof ?? null,
} as any);
}
}, [customer, form]);
@@ -751,6 +754,32 @@ export const CustomerDetailPage: React.FC = () => {
</span>
</label>
{/* Per-customer re-bill proof-attachment override (#866). Tri-state:
inherit the tenant default, or force on/off for this client. */}
{flags.incomingInvoices && (
<div className="mt-4">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('customers.billing.rebillAttachProof', 'Attach supplier proof to re-billed invoices')}
</label>
<select
value={form.rebillAttachProof == null ? 'inherit' : (form.rebillAttachProof ? 'on' : 'off')}
onChange={(e) => {
const v = e.target.value;
setForm((prev) => ({ ...prev, rebillAttachProof: v === 'inherit' ? null : v === 'on' } as any));
}}
className="w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100"
>
<option value="inherit">{t('customers.billing.rebillAttachProofInherit', 'Use tenant default')}</option>
<option value="on">{t('customers.billing.rebillAttachProofOn', 'Always attach')}</option>
<option value="off">{t('customers.billing.rebillAttachProofOff', 'Never attach')}</option>
</select>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('customers.billing.rebillAttachProofHint',
'Overrides the global default for this customer. The Send dialog still lets you pick individual proofs each time an invoice goes out.')}
</p>
</div>
)}
{/* Preview of the open monthly draft (migration 128). Shows
every line item queued for the customer's current billing
period so admin sees exactly what "Trigger invoice now"
@@ -229,6 +229,12 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
});
const rebillNeedsCustomer = disposition === 'rebill' && !customer[0];
// A doc attached to a customer becomes a client invoice line, which needs an
// amount. 0 is a valid amount (a zero-value pass-through); an EMPTY field
// (totalMinor === null) is not — block it here so it can't dead-end later at
// billing. Only enforced when it's actually being billed to a customer.
const rebillNeedsAmount = BOOKING_DISPOSITIONS.includes(disposition) && !!customer[0] && totalMinor == null;
const cannotSave = rebillNeedsCustomer || rebillNeedsAmount;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
@@ -308,11 +314,16 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
)}
</div>
</div>
<div className="flex flex-wrap justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
{rebillNeedsAmount && (
<span className="mr-auto text-xs text-amber-600 dark:text-amber-400">
{t('accounting.incoming.amountRequired', 'Enter the invoice amount before re-billing (0 is allowed).')}
</span>
)}
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
{/* #5: categorize-only OR categorize then continue to mark paid. */}
<Button variant="outline" onClick={() => save.mutate(true)} disabled={save.isPending || rebillNeedsCustomer}>{t('accounting.inbox.saveCategorizePay', 'Save & mark paid')}</Button>
<Button onClick={() => save.mutate(false)} disabled={save.isPending || rebillNeedsCustomer}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}</Button>
<Button variant="outline" onClick={() => save.mutate(true)} disabled={save.isPending || cannotSave}>{t('accounting.inbox.saveCategorizePay', 'Save & mark paid')}</Button>
<Button onClick={() => save.mutate(false)} disabled={save.isPending || cannotSave}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}</Button>
</div>
</div>
</div>
@@ -11,7 +11,10 @@ import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, Ref
import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
import { billsService, isDraftInvoice } from '../../../services/bills.service';
import { accountingService, type InvoiceRebillProof } from '../../../services/accounting.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { formatMoney } from '../../../components/admin/LineItemsTable';
import { formatMoneyMinor } from '../../../utils/money';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
@@ -20,6 +23,7 @@ export const BillDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { format: fmtDate } = useLocalizedDate();
const { flags } = useFeatureFlags();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['invoice', id],
@@ -41,6 +45,12 @@ export const BillDetailPage: React.FC = () => {
// still override it (e.g. partial Skonto + partial waive).
const [payWithSkonto, setPayWithSkonto] = useState(false);
// Send dialog with per-file re-bill proof selection (#866).
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [sendProofs, setSendProofs] = useState<InvoiceRebillProof[]>([]);
const [selectedProofIds, setSelectedProofIds] = useState<Set<number>>(new Set());
const [sending, setSending] = useState(false);
// Pre-build the line-item rows once per data change. Previously this
// was an inline IIFE inside the JSX, rebuilding the array (and N
// <tr> elements) on every render of the page — every payment-dialog
@@ -124,10 +134,38 @@ export const BillDetailPage: React.FC = () => {
toast.error(err?.response?.data?.error || err.message || 'Preview failed');
}
};
// Actually dispatch the send. `proofInboundIds` = the admin's explicit
// re-bill proof picks (empty array = attach none); undefined = no selection,
// let the resolved default decide.
const doSend = async (proofInboundIds?: number[]) => {
setSending(true);
try {
await billsService.send(inv.id, proofInboundIds);
toast.success(t('bills.sentToast', 'Invoice sent.'));
qc.invalidateQueries({ queryKey: ['invoice', id] });
setSendDialogOpen(false);
} catch (e: any) {
toast.error(e?.response?.data?.error || 'Send failed');
} finally {
setSending(false);
}
};
const handleSend = async () => {
// If this invoice re-bills captured supplier invoices, open the Send dialog
// so the admin can pick which proofs ride the email. Otherwise, plain send.
if (flags.incomingInvoices) {
try {
const { proofs, attachDefault } = await accountingService.getInvoiceRebillProofs(inv.id);
if (proofs.length > 0) {
setSendProofs(proofs);
setSelectedProofIds(new Set(attachDefault ? proofs.filter((p) => p.hasProof).map((p) => p.id) : []));
setSendDialogOpen(true);
return;
}
} catch { /* fall through to the plain confirm+send */ }
}
if (!window.confirm(t('bills.confirmSend', 'Send invoice to customer now?'))) return;
try { await billsService.send(inv.id); toast.success(t('bills.sentToast', 'Invoice sent.')); qc.invalidateQueries({ queryKey: ['invoice', id] }); }
catch (e: any) { toast.error(e?.response?.data?.error || 'Send failed'); }
await doSend(undefined);
};
const handleReminder = async () => {
if (!window.confirm(t('bills.confirmReminder', 'Send a reminder now?'))) return;
@@ -466,6 +504,73 @@ export const BillDetailPage: React.FC = () => {
)}
</Card>
{sendDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => !sending && setSendDialogOpen(false)}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-lg mx-4 p-5"
onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold mb-1 text-lg text-neutral-900 dark:text-neutral-100">{t('bills.send.title', 'Send invoice')}</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
{t('bills.send.proofIntro', 'This invoice re-bills captured supplier invoices. Choose which supplier proofs to attach to the email — the invoice PDF is always attached.')}
</p>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('bills.send.proofsLabel', 'Supplier proofs')}
</span>
<div className="flex gap-3 text-xs">
<button type="button" className="text-primary-600 hover:underline"
onClick={() => setSelectedProofIds(new Set(sendProofs.filter((p) => p.hasProof).map((p) => p.id)))}>
{t('bills.send.selectAll', 'Select all')}
</button>
<button type="button" className="text-neutral-500 hover:underline"
onClick={() => setSelectedProofIds(new Set())}>
{t('bills.send.selectNone', 'None')}
</button>
</div>
</div>
<ul className="max-h-64 overflow-y-auto divide-y divide-neutral-200 dark:divide-neutral-700 border border-neutral-200 dark:border-neutral-700 rounded-md">
{sendProofs.map((p) => (
<li key={p.id} className="flex items-center gap-3 px-3 py-2">
<input
type="checkbox"
className="rounded border-neutral-300 dark:border-neutral-600"
disabled={!p.hasProof}
checked={selectedProofIds.has(p.id)}
onChange={(e) => setSelectedProofIds((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(p.id); else next.delete(p.id);
return next;
})}
/>
<div className="min-w-0 flex-1">
<div className="text-sm text-neutral-900 dark:text-neutral-100 truncate">
{p.supplierName || t('bills.send.unknownSupplier', 'Supplier')}
<span className="ml-2 text-xs text-neutral-500 dark:text-neutral-400">
{p.mode === 'passthrough' ? t('bills.send.modePassthrough', 'passthrough') : t('bills.send.modeRebill', 're-bill')}
</span>
</div>
{p.hasProof ? (
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{p.filename || 'proof.pdf'}</div>
) : (
<div className="text-xs text-amber-600 dark:text-amber-400">{t('bills.send.noProofFile', 'No stored proof file')}</div>
)}
</div>
<span className="text-sm tabular-nums text-neutral-700 dark:text-neutral-300">{formatMoneyMinor(p.amountMinor, p.currency || inv.currency)}</span>
</li>
))}
</ul>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" disabled={sending} onClick={() => setSendDialogOpen(false)}>{t('common.cancel', 'Cancel')}</Button>
<Button
disabled={sending}
onClick={() => doSend(sendProofs.filter((p) => p.hasProof && selectedProofIds.has(p.id)).map((p) => p.id))}
>
{t('bills.send.sendWithCount', 'Send with {{count}} proof(s)', { count: sendProofs.filter((p) => p.hasProof && selectedProofIds.has(p.id)).length })}
</Button>
</div>
</div>
</div>
)}
{payDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setPayDialogOpen(false)}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-md mx-4 p-5"
@@ -116,6 +116,44 @@ export interface AccountingSettings {
accounting_vat_reclaim_countries: string[];
/** Output VAT code stamped onto NEW invoices/quotes ('' = none). */
accounting_default_output_vat_code: string;
/** Global default: attach the stored supplier proof PDF to the client-invoice
* email when a re-bill/passthrough is issued (#866). Off by default; a
* per-customer override and the Send dialog's per-file selection build on it. */
accounting_rebill_attach_proof: boolean;
/** Filename template for the attached proof. Tokens: {INVOICE} {SUPPLIER}
* {YEAR} {MONTH} {SEQ}/{SEQ:0Nd}. '' → default 'Beleg-{INVOICE}'. */
crm_rebill_proof_filename_format: string;
}
/** Re-bill / passthrough item for one customer (CRM panel, #866). Status is
* derived from the linked client-invoice lifecycle. */
export interface CustomerRebillItem {
id: number;
supplierName: string | null;
date: string | null;
currency: string | null;
costMinor: number;
rebilledMinor: number;
mode: 'passthrough' | 'rebill';
eventId: number | null;
eventName: string | null;
hasProof: boolean;
proofAttachError: string | null;
status: 'open' | 'sent' | 'paid';
invoiceId: number | null;
invoiceNumber: string | null;
}
/** Re-bill proof attached to a not-yet-sent invoice (Send dialog, #866). */
export interface InvoiceRebillProof {
id: number;
supplierName: string | null;
filename: string | null;
hasProof: boolean;
currency: string | null;
amountMinor: number;
mode: 'passthrough' | 'rebill';
proofAttachError: string | null;
}
export interface CategorizePayload {
@@ -176,6 +214,10 @@ export const accountingService = {
async listPendingRebills(): Promise<PendingRebillSummary[]> { const { data } = await api.get('/admin/expenses/inbound/pending-summary'); return data.items; },
/** Bundle one customer's pending re-bills into a single invoice. */
async billPendingRebills(customerAccountId: number): Promise<{ invoiceId: number; count: number }> { const { data } = await api.post('/admin/expenses/inbound/bill-pending', { customerAccountId }); return data; },
/** Re-bill / passthrough items for a customer, with derived status (#866). */
async listCustomerRebills(customerAccountId: number): Promise<CustomerRebillItem[]> { const { data } = await api.get(`/admin/expenses/inbound/by-customer/${customerAccountId}`); return data.items; },
/** Re-bill proofs on a not-yet-sent invoice + the resolved attach default (#866). */
async getInvoiceRebillProofs(invoiceId: number): Promise<{ proofs: InvoiceRebillProof[]; attachDefault: boolean }> { const { data } = await api.get(`/admin/invoices/${invoiceId}/rebill-proofs`); return data; },
async markInboundPaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; },
async getInboundFileBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; },
async getInboundPageBlob(id: number, page: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; },
@@ -216,6 +258,9 @@ export const accountingService = {
? data.accounting_vat_reclaim_countries : [],
accounting_default_output_vat_code: typeof data.accounting_default_output_vat_code === 'string'
? data.accounting_default_output_vat_code : '',
accounting_rebill_attach_proof: data.accounting_rebill_attach_proof === true,
crm_rebill_proof_filename_format: typeof data.crm_rebill_proof_filename_format === 'string'
? data.crm_rebill_proof_filename_format : '',
};
},
async updateSettings(payload: Partial<AccountingSettings>): Promise<{ updated: string[] }> {
+8 -2
View File
@@ -272,8 +272,14 @@ export const billsService = {
return data.data || data;
},
async send(id: number): Promise<{ sent: true }> {
const { data } = await api.post(`/admin/invoices/${id}/send`);
/**
* Send the invoice now. `proofInboundIds` (issue #866) is the admin's per-file
* re-bill proof selection from the Send dialog; omit to let the resolved
* per-customer/global default decide all-or-none.
*/
async send(id: number, proofInboundIds?: number[]): Promise<{ sent: true }> {
const { data } = await api.post(`/admin/invoices/${id}/send`,
proofInboundIds !== undefined ? { proofInboundIds } : undefined);
return data.data || data;
},
@@ -67,6 +67,10 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
* this customer's invoices qualify for an early-payment discount,
* regardless of template / global defaults. */
skontoDisabled?: boolean;
/** Per-customer re-bill proof-attachment override (#866). Tri-state:
* null = inherit the global default, true = always attach the supplier
* proof to re-billed invoices, false = never. */
rebillAttachProof?: boolean | null;
notes: string | null;
events: Array<{
id: number;
@@ -168,6 +172,8 @@ export const customerAdminService = {
billingCycleDay: 'billing_cycle_day',
// Per-customer Skonto opt-out (migration 112).
skontoDisabled: 'skonto_disabled',
// Per-customer re-bill proof-attachment override (#866). null clears it.
rebillAttachProof: 'rebill_attach_proof',
};
for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v;
@@ -344,6 +350,19 @@ export const customerAdminService = {
return (response.data as any).data ?? response.data;
},
/** Combine open hours and/or open re-bills into ONE invoice (#866, Feature 3).
* Hours and re-bills stay as distinct, contiguous line groups. */
async billCombined(
customerId: number,
opts: { includeHours: boolean; includeRebills: boolean },
): Promise<{ invoiceId: number; entriesBilled: number; rebillsBilled: number }> {
const response = await api.post(
`/admin/customers/${customerId}/bill-combined`,
opts,
);
return (response.data as any).data ?? response.data;
},
/** Landing aggregate for /admin/clients/hours — every customer that
* currently carries unbilled hour entries, with open hours + open
* amount (install default currency). Sorted by open amount desc. */