feat(invoices): M8 AI tools — create/mark_paid/list/stats

The last open item from the plan. Missions can now draft invoices from
chat context, mark customer payments, and read status for autonomous
follow-up cadences.

Tool catalog (packages/shared-ai/src/tools/schemas.ts)
- create_invoice (propose) — clientName + lines[] + currency + due
- mark_invoice_paid (propose) — by id, optional back-dated paidAt
- list_invoices (auto) — with status + limit filter
- get_invoice_stats (auto) — open/overdue/YTD per currency

Had to widen the tool-parameter type vocabulary so create_invoice can
declare lines as a typed array. Touched three places:
- ToolSchema-side: the catalog's `type` string is already free-form so
  'array' / 'object' just pass through
- ModuleTool-side (apps/mana/apps/web/src/lib/data/tools/types.ts): added
  'array' | 'object' to the union so TS doesn't narrow the executor's
  param signatures
- function-schema translator (packages/shared-ai): mapParamType +
  JsonSchemaProperty both gained the two new types; the catalog-typo
  guard test now uses 'fruit' as its sentinel (array no longer unknown)

Executor (apps/mana/apps/web/src/lib/modules/invoices/tools.ts)
- coerceLines accepts either a real array or a JSON-stringified array
  (planners vary), skips malformed entries, converts major→minor units
- create_invoice pulls the generated number back from Dexie so the
  success message shows "Entwurf 2026-0042 …" — the user recognises it
- mark_invoice_paid normalises YYYY-MM-DD → ISO so the store's timestamp
  invariant (ISO throughout) stays intact
- list_invoices derives overdue on read (consistent with useAllInvoices),
  returns major-unit amounts so the LLM reasons in user-facing numbers
- get_invoice_stats returns counts + open/overdue/YTD per currency

Registration: invoicesTools added to tools/init.ts. mana-ai drift guard
is happy (41/41 green); webapp + shared-ai type-check 0 errors; full
invoice test suite 59/59 green.

Closes: docs/plans/invoices-module.md §M8. All plan milestones now DONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 18:22:20 +02:00
parent 0d613e1846
commit a4bc7d2ee3
7 changed files with 427 additions and 4 deletions

View file

@ -41,6 +41,7 @@ import { moodTools } from '$lib/modules/mood/tools';
import { wishesTools } from '$lib/modules/wishes/tools';
import { wetterTools } from '$lib/modules/wetter/tools';
import { quizTools } from '$lib/modules/quiz/tools';
import { invoicesTools } from '$lib/modules/invoices/tools';
let initialized = false;
@ -83,5 +84,6 @@ export function initTools(): void {
registerTools(wishesTools);
registerTools(wetterTools);
registerTools(quizTools);
registerTools(invoicesTools);
initialized = true;
}

View file

