From 8d00ee069743f8dfb828b32d7e0f69b2271b6859 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 20 Apr 2026 15:40:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(invoices):=20M2=20CRUD=20=E2=80=94=20draft?= =?UTF-8?q?=20lifecycle,=20totals,=20list=20+=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full draft → sent → paid flow with the supporting surface: Data layer - totals.ts: pure computeLineTotal / computeInvoiceTotals (testable without Dexie); per-line rounding then per-rate summing so the breakdown equals the sum-of-lines exactly - queries.ts: useAllInvoices (live, overdue derived on read), useInvoiceClients, computeStats per currency, formatAmount - stores/settings.svelte.ts: singleton with stable sentinel id, ensureSettings() lazy-creates, takeNextNumber() atomic via Dexie rw-transaction (plaintext-only fields → no crypto breaks the tx) - stores/invoices.svelte.ts: create / update / updateLines (recomputes totals) / markSent / markPaid / void / duplicate / soft-delete; drafts only editable, paid can't be voided, sent/paid can't be deleted Components - StatusBadge, LinesEditor (add/remove/reorder, live per-line totals, minor-unit conversion on type), ClientPicker (contacts + invoice client book, manual entry fallback, snapshot binding), SenderProfileForm (sender + sequence + defaults), InvoiceForm (create + edit with live totals + VAT breakdown) Views & routes - DetailView with full action bar (edit/duplicate/mark sent/mark paid/ void/delete), lines table, per-rate totals block - ListView rewritten: status chips with counts, stats cards (open/overdue/YTD fakturiert/YTD bezahlt) scoped to primary currency, search, row-click to detail - routes: /invoices/new, /invoices/[id], /invoices/[id]/edit (with edit-lock for non-drafts), /invoices/settings Design notes addressed - Number sequence atomicity: rw-transaction around plaintext fields guards same-device race; cross-device offline collision documented in settings store header as a known gap, acceptable for solo-MVP - Edit-after-send: drafts only are editable; to revise a sent invoice, void and duplicate (keeps bookkeeping evidence intact) Plan: docs/plans/invoices-module.md §M2. Next: M3 pdf-lib renderer + M5 swissqrbill. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/modules/invoices/ListView.svelte | 341 ++++++++++++--- .../invoices/components/ClientPicker.svelte | 221 ++++++++++ .../invoices/components/InvoiceForm.svelte | 332 +++++++++++++++ .../invoices/components/LinesEditor.svelte | 262 ++++++++++++ .../components/SenderProfileForm.svelte | 294 +++++++++++++ .../invoices/components/StatusBadge.svelte | 33 ++ .../web/src/lib/modules/invoices/index.ts | 19 + .../web/src/lib/modules/invoices/queries.ts | 189 +++++++++ .../invoices/stores/invoices.svelte.ts | 262 ++++++++++++ .../invoices/stores/settings.svelte.ts | 167 ++++++++ .../web/src/lib/modules/invoices/totals.ts | 65 +++ .../modules/invoices/views/DetailView.svelte | 390 ++++++++++++++++++ .../routes/(app)/invoices/[id]/+page.svelte | 34 ++ .../(app)/invoices/[id]/edit/+page.svelte | 76 ++++ .../routes/(app)/invoices/new/+page.svelte | 40 ++ .../(app)/invoices/settings/+page.svelte | 40 ++ 16 files changed, 2704 insertions(+), 61 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/components/ClientPicker.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/components/InvoiceForm.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/components/LinesEditor.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/components/SenderProfileForm.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/components/StatusBadge.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/stores/settings.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/totals.ts create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/invoices/[id]/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/invoices/[id]/edit/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/invoices/new/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/invoices/settings/+page.svelte diff --git a/apps/mana/apps/web/src/lib/modules/invoices/ListView.svelte b/apps/mana/apps/web/src/lib/modules/invoices/ListView.svelte index 9426b5a9b..d4c188876 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/invoices/ListView.svelte @@ -1,56 +1,158 @@

Rechnungen

-

Outbound-Finance — Rechnungen stellen und verfolgen

+

Rechnungen stellen und Zahlungen verfolgen

