diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 7d26da2f8..b6166e2b4 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -78,6 +78,11 @@ import type { LocalNote } from '../../modules/notes/types'; import type { LocalDream, LocalDreamSymbol } from '../../modules/dreams/types'; import type { LocalJournalEntry } from '../../modules/journal/types'; import type { LocalMemo } from '../../modules/memoro/types'; +import type { + LocalInvoice, + LocalInvoiceClient, + LocalInvoiceSettings, +} from '../../modules/invoices/types'; export const ENCRYPTION_REGISTRY: Record = { // ─── Chat ──────────────────────────────────────────────── @@ -580,6 +585,54 @@ export const ENCRYPTION_REGISTRY: Record = { enabled: true, fields: ['title', 'originalTitle', 'creators', 'review', 'tags'], }, + + // ─── Invoices ──────────────────────────────────────────── + // Outbound finance. Sensitive surface is non-trivial: client name and + // address, the free-text subject/notes/terms, and the line items + // themselves (title/description carry service names or project codes + // that leak who the user works for + what they're paid to do). + // + // Plaintext (intentional): + // - number, status, clientId, clientSource, currency, issueDate, + // dueDate, sentAt, paidAt, referenceNumber, pdfBlobKey: all + // structural, used for indexing/filter/aggregation. + // - totals (net/vat/gross): kept plaintext so the dashboard's + // "open" and "overdue" sums can be computed in a liveQuery + // without decrypting every row. The sum-across-customers is + // business-useful info the user sees at a glance; encrypting it + // would defeat the local-first reactive layer. + // - lines is encrypted as a whole blob (see below): the numeric + // subfields would be plaintext-eligible, but serialising the + // whole array in one pass keeps the encryption boundary simple + // and lets per-line titles travel encrypted alongside. + invoices: entry(['clientSnapshot', 'subject', 'notes', 'terms', 'lines']), + + // Optional per-user client book. Everything user-typed is sensitive — + // name, postal address, email, VAT number, IBAN, free-text notes. + // defaultCurrency / defaultDueDays stay plaintext (structural enums). + invoiceClients: entry([ + 'name', + 'address', + 'email', + 'vatNumber', + 'iban', + 'notes', + ]), + + // Singleton sender profile. The user's legal address + IBAN live here + // and are the most sensitive fields in the module (appear on every PDF + // the user issues). logoMediaId / accentColor / number sequence state + // are plaintext — structural, no privacy value. + invoiceSettings: entry([ + 'senderName', + 'senderAddress', + 'senderEmail', + 'senderVatNumber', + 'senderIban', + 'senderBic', + 'footer', + 'defaultTerms', + ]), }; /** diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index a9a7720d2..44a26853c 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -592,6 +592,21 @@ db.version(26).stores({ libraryEntries: 'id, kind, status, completedAt, isFavorite', }); +// v27 — Invoices module: outbound finance (issuing invoices to clients). +// See docs/plans/invoices-module.md. Three tables: +// - invoices: the invoice records (status/dueDate/clientId indexed for +// the "overdue per client" + "open per status" queries that drive the +// ListView + dashboard widgets). +// - invoiceClients: optional per-user client book; only userId needed +// since listing is always scoped to the current user. +// - invoiceSettings: singleton sender profile (one row per user, id is +// the stable sentinel INVOICE_SETTINGS_ID so sync dedupes on it). +db.version(27).stores({ + invoices: 'id, number, status, clientId, issueDate, dueDate', + invoiceClients: 'id', + invoiceSettings: 'id', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 05a53553d..016c0c063 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -99,6 +99,7 @@ import { kontextModuleConfig } from '$lib/modules/kontext/module.config'; import { quizModuleConfig } from '$lib/modules/quiz/module.config'; import { profileModuleConfig } from '$lib/modules/profile/module.config'; import { libraryModuleConfig } from '$lib/modules/library/module.config'; +import { invoicesModuleConfig } from '$lib/modules/invoices/module.config'; import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config'; @@ -155,6 +156,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ quizModuleConfig, profileModuleConfig, libraryModuleConfig, + invoicesModuleConfig, wetterModuleConfig, aiModuleConfig, ]; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/ListView.svelte b/apps/mana/apps/web/src/lib/modules/invoices/ListView.svelte new file mode 100644 index 000000000..9426b5a9b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/ListView.svelte @@ -0,0 +1,178 @@ + + + +
+
+
+

Rechnungen

+

Outbound-Finance — Rechnungen stellen und verfolgen

+
+ +
+ + {#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.

+
+ {:else} +
    + {#each invoices as invoice (invoice.id)} +
  • + {invoice.number} + {invoice.clientSnapshot?.name ?? '—'} + {formatAmount(invoice.totals.gross, invoice.currency)} + + + {STATUS_LABELS[invoice.status].de} + +
  • + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/invoices/collections.ts b/apps/mana/apps/web/src/lib/modules/invoices/collections.ts new file mode 100644 index 000000000..cfa2b5ddf --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/collections.ts @@ -0,0 +1,64 @@ +/** + * Invoices module — Dexie accessors and guest seed. + * + * Tables: + * - `invoices` — the invoice records themselves + * - `invoiceClients` — optional per-user client book + * - `invoiceSettings` — singleton sender profile + number sequence + */ + +import { db } from '$lib/data/database'; +import type { LocalInvoice, LocalInvoiceClient, LocalInvoiceSettings } from './types'; + +export const invoiceTable = db.table('invoices'); +export const invoiceClientTable = db.table('invoiceClients'); +export const invoiceSettingsTable = db.table('invoiceSettings'); + +// ─── Guest Seed ──────────────────────────────────────────── +// One draft invoice so first-run users immediately see what the module is +// for. Amounts stored in Rappen / cents (integer) — 150.00 CHF → 15000. + +export const INVOICES_GUEST_SEED = { + invoices: [ + { + id: 'demo-invoice-draft', + number: `${new Date().getFullYear()}-0001`, + status: 'draft' as const, + clientId: null, + clientSource: 'invoice-client' as const, + clientSnapshot: { + name: 'Muster Kunde AG', + address: 'Bahnhofstrasse 1\n8000 Zürich', + email: 'kontakt@muster-kunde.ch', + }, + currency: 'CHF' as const, + issueDate: new Date().toISOString().slice(0, 10), + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), + sentAt: null, + paidAt: null, + lines: [ + { + id: 'demo-line-1', + title: 'Beratung', + description: null, + quantity: 4, + unit: 'Std', + unitPrice: 15000, // 150.00 CHF + vatRate: 8.1, + discount: null, + }, + ], + subject: 'Beratungsleistung', + notes: null, + terms: 'Zahlbar innert 30 Tagen netto.', + referenceNumber: null, + pdfBlobKey: null, + totals: { + net: 60000, + vat: 4860, + gross: 64860, + vatBreakdown: [{ rate: 8.1, base: 60000, tax: 4860 }], + }, + }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/constants.ts b/apps/mana/apps/web/src/lib/modules/invoices/constants.ts new file mode 100644 index 000000000..18d203b0d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/constants.ts @@ -0,0 +1,47 @@ +import type { Currency, InvoiceStatus } from './types'; + +export const STATUS_LABELS: Record = { + draft: { de: 'Entwurf', en: 'Draft' }, + sent: { de: 'Versendet', en: 'Sent' }, + paid: { de: 'Bezahlt', en: 'Paid' }, + overdue: { de: 'Überfällig', en: 'Overdue' }, + void: { de: 'Storniert', en: 'Void' }, +}; + +export const STATUS_COLORS: Record = { + draft: '#64748b', + sent: '#3b82f6', + paid: '#22c55e', + overdue: '#ef4444', + void: '#94a3b8', +}; + +/** Swiss VAT rates as of 2024 (MwSt-Satz). */ +export const VAT_RATES_CH = [ + { value: 0, label: '0% (ausgenommen)' }, + { value: 2.6, label: '2.6% (reduziert)' }, + { value: 3.8, label: '3.8% (Beherbergung)' }, + { value: 8.1, label: '8.1% (Normalsatz)' }, +]; + +/** German VAT rates. */ +export const VAT_RATES_DE = [ + { value: 0, label: '0%' }, + { value: 7, label: '7% (ermäßigt)' }, + { value: 19, label: '19% (Regelsatz)' }, +]; + +export const CURRENCIES: Record = { + CHF: { symbol: 'CHF', minorUnit: 100 }, + EUR: { symbol: '€', minorUnit: 100 }, + USD: { symbol: '$', minorUnit: 100 }, +}; + +export const DEFAULT_DUE_DAYS = 30; + +export const DEFAULT_NUMBER_PREFIX = `${new Date().getFullYear()}-`; +export const DEFAULT_NUMBER_PADDING = 4; + +/** Sentinel id for the singleton settings row. Stable across devices so the + * sync engine dedupes on it instead of fighting over two parallel rows. */ +export const INVOICE_SETTINGS_ID = 'invoice-settings'; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/index.ts b/apps/mana/apps/web/src/lib/modules/invoices/index.ts new file mode 100644 index 000000000..620937d8d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/index.ts @@ -0,0 +1,39 @@ +/** + * Invoices module — barrel exports. + */ + +export { + invoiceTable, + invoiceClientTable, + invoiceSettingsTable, + INVOICES_GUEST_SEED, +} from './collections'; + +export { + STATUS_LABELS, + STATUS_COLORS, + VAT_RATES_CH, + VAT_RATES_DE, + CURRENCIES, + DEFAULT_DUE_DAYS, + DEFAULT_NUMBER_PREFIX, + DEFAULT_NUMBER_PADDING, + INVOICE_SETTINGS_ID, +} from './constants'; + +export type { + LocalInvoice, + LocalInvoiceLine, + LocalInvoiceClient, + LocalInvoiceSettings, + Invoice, + InvoiceLine, + InvoiceClient, + InvoiceSettings, + InvoiceStatus, + InvoiceTotals, + InvoiceClientSnapshot, + VatBreakdownEntry, + Currency, + ClientSource, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/module.config.ts b/apps/mana/apps/web/src/lib/modules/invoices/module.config.ts new file mode 100644 index 000000000..e4447524d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const invoicesModuleConfig: ModuleConfig = { + appId: 'invoices', + tables: [{ name: 'invoices' }, { name: 'invoiceClients' }, { name: 'invoiceSettings' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/types.ts b/apps/mana/apps/web/src/lib/modules/invoices/types.ts new file mode 100644 index 000000000..06ae5c76a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/types.ts @@ -0,0 +1,231 @@ +/** + * Invoices module types. + * + * Outbound finance: the user issues invoices to their clients. Plan: + * `docs/plans/invoices-module.md`. Sibling to `finance` (inbound tracking), + * not a replacement. + * + * Money is stored as integers in the currency's smallest unit (Rappen / cents) + * to avoid floating-point drift on totals. Conversion to display format + * happens in the view layer. + */ + +import type { BaseRecord } from '@mana/local-store'; + +// ─── Discriminators & Enums ────────────────────────────── + +export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'void'; + +export type Currency = 'CHF' | 'EUR' | 'USD'; + +/** + * Which table the `clientId` FK points into. Set at create time so the + * resolver can look up the snapshot without having to probe both tables. + */ +export type ClientSource = 'contact' | 'invoice-client'; + +// ─── Line Items ────────────────────────────────────────── + +/** + * A single invoice line. `id` is a client-generated UUID used as the stable + * key for reorder operations — not a Dexie row id (lines live inline on the + * invoice for atomicity of edits and serialisation through the encryption + * boundary). + */ +export interface LocalInvoiceLine { + id: string; + title: string; + description?: string | null; + quantity: number; + unit?: string | null; + /** In the currency's smallest unit (Rappen / cents), integer. */ + unitPrice: number; + /** Percent; e.g. 8.1 for Swiss standard rate. */ + vatRate: number; + /** Percent; 0..100. null/undefined means no discount. */ + discount?: number | null; +} + +// ─── Totals ────────────────────────────────────────────── + +export interface VatBreakdownEntry { + rate: number; + base: number; + tax: number; +} + +/** + * Denormalised totals — recomputed on every update. Kept plaintext so the + * dashboard widgets / overdue-sum queries don't have to decrypt. + */ +export interface InvoiceTotals { + net: number; + vat: number; + gross: number; + vatBreakdown: VatBreakdownEntry[]; +} + +// ─── Client Snapshot ───────────────────────────────────── + +/** + * Frozen at send time so that later edits to the Contact / InvoiceClient + * don't retroactively change a legally binding, already-sent invoice. + * See §"Offene Fragen" in the plan for the edit-after-send policy (only + * drafts are editable for this reason). + */ +export interface InvoiceClientSnapshot { + name: string; + address?: string; + email?: string; + vatNumber?: string; +} + +// ─── Local Record — Invoice (Dexie) ────────────────────── + +export interface LocalInvoice extends BaseRecord { + /** Rendered invoice number, e.g. "2026-0042". Generated from settings. */ + number: string; + status: InvoiceStatus; + clientId: string | null; + clientSource: ClientSource; + clientSnapshot: InvoiceClientSnapshot; + currency: Currency; + /** YYYY-MM-DD */ + issueDate: string; + /** YYYY-MM-DD */ + dueDate: string; + /** ISO timestamp — filled when status transitions to sent. */ + sentAt?: string | null; + /** ISO timestamp — filled when status transitions to paid. */ + paidAt?: string | null; + lines: LocalInvoiceLine[]; + subject?: string | null; + notes?: string | null; + terms?: string | null; + /** QR-Referenz (CH: 27 digits, mod-10 check) or SCOR ISO 11649. */ + referenceNumber?: string | null; + /** uload media id of the most recently rendered PDF (cache). */ + pdfBlobKey?: string | null; + totals: InvoiceTotals; +} + +// ─── Local Record — Client Book (Dexie) ────────────────── + +/** + * Optional separate client book for invoice-specific data that doesn't + * belong in the main contacts table (IBAN for SEPA, default due days, + * default currency). Users can also just invoice contacts directly — + * this table is opt-in. + */ +export interface LocalInvoiceClient extends BaseRecord { + name: string; + address?: string | null; + email?: string | null; + vatNumber?: string | null; + iban?: string | null; + defaultCurrency: Currency; + defaultDueDays: number; + notes?: string | null; +} + +// ─── Local Record — Settings (Dexie, singleton per user) ─ + +/** + * User-scoped sender profile + number sequence state. There is exactly one + * row per user; `id` is a stable sentinel (`'invoice-settings'`) and the + * Dexie hook stamps userId as usual. + */ +export interface LocalInvoiceSettings extends BaseRecord { + // Sender profile + senderName: string; + senderAddress: string; + senderEmail: string; + senderVatNumber?: string | null; + senderIban: string; + senderBic?: string | null; + + // Branding + logoMediaId?: string | null; + accentColor: string; + footer?: string | null; + + // Number sequence + numberPrefix: string; + numberPadding: number; + nextNumber: number; + + // Defaults + defaultCurrency: Currency; + defaultVatRate: number; + defaultDueDays: number; + defaultTerms?: string | null; +} + +// ─── Domain Types (plaintext, for UI) ──────────────────── + +export interface InvoiceLine { + id: string; + title: string; + description: string | null; + quantity: number; + unit: string | null; + unitPrice: number; + vatRate: number; + discount: number | null; +} + +export interface Invoice { + id: string; + number: string; + status: InvoiceStatus; + clientId: string | null; + clientSource: ClientSource; + clientSnapshot: InvoiceClientSnapshot; + currency: Currency; + issueDate: string; + dueDate: string; + sentAt: string | null; + paidAt: string | null; + lines: InvoiceLine[]; + subject: string | null; + notes: string | null; + terms: string | null; + referenceNumber: string | null; + pdfBlobKey: string | null; + totals: InvoiceTotals; + createdAt: string; + updatedAt: string; +} + +export interface InvoiceClient { + id: string; + name: string; + address: string | null; + email: string | null; + vatNumber: string | null; + iban: string | null; + defaultCurrency: Currency; + defaultDueDays: number; + notes: string | null; + createdAt: string; +} + +export interface InvoiceSettings { + id: string; + senderName: string; + senderAddress: string; + senderEmail: string; + senderVatNumber: string | null; + senderIban: string; + senderBic: string | null; + logoMediaId: string | null; + accentColor: string; + footer: string | null; + numberPrefix: string; + numberPadding: number; + nextNumber: number; + defaultCurrency: Currency; + defaultVatRate: number; + defaultDueDays: number; + defaultTerms: string | null; +} diff --git a/apps/mana/apps/web/src/routes/(app)/invoices/+page.svelte b/apps/mana/apps/web/src/routes/(app)/invoices/+page.svelte new file mode 100644 index 000000000..3639c5640 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/invoices/+page.svelte @@ -0,0 +1,9 @@ + + + + Rechnungen - Mana + + + diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index b65bb7fb3..56a557edc 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -231,6 +231,11 @@ export const APP_ICONS = { // gradient sits next to music/photos/picture in the Kreativität & Medien row. `` ), + invoices: svgToDataUrl( + // Document with a QR-code corner (CH QR-Bill) + a diagonal amount line. + // Emerald→teal sits next to finance green in the Arbeit & Finanzen row. + `` + ), } as const; export type AppIconId = keyof typeof APP_ICONS; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index ed26b23a3..bcff304ab 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -1020,6 +1020,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'guest', }, + { + id: 'invoices', + name: 'Rechnungen', + description: { + de: 'Rechnungen stellen mit QR-Bill', + en: 'Issue invoices with Swiss QR-Bill', + }, + longDescription: { + de: 'Rechnungen an Kunden stellen — mit PDF-Export, Schweizer QR-Rechnung, Mehrwertsteuer und Zahlungsverfolgung. Ergänzt das Finance-Modul um die Outbound-Seite.', + en: 'Issue invoices to clients — with PDF export, Swiss QR-Bill, VAT handling and payment tracking. Adds the outbound side to the Finance module.', + }, + icon: APP_ICONS.invoices, + color: '#059669', + comingSoon: false, + status: 'development', + requiredTier: 'alpha', + }, ]; /**