@ -21,7 +21,10 @@ export interface ModuleTool {
export interface ToolParameter {
name: string;
type: 'string' | 'number' | 'boolean';
/** JSON Schema primitive types. `array` / `object` used by tools that
* accept structured payloads (e.g. invoice line items) parsing
* is the tool's responsibility inside execute(). */
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
description: string;
required: boolean;
enum?: string[];

View file

@ -40,6 +40,8 @@ export { generateSCORReference, QRBillError } from './pdf/qr-bill';
export { invoicesStore } from './stores/invoices.svelte';
export { invoiceSettingsStore, ensureSettings } from './stores/settings.svelte';
export { invoicesTools } from './tools';
export type {
LocalInvoice,
LocalInvoiceLine,

View file

@ -0,0 +1,301 @@
/**
* Invoice Tools LLM-accessible operations.
*
* Schema definitions live in @mana/shared-ai's AI_TOOL_CATALOG (the SSOT);
* this file provides the matching `execute` fns that call the local store.
* Amounts arrive from the LLM in major units (CHF 150.00) and get
* converted to minor units (15000) here.
*/
import type { ModuleTool } from '$lib/data/tools/types';
import { invoiceTable } from './collections';
import { decryptRecords } from '$lib/data/crypto';
import { invoicesStore } from './stores/invoices.svelte';
import { toInvoice, computeStats, formatAmount } from './queries';
import { CURRENCIES } from './constants';
import type {
LocalInvoice,
LocalInvoiceLine,
Invoice,
InvoiceStatus,
Currency,
InvoiceClientSnapshot,
} from './types';
// ─── Helpers ────────────────────────────────────────────
async function listDecryptedInvoices(): Promise<Invoice[]> {
const rows = await invoiceTable.toArray();
const visible = rows.filter((r) => !r.deletedAt);
const decrypted = await decryptRecords<LocalInvoice>('invoices', visible);
const today = new Date().toISOString().slice(0, 10);
return decrypted.map((local) => {
const inv = toInvoice(local);
if (inv.status === 'sent' && inv.dueDate < today) {
return { ...inv, status: 'overdue' as InvoiceStatus };
}
return inv;
});
}
interface RawLine {
title?: string;
quantity?: number;
unitPrice?: number;
vatRate?: number;
unit?: string;
description?: string;
discount?: number;
}
/**
* Coerce whatever the LLM produced for `lines` into LocalInvoiceLine[].
* Accepts either a real array or a JSON-stringified one (some planners
* emit strings for array params). Rejects when no usable entries remain.
*/
function coerceLines(raw: unknown, currency: Currency, defaultVatRate: number): LocalInvoiceLine[] {
let parsed: RawLine[];
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw) as RawLine[];
} catch {
throw new Error('Positionen konnten nicht geparst werden (ungültiges JSON).');
}
} else if (Array.isArray(raw)) {
parsed = raw as RawLine[];
} else {
throw new Error('Positionen müssen als Array übergeben werden.');
}
const minor = CURRENCIES[currency].minorUnit;
const lines: LocalInvoiceLine[] = [];
for (const p of parsed) {
if (!p.title || typeof p.quantity !== 'number' || typeof p.unitPrice !== 'number') {
continue; // silently skip malformed entries so a single bad line doesn't kill the invoice
}
lines.push({
id: crypto.randomUUID(),
title: p.title,
description: p.description ?? null,
quantity: p.quantity,
unit: p.unit ?? null,
unitPrice: Math.round(p.unitPrice * minor),
vatRate: typeof p.vatRate === 'number' ? p.vatRate : defaultVatRate,
discount: typeof p.discount === 'number' ? p.discount : null,
});
}
if (lines.length === 0) {
throw new Error('Keine gültige Position — erwarte { title, quantity, unitPrice } je Eintrag.');
}
return lines;
}
// ─── Tools ──────────────────────────────────────────────
export const invoicesTools: ModuleTool[] = [
{
name: 'create_invoice',
module: 'invoices',
description:
'Erstellt eine neue Rechnung als Entwurf. Setzt Kunde (Name + optional Adresse + E-Mail), Positionen (Titel, Menge, Einzelpreis in Hauptwaehrung), Faelligkeit. Nummer wird automatisch vergeben.',
parameters: [
{
name: 'clientName',
type: 'string',
description: 'Name des Kunden (erforderlich)',
required: true,
},
{
name: 'clientEmail',
type: 'string',
description: 'E-Mail-Adresse des Kunden',
required: false,
},
{
name: 'clientAddress',
type: 'string',
description: 'Postanschrift des Kunden (mehrzeilig, Strasse + Nr, dann PLZ Ort)',
required: false,
},
{
name: 'subject',
type: 'string',
description: 'Kurzer Betreff (z.B. "Beratung April")',
required: false,
},
{
name: 'currency',
type: 'string',
description: 'Waehrung (Standard: CHF)',
required: false,
enum: ['CHF', 'EUR', 'USD'],
},
{
name: 'dueDate',
type: 'string',
description: 'Faelligkeitsdatum (YYYY-MM-DD). Ohne Angabe: +30 Tage ab heute.',
required: false,
},
{
name: 'lines',
type: 'array',
description:
'Array von Positionen: [{ title, quantity, unitPrice (Hauptwaehrung), vatRate?, unit? }]. Mindestens eine Position.',
required: true,
},
],
async execute(params) {
const clientName = String(params.clientName ?? '').trim();
if (!clientName) {
return { success: false, message: 'Kundenname fehlt.' };
}
const currency = (params.currency as Currency) ?? 'CHF';
const snapshot: InvoiceClientSnapshot = {
name: clientName,
address: (params.clientAddress as string | undefined)?.trim() || undefined,
email: (params.clientEmail as string | undefined)?.trim() || undefined,
};
try {
const lines = coerceLines(params.lines, currency, 8.1);
const id = await invoicesStore.createInvoice({
clientId: null,
clientSource: 'invoice-client',
clientSnapshot: snapshot,
currency,
dueDate: (params.dueDate as string | undefined) || undefined,
lines,
subject: (params.subject as string | undefined) || null,
});
// Re-read to get the generated number + total for the message.
const row = await invoiceTable.get(id);
const gross = row?.totals?.gross ?? 0;
return {
success: true,
data: { id, number: row?.number, gross, currency },
message: `Entwurf ${row?.number ?? '(neu)'} für ${clientName} über ${formatAmount(gross, currency)} angelegt.`,
};
} catch (e) {
return {
success: false,
message: e instanceof Error ? e.message : 'Erstellen fehlgeschlagen',
};
}
},
},
{
name: 'mark_invoice_paid',
module: 'invoices',
description:
'Markiert eine versendete oder ueberfaellige Rechnung als bezahlt. paidAt ist optional (Standard: heute, fuer rueckdatierte Eingaenge ein fruehes Datum setzen).',
parameters: [
{
name: 'invoiceId',
type: 'string',
description: 'ID der Rechnung (aus list_invoices)',
required: true,
},
{
name: 'paidAt',
type: 'string',
description: 'Zahlungsdatum (ISO oder YYYY-MM-DD). Standard: jetzt.',
required: false,
},
],
async execute(params) {
const id = String(params.invoiceId ?? '').trim();
if (!id) return { success: false, message: 'Rechnungs-ID fehlt.' };
const existing = await invoiceTable.get(id);
if (!existing) return { success: false, message: `Rechnung ${id} nicht gefunden.` };
// Normalise YYYY-MM-DD → ISO so markPaid's timestamp is consistent with
// the UI path (which uses new Date().toISOString()).
let paidAt = params.paidAt as string | undefined;
if (paidAt && /^\d{4}-\d{2}-\d{2}$/.test(paidAt)) {
paidAt = new Date(`${paidAt}T12:00:00.000Z`).toISOString();
}
await invoicesStore.markPaid(id, paidAt);
return {
success: true,
data: { id, number: existing.number },
message: `Rechnung ${existing.number} als bezahlt markiert.`,
};
},
},
{
name: 'list_invoices',
module: 'invoices',
description:
'Listet Rechnungen auf. Optional nach Status (draft/sent/paid/overdue/void) und Limit gefiltert. Gibt ID, Nummer, Kunde, Status, Betrag, Faelligkeit zurueck.',
parameters: [
{
name: 'status',
type: 'string',
description: 'Nur diesen Status zeigen',
required: false,
enum: ['draft', 'sent', 'paid', 'overdue', 'void'],
},
{
name: 'limit',
type: 'number',
description: 'Maximale Anzahl (Standard: 20)',
required: false,
},
],
async execute(params) {
const all = await listDecryptedInvoices();
const status = params.status as InvoiceStatus | undefined;
const filtered = status ? all.filter((i) => i.status === status) : all;
const limit = Number(params.limit ?? 20);
const slice = filtered.slice(0, Math.max(1, limit));
const data = slice.map((i) => ({
id: i.id,
number: i.number,
status: i.status,
client: i.clientSnapshot.name,
gross: i.totals.gross / CURRENCIES[i.currency].minorUnit,
currency: i.currency,
issueDate: i.issueDate,
dueDate: i.dueDate,
}));
return {
success: true,
data,
message: `${slice.length} Rechnung${slice.length === 1 ? '' : 'en'}${status ? ` im Status ${status}` : ''}.`,
};
},
},
{
name: 'get_invoice_stats',
module: 'invoices',
description:
'Gibt Rechnungs-Kennzahlen zurueck: offene Summe, ueberfaellige Summe, YTD fakturiert + bezahlt (pro Waehrung, in Hauptwaehrung als Gleitkomma).',
parameters: [],
async execute() {
const all = await listDecryptedInvoices();
const year = new Date().getFullYear();
const stats = computeStats(all, year);
// Convert minor-unit maps to major-unit maps so the LLM can reason
// with numbers that match the user's mental model.
const toMajor = (map: Record<Currency, number>): Record<Currency, number> => ({
CHF: map.CHF / 100,
EUR: map.EUR / 100,
USD: map.USD / 100,
});
return {
success: true,
data: {
counts: stats.totalByStatus,
openByCurrency: toMajor(stats.openByCurrency),
overdueByCurrency: toMajor(stats.overdueByCurrency),
invoicedYtdByCurrency: toMajor(stats.invoicedYtdByCurrency),
paidYtdByCurrency: toMajor(stats.paidYtdByCurrency),
year,
},
message: `${stats.totalByStatus.sent + stats.totalByStatus.overdue} offen, ${stats.totalByStatus.overdue} überfällig.`,
};
},
},
];