+
+
+ +
-
+
+
+
Offen
+
{formatAmount(stats.openByCurrency[openCurrency], openCurrency)}
+
+ {stats.totalByStatus.sent + stats.totalByStatus.overdue} Rechnungen +
+
+
+
Überfällig
+
+ {formatAmount(stats.overdueByCurrency[overdueCurrency], overdueCurrency)} +
+
{stats.totalByStatus.overdue} Rechnungen
+
+
+
Fakturiert {currentYear}
+
+ {formatAmount(stats.invoicedYtdByCurrency[ytdCurrency], ytdCurrency)} +
+
+
+
Bezahlt {currentYear}
+
+ {formatAmount(stats.paidYtdByCurrency[ytdCurrency], ytdCurrency)} +
+
+
+ +
+
+ + {#each ['draft', 'sent', 'overdue', 'paid', 'void'] as status (status)} + + {/each} +
+ +
+ {#if invoices.length === 0}
📄

Noch keine Rechnungen

-

Stelle deine erste Rechnung mit automatischem PDF-Export und Schweizer QR-Bill.

-

M1 Skelett — Erstellen-Flow folgt in M2.

+

Stelle deine erste Rechnung — inklusive PDF-Export und Schweizer QR-Bill (M5).

+ +
+ {:else if filtered.length === 0} +
+

Keine Rechnungen gefunden.

{:else} -
    - {#each invoices as invoice (invoice.id)} -
  • - {invoice.number} - {invoice.clientSnapshot?.name ?? '—'} - {formatAmount(invoice.totals.gross, invoice.currency)} - - - {STATUS_LABELS[invoice.status].de} - +
      + {#each filtered as invoice (invoice.id)} +
    • +
    • {/each}
    @@ -60,7 +162,7 @@ diff --git a/apps/mana/apps/web/src/lib/modules/invoices/components/ClientPicker.svelte b/apps/mana/apps/web/src/lib/modules/invoices/components/ClientPicker.svelte new file mode 100644 index 000000000..bc3de271e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/components/ClientPicker.svelte @@ -0,0 +1,221 @@ + + + +
    + + + + + + + +
    + + diff --git a/apps/mana/apps/web/src/lib/modules/invoices/components/InvoiceForm.svelte b/apps/mana/apps/web/src/lib/modules/invoices/components/InvoiceForm.svelte new file mode 100644 index 000000000..443bb4741 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/components/InvoiceForm.svelte @@ -0,0 +1,332 @@ + + + +
    { + e.preventDefault(); + saveDraft(); + }} + class="form" +> +
    +

    Kunde

    + +
    + +
    +

    Rechnung

    +
    + + + + +
    +
    + +
    +

    Positionen

    + +
    + +
    +

    Summe

    +
    +
    Netto
    +
    {formatAmount(totals.net, currency)}
    + {#each totals.vatBreakdown as b (b.rate)} +
    MwSt. {b.rate}%
    +
    {formatAmount(b.tax, currency)}
    + {/each} +
    Total
    +
    {formatAmount(totals.gross, currency)}
    +
    +
    + +
    +

    Notizen & Zahlungsbedingungen

    + + +
    + + {#if error} +
    {error}
    + {/if} + +
    + + +
    +
    + + diff --git a/apps/mana/apps/web/src/lib/modules/invoices/components/LinesEditor.svelte b/apps/mana/apps/web/src/lib/modules/invoices/components/LinesEditor.svelte new file mode 100644 index 000000000..463c53630 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/components/LinesEditor.svelte @@ -0,0 +1,262 @@ + + + +
    +
    + Position + Menge + Einheit + Einzelpreis + MwSt. + Total + +
    + + {#if lines.length === 0} +

    Noch keine Positionen. Füge die erste hinzu.

    + {/if} + + {#each lines as line (line.id)} + {@const { net, tax } = computeLineTotal(line)} +
    + updateLine(line.id, { title: e.currentTarget.value })} + /> + updateLine(line.id, { quantity: Number(e.currentTarget.value) || 0 })} + /> + updateLine(line.id, { unit: e.currentTarget.value || null })} + /> + + updateLine(line.id, { unitPrice: majorToMinor(Number(e.currentTarget.value) || 0) })} + /> + + + {formatMinor(net + tax)} + netto {formatMinor(net)} + + + + + + +
    + {/each} + + +
    + + diff --git a/apps/mana/apps/web/src/lib/modules/invoices/components/SenderProfileForm.svelte b/apps/mana/apps/web/src/lib/modules/invoices/components/SenderProfileForm.svelte new file mode 100644 index 000000000..f4de1016b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/components/SenderProfileForm.svelte @@ -0,0 +1,294 @@ + + + +{#if !settings} +

    Lade Einstellungen …

    +{:else} +
    { + e.preventDefault(); + save(); + }} + class="form" + > +
    +

    Absender

    +

    Erscheint im Kopf jeder Rechnung.

    + + + + + +
    + + +
    + +
    + + +
    + + +
    + +
    +

    Nummernkreis

    +

    Nächste Rechnung: {nextPreview}

    + +
    + + + +
    +
    + +
    +

    Standards

    + +
    + + + +
    + + +
    + +
    + + {#if savedAt} + Gespeichert um {savedAt} + {/if} +
    +
    +{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/invoices/components/StatusBadge.svelte b/apps/mana/apps/web/src/lib/modules/invoices/components/StatusBadge.svelte new file mode 100644 index 000000000..3aaae8af5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/components/StatusBadge.svelte @@ -0,0 +1,33 @@ + + + + + {STATUS_LABELS[status].de} + + + diff --git a/apps/mana/apps/web/src/lib/modules/invoices/index.ts b/apps/mana/apps/web/src/lib/modules/invoices/index.ts index 620937d8d..5c10ae3b5 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/index.ts +++ b/apps/mana/apps/web/src/lib/modules/invoices/index.ts @@ -21,6 +21,22 @@ export { INVOICE_SETTINGS_ID, } from './constants'; +export { + useAllInvoices, + useInvoiceClients, + toInvoice, + toInvoiceClient, + filterByStatus, + searchInvoices, + computeStats, + formatAmount, +} from './queries'; + +export { computeLineTotal, computeInvoiceTotals, EMPTY_TOTALS } from './totals'; + +export { invoicesStore } from './stores/invoices.svelte'; +export { invoiceSettingsStore, ensureSettings } from './stores/settings.svelte'; + export type { LocalInvoice, LocalInvoiceLine, @@ -37,3 +53,6 @@ export type { Currency, ClientSource, } from './types'; + +export type { InvoiceStats } from './queries'; +export type { CreateInvoiceInput } from './stores/invoices.svelte'; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/queries.ts b/apps/mana/apps/web/src/lib/modules/invoices/queries.ts new file mode 100644 index 000000000..80b077cc9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/queries.ts @@ -0,0 +1,189 @@ +/** + * Reactive queries and pure helpers for the Invoices module. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { decryptRecords } from '$lib/data/crypto'; +import { db } from '$lib/data/database'; +import type { + LocalInvoice, + LocalInvoiceClient, + Invoice, + InvoiceClient, + InvoiceStatus, + Currency, +} from './types'; +import { INVOICE_SETTINGS_ID, CURRENCIES } from './constants'; + +// ─── Type Converters ───────────────────────────────────── + +export function toInvoice(local: LocalInvoice): Invoice { + const now = new Date().toISOString(); + return { + id: local.id, + number: local.number, + status: local.status, + clientId: local.clientId ?? null, + clientSource: local.clientSource, + clientSnapshot: local.clientSnapshot, + currency: local.currency, + issueDate: local.issueDate, + dueDate: local.dueDate, + sentAt: local.sentAt ?? null, + paidAt: local.paidAt ?? null, + lines: (local.lines ?? []).map((l) => ({ + id: l.id, + title: l.title, + description: l.description ?? null, + quantity: l.quantity, + unit: l.unit ?? null, + unitPrice: l.unitPrice, + vatRate: l.vatRate, + discount: l.discount ?? null, + })), + subject: local.subject ?? null, + notes: local.notes ?? null, + terms: local.terms ?? null, + referenceNumber: local.referenceNumber ?? null, + pdfBlobKey: local.pdfBlobKey ?? null, + totals: local.totals, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +export function toInvoiceClient(local: LocalInvoiceClient): InvoiceClient { + const now = new Date().toISOString(); + return { + id: local.id, + name: local.name, + address: local.address ?? null, + email: local.email ?? null, + vatNumber: local.vatNumber ?? null, + iban: local.iban ?? null, + defaultCurrency: local.defaultCurrency, + defaultDueDays: local.defaultDueDays, + notes: local.notes ?? null, + createdAt: local.createdAt ?? now, + }; +} + +// ─── Live Queries ──────────────────────────────────────── + +/** + * All invoices (not soft-deleted), decrypted, newest first by issueDate. + * The `overdue` status is computed on the fly here — we don't persist it + * because it's a pure function of (status === 'sent') && (dueDate < today) + * and persisting would require a cron to flip it, creating sync churn. + */ +export function useAllInvoices() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('invoices').toArray(); + const visible = locals.filter((i) => !i.deletedAt); + const decrypted = await decryptRecords('invoices', visible); + const today = new Date().toISOString().slice(0, 10); + return decrypted + .map((local) => { + const invoice = toInvoice(local); + if (invoice.status === 'sent' && invoice.dueDate < today) { + return { ...invoice, status: 'overdue' as InvoiceStatus }; + } + return invoice; + }) + .sort((a, b) => b.issueDate.localeCompare(a.issueDate)); + }, [] as Invoice[]); +} + +export function useInvoiceClients() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('invoiceClients').toArray(); + const visible = locals.filter((c) => !c.deletedAt); + const decrypted = await decryptRecords('invoiceClients', visible); + return decrypted.map(toInvoiceClient).sort((a, b) => a.name.localeCompare(b.name)); + }, [] as InvoiceClient[]); +} + +// ─── Pure Helpers ──────────────────────────────────────── + +export function filterByStatus(invoices: Invoice[], status: InvoiceStatus): Invoice[] { + return invoices.filter((i) => i.status === status); +} + +export function searchInvoices(invoices: Invoice[], query: string): Invoice[] { + const q = query.toLowerCase(); + return invoices.filter( + (i) => + i.number.toLowerCase().includes(q) || + i.clientSnapshot.name.toLowerCase().includes(q) || + (i.subject?.toLowerCase().includes(q) ?? false) + ); +} + +// ─── Stats ─────────────────────────────────────────────── + +export interface InvoiceStats { + totalByStatus: Record; + /** Open amount = sent + overdue, keyed by currency because we can't sum CHF and EUR. */ + openByCurrency: Record; + overdueByCurrency: Record; + paidYtdByCurrency: Record; + invoicedYtdByCurrency: Record; +} + +function emptyCurrencyMap(): Record { + return { CHF: 0, EUR: 0, USD: 0 }; +} + +export function computeStats(invoices: Invoice[], year: number): InvoiceStats { + const totalByStatus: Record = { + draft: 0, + sent: 0, + paid: 0, + overdue: 0, + void: 0, + }; + const openByCurrency = emptyCurrencyMap(); + const overdueByCurrency = emptyCurrencyMap(); + const paidYtdByCurrency = emptyCurrencyMap(); + const invoicedYtdByCurrency = emptyCurrencyMap(); + const yearPrefix = String(year); + + for (const inv of invoices) { + totalByStatus[inv.status]++; + if (inv.status === 'sent' || inv.status === 'overdue') { + openByCurrency[inv.currency] += inv.totals.gross; + } + if (inv.status === 'overdue') { + overdueByCurrency[inv.currency] += inv.totals.gross; + } + if (inv.issueDate.startsWith(yearPrefix) && inv.status !== 'void' && inv.status !== 'draft') { + invoicedYtdByCurrency[inv.currency] += inv.totals.gross; + } + if (inv.paidAt?.startsWith(yearPrefix)) { + paidYtdByCurrency[inv.currency] += inv.totals.gross; + } + } + + return { + totalByStatus, + openByCurrency, + overdueByCurrency, + paidYtdByCurrency, + invoicedYtdByCurrency, + }; +} + +// ─── Formatting ────────────────────────────────────────── + +/** + * Format a minor-unit integer amount as a human-readable string with the + * currency symbol. E.g. (15000, 'CHF') → "CHF 150.00". + */ +export function formatAmount(minor: number, currency: Currency): string { + const { symbol, minorUnit } = CURRENCIES[currency]; + return `${symbol} ${(minor / minorUnit).toFixed(2)}`; +} + +// ─── Settings singleton helpers ────────────────────────── + +export { INVOICE_SETTINGS_ID }; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts b/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts new file mode 100644 index 000000000..12dbb2d43 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts @@ -0,0 +1,262 @@ +/** + * Invoices store — mutation-only service. + * + * Reads happen via queries.ts. Writes go through this store so the + * encryption step, totals recomputation, and event emission stay + * consistent. The status machine is enforced here: + * + * draft → sent (markSent) → paid (markPaid) + * → void (voidInvoice) + * any → void (voidInvoice) + * + * "overdue" is NOT a persisted status — it's derived on read from + * (status==='sent' && dueDate < today). See queries.ts. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { invoiceTable } from '../collections'; +import { computeInvoiceTotals } from '../totals'; +import type { + LocalInvoice, + LocalInvoiceLine, + InvoiceClientSnapshot, + ClientSource, + Currency, +} from '../types'; +import { invoiceSettingsStore } from './settings.svelte'; + +export interface CreateInvoiceInput { + clientId: string | null; + clientSource: ClientSource; + clientSnapshot: InvoiceClientSnapshot; + currency: Currency; + issueDate?: string; + dueDate?: string; + lines?: LocalInvoiceLine[]; + subject?: string | null; + notes?: string | null; + terms?: string | null; +} + +function todayISO(): string { + return new Date().toISOString().slice(0, 10); +} + +function addDaysISO(base: string, days: number): string { + const d = new Date(`${base}T00:00:00`); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0, 10); +} + +export const invoicesStore = { + /** + * Create a new invoice in status `draft`. Pulls the next invoice number + * atomically from settings, falls back to sensible defaults for dates / + * terms / VAT if the caller didn't set them. + */ + async createInvoice(input: CreateInvoiceInput): Promise { + const defaults = await invoiceSettingsStore.getDefaults(); + const number = await invoiceSettingsStore.takeNextNumber(); + const issueDate = input.issueDate ?? todayISO(); + const dueDate = input.dueDate ?? addDaysISO(issueDate, defaults.dueDays); + const lines = input.lines ?? []; + const totals = computeInvoiceTotals(lines); + + const newLocal: LocalInvoice = { + id: crypto.randomUUID(), + number, + status: 'draft', + clientId: input.clientId, + clientSource: input.clientSource, + clientSnapshot: input.clientSnapshot, + currency: input.currency, + issueDate, + dueDate, + sentAt: null, + paidAt: null, + lines, + subject: input.subject ?? null, + notes: input.notes ?? null, + terms: input.terms ?? defaults.terms, + referenceNumber: null, + pdfBlobKey: null, + totals, + }; + + await encryptRecord('invoices', newLocal); + await invoiceTable.add(newLocal); + emitDomainEvent('InvoiceCreated', 'invoices', 'invoices', newLocal.id, { + invoiceId: newLocal.id, + number, + gross: totals.gross, + currency: input.currency, + }); + return newLocal.id; + }, + + /** + * Update free-form metadata on a draft invoice. Only drafts are editable — + * once an invoice is sent, its content is frozen (see plan §Offene Fragen). + * To "fix" a sent invoice, void it and duplicate → edit → send again. + */ + async updateInvoice( + id: string, + patch: Partial< + Pick< + LocalInvoice, + | 'clientId' + | 'clientSource' + | 'clientSnapshot' + | 'currency' + | 'issueDate' + | 'dueDate' + | 'subject' + | 'notes' + | 'terms' + | 'referenceNumber' + | 'number' + > + > + ) { + const existing = await invoiceTable.get(id); + if (!existing) return; + if (existing.status !== 'draft') { + throw new Error( + '[invoices] only drafts can be edited; void and duplicate to revise a sent invoice' + ); + } + const wrapped = { ...patch } as Record; + await encryptRecord('invoices', wrapped); + await invoiceTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + /** + * Replace the full line list. Recomputes totals so denormalised totals + * stay in sync with the lines (invariant the dashboard queries depend on). + * Drafts only — see updateInvoice. + */ + async updateLines(id: string, lines: LocalInvoiceLine[]) { + const existing = await invoiceTable.get(id); + if (!existing) return; + if (existing.status !== 'draft') { + throw new Error('[invoices] only drafts can be edited'); + } + const totals = computeInvoiceTotals(lines); + const patch = { lines, totals } as Record; + // `lines` is in the encryption allowlist; `totals` is not. encryptRecord + // only touches allowlisted keys, so a single call is correct for both. + await encryptRecord('invoices', patch); + await invoiceTable.update(id, { + ...patch, + updatedAt: new Date().toISOString(), + }); + }, + + /** + * Transition draft → sent. Freezes the invoice (no more edits) and stamps + * sentAt. The caller is expected to have already triggered the send flow + * (e.g. open mail compose with PDF attached); this is purely the state + * transition. + */ + async markSent(id: string) { + const existing = await invoiceTable.get(id); + if (!existing) return; + if (existing.status !== 'draft') return; + await invoiceTable.update(id, { + status: 'sent', + sentAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('InvoiceSent', 'invoices', 'invoices', id, { + invoiceId: id, + number: existing.number, + }); + }, + + /** + * Transition sent/overdue → paid. `paidAt` defaults to today; caller can + * override for back-dated entries (e.g. "customer paid last Friday"). + */ + async markPaid(id: string, paidAt?: string) { + const existing = await invoiceTable.get(id); + if (!existing) return; + if (existing.status !== 'sent' && existing.status !== 'overdue') return; + const stamp = paidAt ?? new Date().toISOString(); + await invoiceTable.update(id, { + status: 'paid', + paidAt: stamp, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('InvoicePaid', 'invoices', 'invoices', id, { + invoiceId: id, + number: existing.number, + paidAt: stamp, + }); + }, + + /** + * Void an invoice — works from any non-paid status. Preserves the row + * (sequence-number history matters for bookkeeping) but flags it + * inactive. Paid invoices can't be voided; issue a credit note instead + * (Phase 2 feature). + */ + async voidInvoice(id: string) { + const existing = await invoiceTable.get(id); + if (!existing) return; + if (existing.status === 'paid') { + throw new Error('[invoices] paid invoices cannot be voided; issue a credit note'); + } + await invoiceTable.update(id, { + status: 'void', + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('InvoiceVoided', 'invoices', 'invoices', id, { + invoiceId: id, + number: existing.number, + }); + }, + + /** + * Duplicate an existing invoice (typically a sent/paid one that needs + * reissuing). Produces a new draft with a fresh number, today's issue + * date, and the same lines / client / subject. + */ + async duplicate(id: string): Promise { + const existing = await invoiceTable.get(id); + if (!existing) throw new Error('[invoices] duplicate: source not found'); + const { decryptRecords } = await import('$lib/data/crypto'); + const [decrypted] = (await decryptRecords('invoices', [existing])) as LocalInvoice[]; + return this.createInvoice({ + clientId: decrypted.clientId, + clientSource: decrypted.clientSource, + clientSnapshot: decrypted.clientSnapshot, + currency: decrypted.currency, + lines: decrypted.lines, + subject: decrypted.subject, + notes: decrypted.notes, + terms: decrypted.terms, + }); + }, + + /** + * Soft-delete — sets deletedAt. Only drafts and voided invoices can be + * deleted; sent/paid/overdue must be voided first (bookkeeping: you don't + * make evidence of a sent invoice disappear). + */ + async deleteInvoice(id: string) { + const existing = await invoiceTable.get(id); + if (!existing) return; + if (existing.status !== 'draft' && existing.status !== 'void') { + throw new Error('[invoices] only drafts or voided invoices can be deleted'); + } + await invoiceTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('InvoiceDeleted', 'invoices', 'invoices', id, { invoiceId: id }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/stores/settings.svelte.ts b/apps/mana/apps/web/src/lib/modules/invoices/stores/settings.svelte.ts new file mode 100644 index 000000000..c054dbc48 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/stores/settings.svelte.ts @@ -0,0 +1,167 @@ +/** + * Invoice settings store — singleton row per user. + * + * The settings hold the sender profile (used on every PDF) and the number + * sequence state. `takeNextNumber()` is the only path that should hand out + * invoice numbers — a Dexie transaction wraps read + increment so a fast + * double-click or two parallel create flows on the same device can't + * hand out duplicate numbers. + * + * ## Transaction boundaries and crypto + * + * Web Crypto calls inside a Dexie rw transaction break the transaction + * (any non-Dexie await suspends it). Number-sequence fields (`nextNumber`, + * `numberPrefix`, `numberPadding`) are plaintext per the encryption + * registry, so we can read and write them atomically without a decrypt + * step inside the transaction. + * + * ## Sync collision (offline multi-device) — known gap, M2 + * + * Two devices offline both reading `nextNumber=42` → both assign + * "2026-0042". Not handled in M2 because: + * (1) the MVP target is solo freelancers (one active device), + * (2) sync LWW on `nextNumber` converges on the max so subsequent + * invoices stop colliding — only the two in-flight numbers dupe, + * (3) proper detection needs a scan-on-apply hook in the sync layer; + * better to solve once, cleanly, in Phase 2 than patch in M2. + * Users can manually renumber via the edit form in the meantime. + */ + +import { db } from '$lib/data/database'; +import { encryptRecord, decryptRecords } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { invoiceSettingsTable } from '../collections'; +import type { LocalInvoiceSettings, InvoiceSettings, Currency } from '../types'; +import { + INVOICE_SETTINGS_ID, + DEFAULT_DUE_DAYS, + DEFAULT_NUMBER_PREFIX, + DEFAULT_NUMBER_PADDING, +} from '../constants'; + +function toInvoiceSettings(local: LocalInvoiceSettings): InvoiceSettings { + return { + id: local.id, + senderName: local.senderName ?? '', + senderAddress: local.senderAddress ?? '', + senderEmail: local.senderEmail ?? '', + senderVatNumber: local.senderVatNumber ?? null, + senderIban: local.senderIban ?? '', + senderBic: local.senderBic ?? null, + logoMediaId: local.logoMediaId ?? null, + accentColor: local.accentColor ?? '#059669', + footer: local.footer ?? null, + numberPrefix: local.numberPrefix ?? DEFAULT_NUMBER_PREFIX, + numberPadding: local.numberPadding ?? DEFAULT_NUMBER_PADDING, + nextNumber: local.nextNumber ?? 1, + defaultCurrency: local.defaultCurrency ?? 'CHF', + defaultVatRate: local.defaultVatRate ?? 8.1, + defaultDueDays: local.defaultDueDays ?? DEFAULT_DUE_DAYS, + defaultTerms: local.defaultTerms ?? null, + }; +} + +/** + * Get or create the singleton settings row. Must be called OUTSIDE any + * Dexie rw transaction because encryptRecord runs Web Crypto. + */ +async function ensureSettings(): Promise { + const existing = await invoiceSettingsTable.get(INVOICE_SETTINGS_ID); + if (existing) return existing; + + const defaults: LocalInvoiceSettings = { + id: INVOICE_SETTINGS_ID, + senderName: '', + senderAddress: '', + senderEmail: '', + senderVatNumber: null, + senderIban: '', + senderBic: null, + logoMediaId: null, + accentColor: '#059669', + footer: null, + numberPrefix: DEFAULT_NUMBER_PREFIX, + numberPadding: DEFAULT_NUMBER_PADDING, + nextNumber: 1, + defaultCurrency: 'CHF', + defaultVatRate: 8.1, + defaultDueDays: DEFAULT_DUE_DAYS, + defaultTerms: null, + }; + const wrapped = { ...defaults }; + await encryptRecord('invoiceSettings', wrapped); + await invoiceSettingsTable.add(wrapped); + return wrapped; +} + +function formatNumber(prefix: string, n: number, padding: number): string { + return `${prefix}${String(n).padStart(padding, '0')}`; +} + +export const invoiceSettingsStore = { + async get(): Promise { + await ensureSettings(); + const row = await invoiceSettingsTable.get(INVOICE_SETTINGS_ID); + if (!row) throw new Error('[invoices] settings row missing after ensureSettings'); + const [decrypted] = (await decryptRecords('invoiceSettings', [row])) as LocalInvoiceSettings[]; + return toInvoiceSettings(decrypted); + }, + + /** + * Atomic: pull the next number, increment the counter, hand the formatted + * string back. Number-sequence fields are plaintext, so no crypto needs to + * run inside the transaction. Caller must have awaited `ensureSettings()` + * at some earlier point — we don't call it here to keep the hot path fast + * and to avoid nesting an async boundary inside the rw transaction. + */ + async takeNextNumber(): Promise { + await ensureSettings(); + let out = ''; + await db.transaction('rw', invoiceSettingsTable, async () => { + const current = await invoiceSettingsTable.get(INVOICE_SETTINGS_ID); + if (!current) throw new Error('[invoices] settings row vanished mid-transaction'); + const nextN = current.nextNumber ?? 1; + const prefix = current.numberPrefix ?? DEFAULT_NUMBER_PREFIX; + const padding = current.numberPadding ?? DEFAULT_NUMBER_PADDING; + out = formatNumber(prefix, nextN, padding); + await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, { + nextNumber: nextN + 1, + updatedAt: new Date().toISOString(), + }); + }); + return out; + }, + + async update(patch: Partial>) { + await ensureSettings(); + const wrapped = { ...patch } as Record; + await encryptRecord('invoiceSettings', wrapped); + await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('InvoiceSettingsUpdated', 'invoices', 'invoiceSettings', INVOICE_SETTINGS_ID, { + fields: Object.keys(patch), + }); + }, + + /** Convenience: defaults extracted for the InvoiceForm's initial state. */ + async getDefaults(): Promise<{ + currency: Currency; + vatRate: number; + dueDays: number; + terms: string | null; + senderIban: string; + }> { + const s = await this.get(); + return { + currency: s.defaultCurrency, + vatRate: s.defaultVatRate, + dueDays: s.defaultDueDays, + terms: s.defaultTerms, + senderIban: s.senderIban, + }; + }, +}; + +export { ensureSettings }; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/totals.ts b/apps/mana/apps/web/src/lib/modules/invoices/totals.ts new file mode 100644 index 000000000..4e10455fd --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/totals.ts @@ -0,0 +1,65 @@ +/** + * Pure totals computation — kept out of the store so it's trivially unit- + * testable and can be called from the form for live preview without hitting + * Dexie. Money is always integer minor units (Rappen / cents). + * + * Rounding: bankers / half-even would be stricter but the default Math.round + * (half-away-from-zero) matches what most Swiss bookkeeping tools do at the + * invoice level. Per-line tax is rounded first, then summed per rate, so + * the breakdown equals sum-of-lines exactly. + */ + +import type { InvoiceLine, LocalInvoiceLine, InvoiceTotals, VatBreakdownEntry } from './types'; + +type AnyLine = InvoiceLine | LocalInvoiceLine; + +/** + * Compute a single line's net + tax in minor units. + * net = quantity × unitPrice × (1 − discount/100) + * tax = net × vatRate / 100 + * + * Discount is applied before tax (standard invoicing convention: the customer + * owes tax on the discounted price, not the list price). + */ +export function computeLineTotal(line: AnyLine): { net: number; tax: number } { + const rawNet = line.quantity * line.unitPrice; + const discount = line.discount ?? 0; + const net = Math.round(rawNet * (1 - discount / 100)); + const tax = Math.round((net * line.vatRate) / 100); + return { net, tax }; +} + +/** + * Compute the full totals envelope for an invoice. Groups tax by rate so the + * PDF footer can show a per-rate breakdown (required in CH for invoices that + * mix reduced and standard-rate positions). + */ +export function computeInvoiceTotals(lines: readonly AnyLine[]): InvoiceTotals { + const byRate = new Map(); + let net = 0; + let vat = 0; + + for (const line of lines) { + const { net: lineNet, tax: lineTax } = computeLineTotal(line); + net += lineNet; + vat += lineTax; + const bucket = byRate.get(line.vatRate) ?? { base: 0, tax: 0 }; + bucket.base += lineNet; + bucket.tax += lineTax; + byRate.set(line.vatRate, bucket); + } + + const vatBreakdown: VatBreakdownEntry[] = [...byRate.entries()] + .sort(([a], [b]) => a - b) + .map(([rate, { base, tax }]) => ({ rate, base, tax })); + + return { net, vat, gross: net + vat, vatBreakdown }; +} + +/** Zero total — useful as initial state before any lines exist. */ +export const EMPTY_TOTALS: InvoiceTotals = { + net: 0, + vat: 0, + gross: 0, + vatBreakdown: [], +}; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte new file mode 100644 index 000000000..93945931f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte @@ -0,0 +1,390 @@ + + + +
    +
    +
    +
    {invoice.number}
    +

    {invoice.subject || 'Rechnung'}

    + +
    +
    +
    {formatAmount(invoice.totals.gross, invoice.currency)}
    +
    + Fällig {invoice.dueDate} +
    +
    +
    + +
    + {#if invoice.status === 'draft'} + + + {/if} + {#if invoice.status === 'sent' || invoice.status === 'overdue'} + + {/if} + + {#if invoice.status !== 'paid' && invoice.status !== 'void'} + + {/if} + {#if invoice.status === 'draft' || invoice.status === 'void'} + + {/if} +
    + + {#if actionError} +
    {actionError}
    + {/if} + +
    +

    Empfänger

    +
    +
    {invoice.clientSnapshot.name}
    + {#if invoice.clientSnapshot.address} +
    {invoice.clientSnapshot.address}
    + {/if} + {#if invoice.clientSnapshot.email} +
    {invoice.clientSnapshot.email}
    + {/if} + {#if invoice.clientSnapshot.vatNumber} +
    MwSt-Nr.: {invoice.clientSnapshot.vatNumber}
    + {/if} +
    +
    + +
    +

    Positionen

    + + + + + + + + + + + + {#each invoice.lines as line (line.id)} + + + + + + + + {/each} + +
    PositionMengeEinzelpreisMwSt.Netto
    +
    {line.title}
    + {#if line.description}
    {line.description}
    {/if} +
    {line.quantity}{line.unit ? ` ${line.unit}` : ''}{formatAmount(line.unitPrice, invoice.currency)}{line.vatRate}% + {formatAmount(line.quantity * line.unitPrice, invoice.currency)} +
    +
    + +
    +

    Summe

    +
    +
    Netto
    +
    {formatAmount(invoice.totals.net, invoice.currency)}
    + {#each invoice.totals.vatBreakdown as b (b.rate)} +
    MwSt. {b.rate}%
    +
    {formatAmount(b.tax, invoice.currency)}
    + {/each} +
    Total
    +
    {formatAmount(invoice.totals.gross, invoice.currency)}
    +
    +
    + + {#if invoice.notes} +
    +

    Notizen

    +

    {invoice.notes}

    +
    + {/if} + + {#if invoice.terms} +
    +

    Zahlungsbedingungen

    +

    {invoice.terms}

    +
    + {/if} + +
    +
    Status: {STATUS_LABELS[invoice.status].de}
    + {#if invoice.sentAt}
    Versendet: {new Date(invoice.sentAt).toLocaleString()}
    {/if} + {#if invoice.paidAt}
    Bezahlt: {new Date(invoice.paidAt).toLocaleString()}
    {/if} +
    +
    + + diff --git a/apps/mana/apps/web/src/routes/(app)/invoices/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/invoices/[id]/+page.svelte new file mode 100644 index 000000000..b3442ba5e --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/invoices/[id]/+page.svelte @@ -0,0 +1,34 @@ + + + + {invoice?.number ?? 'Rechnung'} - Mana + + +{#if invoice} + +{:else if invoices$.value !== undefined} +
    +

    Rechnung nicht gefunden.

    + Zurück zur Übersicht +
    +{:else} +
    Lädt …
    +{/if} + + diff --git a/apps/mana/apps/web/src/routes/(app)/invoices/[id]/edit/+page.svelte b/apps/mana/apps/web/src/routes/(app)/invoices/[id]/edit/+page.svelte new file mode 100644 index 000000000..eddc06ed5 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/invoices/[id]/edit/+page.svelte @@ -0,0 +1,76 @@ + + + + Rechnung bearbeiten - Mana + + +
    + {#if !invoice && invoices$.value !== undefined} +
    +

    Rechnung nicht gefunden.

    + Zurück zur Übersicht +
    + {:else if invoice && !canEdit} +
    +

    Rechnung kann nicht bearbeitet werden

    +

    + Nur Entwürfe sind editierbar. Diese Rechnung hat Status + {invoice.status}. Um eine versendete Rechnung zu ändern, storniere sie und + dupliziere sie als neuen Entwurf. +

    + Zurück zum Detail +
    + {:else if invoice} +
    +

    Rechnung {invoice.number} bearbeiten

    +
    + + {:else} +
    Lädt …
    + {/if} +
    + + diff --git a/apps/mana/apps/web/src/routes/(app)/invoices/new/+page.svelte b/apps/mana/apps/web/src/routes/(app)/invoices/new/+page.svelte new file mode 100644 index 000000000..1877d403b --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/invoices/new/+page.svelte @@ -0,0 +1,40 @@ + + + + Neue Rechnung - Mana + + +
    +
    +

    Neue Rechnung

    +

    Entwurf erstellen — wird nach dem Speichern noch nicht versendet.

    +
    + + +
    + + diff --git a/apps/mana/apps/web/src/routes/(app)/invoices/settings/+page.svelte b/apps/mana/apps/web/src/routes/(app)/invoices/settings/+page.svelte new file mode 100644 index 000000000..96f565e24 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/invoices/settings/+page.svelte @@ -0,0 +1,40 @@ + + + + Rechnungs-Einstellungen - Mana + + +
    +
    +

    Rechnungs-Einstellungen

    +

    Absender, Nummernkreis und Standards für alle neuen Rechnungen.

    +
    + + +
    + +