View file

@ -87,8 +87,10 @@ describe('toolToFunctionSchema', () => {
description: 'Broken',
defaultPolicy: 'auto',
parameters: [
// `fruit` is genuinely not a JSON Schema type — the catalog-typo
// guard exists to catch accidental one-off strings like this.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ name: 'p', type: 'array' as any, description: 'p', required: true },
{ name: 'p', type: 'fruit' as any, description: 'p', required: true },
],
};
expect(() => toolToFunctionSchema(tool)).toThrow(/Unsupported parameter type/);

View file

@ -16,9 +16,14 @@
import type { ToolSchema } from './schemas';
/** OpenAI-compatible JSON-Schema property type. */
/** OpenAI-compatible JSON-Schema property type. `array` / `object` are
* emitted without a nested `items` / `properties` schema tools that
* take structured payloads describe the expected shape inside their
* human-readable description and parse in the executor. Tightening to
* a full JSON-Schema tree would be strictly better but isn't required
* by the OpenAI / Anthropic function-calling specs. */
export interface JsonSchemaProperty {
type: 'string' | 'number' | 'integer' | 'boolean';
type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
description?: string;
enum?: readonly string[];
}
@ -53,6 +58,8 @@ function mapParamType(t: string): JsonSchemaProperty['type'] {
case 'number':
case 'integer':
case 'boolean':
case 'array':
case 'object':
return t;
default:
// Unknown types in the catalog are a bug, but don't silently

View file

@ -1110,6 +1110,112 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
defaultPolicy: 'auto',
parameters: [{ name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }],
},
// ── Invoices ─────────────────────────────────────────────
{
name: 'create_invoice',
module: 'invoices',
description:
'Erstellt eine neue Rechnung als Entwurf. Setzt Kunde (Name + optional Adresse + E-Mail), Positionen (Titel, Menge, Einzelpreis in Hauptwaehrung), Faelligkeit. Nummer wird automatisch vergeben.',
defaultPolicy: 'propose',
parameters: [
{
name: 'clientName',
type: 'string',
description: 'Name des Kunden (erforderlich)',
required: true,
},
{
name: 'clientEmail',
type: 'string',
description: 'E-Mail-Adresse des Kunden',
required: false,
},
{
name: 'clientAddress',
type: 'string',
description: 'Postanschrift des Kunden (mehrzeilig, Strasse + Nr, dann PLZ Ort)',
required: false,
},
{
name: 'subject',
type: 'string',
description: 'Kurzer Betreff (z.B. "Beratung April")',
required: false,
},
{
name: 'currency',
type: 'string',
description: 'Waehrung (Standard: CHF)',
required: false,
enum: ['CHF', 'EUR', 'USD'],
},
{
name: 'dueDate',
type: 'string',
description: 'Faelligkeitsdatum (YYYY-MM-DD). Ohne Angabe: +30 Tage ab heute.',
required: false,
},
{
name: 'lines',
type: 'array',
description:
'Array von Positionen: [{ title: string, quantity: number, unitPrice: number (in Hauptwaehrung, z.B. 150.00), vatRate?: number, unit?: string }]. Mindestens eine Position.',
required: true,
},
],
},
{
name: 'mark_invoice_paid',
module: 'invoices',
description:
'Markiert eine versendete oder ueberfaellige Rechnung als bezahlt. paidAt ist optional (Standard: heute, fuer rueckdatierte Eingaenge ein fruehes Datum setzen).',
defaultPolicy: 'propose',
parameters: [
{
name: 'invoiceId',
type: 'string',
description: 'ID der Rechnung (aus list_invoices)',
required: true,
},
{
name: 'paidAt',
type: 'string',
description: 'Zahlungsdatum (ISO oder YYYY-MM-DD). Standard: jetzt.',
required: false,
},
],
},
{
name: 'list_invoices',
module: 'invoices',
description:
'Listet Rechnungen auf. Optional nach Status (draft/sent/paid/overdue/void) und Limit gefiltert. Gibt ID, Nummer, Kunde, Status, Betrag, Faelligkeit zurueck.',
defaultPolicy: 'auto',
parameters: [
{
name: 'status',
type: 'string',
description: 'Nur diesen Status zeigen',
required: false,
enum: ['draft', 'sent', 'paid', 'overdue', 'void'],
},
{
name: 'limit',
type: 'number',
description: 'Maximale Anzahl (Standard: 20)',
required: false,
},
],
},
{
name: 'get_invoice_stats',
module: 'invoices',
description:
'Gibt Rechnungs-Kennzahlen zurueck: offene Summe, ueberfaellige Summe, YTD fakturiert + bezahlt (pro Waehrung, in Hauptwaehrung als Gleitkomma).',
defaultPolicy: 'auto',
parameters: [],
},
];
// ═══════════════════════════════════════════════════════════════