diff --git a/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts b/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts index 4d14e74fc..634293e66 100644 --- a/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts +++ b/apps/mana/apps/web/src/lib/components/dashboard/widget-registry.ts @@ -33,6 +33,7 @@ import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.s import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte'; import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte'; import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte'; +import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte'; import DayTimelineWidget from './widgets/DayTimelineWidget.svelte'; import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte'; @@ -62,4 +63,5 @@ export const widgetComponents: Record = { period: PeriodWidget, 'news-unread': NewsUnreadWidget, 'body-stats': BodyStatsWidget, + 'invoices-open': InvoicesOpenWidget, }; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/mail-template.test.ts b/apps/mana/apps/web/src/lib/modules/invoices/mail-template.test.ts new file mode 100644 index 000000000..7246ce565 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/mail-template.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest'; +import { buildInvoiceMailDraft, mailDraftToMailto, looksLikeEmail } from './mail-template'; +import type { Invoice, InvoiceSettings } from './types'; + +const settings: InvoiceSettings = { + id: 'invoice-settings', + senderName: 'Till', + senderAddress: 'Bahnhofstrasse 1\n8000 Zürich', + senderEmail: 'till@example.ch', + senderVatNumber: null, + senderIban: 'CH9300762011623852957', + senderBic: null, + logoMediaId: null, + accentColor: '#059669', + footer: null, + numberPrefix: '2026-', + numberPadding: 4, + nextNumber: 1, + defaultCurrency: 'CHF', + defaultVatRate: 8.1, + defaultDueDays: 30, + defaultTerms: null, +}; + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: 'i', + number: '2026-0001', + status: 'draft', + clientId: null, + clientSource: 'invoice-client', + clientSnapshot: { + name: 'Kunde GmbH', + address: 'Seefeldstrasse 5\n8008 Zürich', + email: 'kunde@example.ch', + }, + currency: 'CHF', + issueDate: '2026-04-20', + dueDate: '2026-05-20', + sentAt: null, + paidAt: null, + lines: [], + subject: null, + notes: null, + terms: null, + referenceNumber: null, + pdfBlobKey: null, + totals: { + net: 15000, + vat: 1215, + gross: 16215, + vatBreakdown: [{ rate: 8.1, base: 15000, tax: 1215 }], + }, + createdAt: '', + updatedAt: '', + ...overrides, + }; +} + +describe('buildInvoiceMailDraft', () => { + it('prefills recipient from the frozen snapshot', () => { + const draft = buildInvoiceMailDraft(makeInvoice(), settings); + expect(draft.to).toBe('kunde@example.ch'); + }); + + it('empty recipient when snapshot has no email', () => { + const invoice = makeInvoice({ + clientSnapshot: { name: 'Kunde', address: 'foo\n8000 bar' }, + }); + const draft = buildInvoiceMailDraft(invoice, settings); + expect(draft.to).toBe(''); + }); + + it('includes the invoice number in the subject', () => { + const draft = buildInvoiceMailDraft(makeInvoice(), settings); + expect(draft.subject).toContain('2026-0001'); + }); + + it('appends subject when present', () => { + const draft = buildInvoiceMailDraft(makeInvoice({ subject: 'Beratung April' }), settings); + expect(draft.subject).toContain('Beratung April'); + expect(draft.subject).toBe('Rechnung 2026-0001 — Beratung April'); + }); + + it('omits the em-dash when subject is blank', () => { + const draft = buildInvoiceMailDraft(makeInvoice({ subject: null }), settings); + expect(draft.subject).toBe('Rechnung 2026-0001'); + }); + + it('mentions QR-Einzahlungsschein only for CHF', () => { + const chf = buildInvoiceMailDraft(makeInvoice({ currency: 'CHF' }), settings); + expect(chf.body).toContain('QR-Einzahlungsschein'); + const eur = buildInvoiceMailDraft(makeInvoice({ currency: 'EUR' }), settings); + expect(eur.body).not.toContain('QR-Einzahlungsschein'); + }); + + it('signs off with sender name', () => { + const draft = buildInvoiceMailDraft(makeInvoice(), settings); + expect(draft.body).toContain('Till'); + }); + + it('falls back to a generic salutation when no client name', () => { + const invoice = makeInvoice({ + clientSnapshot: { name: '', address: '', email: '' }, + }); + const draft = buildInvoiceMailDraft(invoice, settings); + expect(draft.body).toContain('Geehrte Damen und Herren'); + }); + + it('includes gross amount and due date', () => { + const draft = buildInvoiceMailDraft(makeInvoice(), settings); + expect(draft.body).toContain('2026-05-20'); + expect(draft.body).toContain('CHF 162.15'); + }); +}); + +describe('mailDraftToMailto', () => { + it('starts with mailto: and encodes the recipient', () => { + const url = mailDraftToMailto({ to: 'foo@bar.ch', subject: 's', body: 'b' }); + expect(url.startsWith('mailto:')).toBe(true); + expect(url).toContain('foo%40bar.ch'); + }); + + it('encodes spaces as %20 (not `+`) for cross-client compatibility', () => { + // URLSearchParams defaults to `+` which macOS Mail / Outlook take + // literally — our patch ensures Apple Mail / iOS render "Hello World" + // and not "Hello+World" in the subject field. + const url = mailDraftToMailto({ to: 'x@y.z', subject: 'Hello World', body: 'a b c' }); + expect(url).not.toContain('+'); + expect(url).toContain('Hello%20World'); + }); + + it('preserves newlines in the body', () => { + const url = mailDraftToMailto({ to: 'x@y.z', subject: 's', body: 'line1\nline2' }); + expect(url).toContain('line1%0Aline2'); + }); +}); + +describe('looksLikeEmail', () => { + it.each([ + ['kontakt@memoro.ai', true], + ['a@b.co', true], + ['spaces inside@bad.com', false], + ['no-at.com', false], + ['trailing@', false], + ['', false], + [' padded@example.com ', true], // allows leading/trailing whitespace (trimmed) + ])('%s → %s', (input, expected) => { + expect(looksLikeEmail(input)).toBe(expected); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.test.ts b/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.test.ts new file mode 100644 index 000000000..d60520c0b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest'; +import { isSCORReferenceValid } from 'swissqrbill/utils'; +import { generateSCORReference, buildQRBillData, QRBillError } from './qr-bill'; +import type { Invoice, InvoiceSettings } from '../types'; + +// ─── Factories ───────────────────────────────────────── + +function makeSettings(overrides: Partial = {}): InvoiceSettings { + return { + id: 'invoice-settings', + senderName: 'Muster AG', + senderAddress: 'Bahnhofstrasse 1\n8000 Zürich', + senderEmail: 'hello@muster.ch', + senderVatNumber: null, + senderIban: 'CH9300762011623852957', + senderBic: null, + logoMediaId: null, + accentColor: '#059669', + footer: null, + numberPrefix: '2026-', + numberPadding: 4, + nextNumber: 1, + defaultCurrency: 'CHF', + defaultVatRate: 8.1, + defaultDueDays: 30, + defaultTerms: null, + ...overrides, + }; +} + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: 'i1', + number: '2026-0042', + status: 'draft', + clientId: null, + clientSource: 'invoice-client', + clientSnapshot: { + name: 'Kunde GmbH', + address: 'Seefeldstrasse 5\n8008 Zürich', + email: 'kunde@example.ch', + }, + currency: 'CHF', + issueDate: '2026-04-20', + dueDate: '2026-05-20', + sentAt: null, + paidAt: null, + lines: [], + subject: 'Beratung', + notes: null, + terms: null, + referenceNumber: null, + pdfBlobKey: null, + totals: { + net: 10000, + vat: 810, + gross: 10810, + vatBreakdown: [{ rate: 8.1, base: 10000, tax: 810 }], + }, + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + ...overrides, + }; +} + +// ─── SCOR reference ───────────────────────────────────── + +describe('generateSCORReference', () => { + it('produces a spec-valid SCOR reference', () => { + const ref = generateSCORReference('2026-0042'); + expect(isSCORReferenceValid(ref)).toBe(true); + }); + + it('starts with RF', () => { + expect(generateSCORReference('2026-0042')).toMatch(/^RF/); + }); + + it('is deterministic for the same invoice number', () => { + expect(generateSCORReference('2026-0042')).toBe(generateSCORReference('2026-0042')); + }); + + it('strips non-alphanumerics from the payload', () => { + const ref = generateSCORReference('2026-0042'); + expect(ref).not.toContain('-'); + }); + + it('truncates payloads to 21 chars so the overall reference fits spec (25)', () => { + const ref = generateSCORReference('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + // RF + 2 check digits + ≤21 payload = 25 chars total + expect(ref.length).toBeLessThanOrEqual(25); + }); + + it('falls back for all-separator input', () => { + const ref = generateSCORReference('---'); + expect(isSCORReferenceValid(ref)).toBe(true); + }); +}); + +// ─── buildQRBillData ───────────────────────────────────── + +describe('buildQRBillData', () => { + it('builds a valid data object for CHF invoice with all fields set', () => { + const data = buildQRBillData(makeInvoice(), makeSettings()); + expect(data.currency).toBe('CHF'); + expect(data.creditor.account).toBe('CH9300762011623852957'); + expect(data.creditor.zip).toBe('8000'); + expect(data.creditor.city).toBe('Zürich'); + expect(data.debtor?.zip).toBe('8008'); + expect(data.amount).toBeCloseTo(108.1, 2); + expect(data.reference).toBeTruthy(); + }); + + it('rejects USD (only CHF + EUR allowed)', () => { + const invoice = makeInvoice({ currency: 'USD' }); + expect(() => buildQRBillData(invoice, makeSettings())).toThrow(QRBillError); + try { + buildQRBillData(invoice, makeSettings()); + } catch (e) { + expect(e).toBeInstanceOf(QRBillError); + expect((e as QRBillError).reason).toBe('invalid-currency'); + } + }); + + it('rejects missing IBAN with a specific reason', () => { + try { + buildQRBillData(makeInvoice(), makeSettings({ senderIban: '' })); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(QRBillError); + expect((e as QRBillError).reason).toBe('missing-iban'); + } + }); + + it('rejects invalid IBAN (bad checksum)', () => { + try { + buildQRBillData(makeInvoice(), makeSettings({ senderIban: 'CH0000000000000000000' })); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(QRBillError); + expect((e as QRBillError).reason).toBe('invalid-iban'); + } + }); + + it('rejects unparseable sender address', () => { + try { + buildQRBillData(makeInvoice(), makeSettings({ senderAddress: 'eine Zeile nur' })); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(QRBillError); + expect((e as QRBillError).reason).toBe('unparseable-sender-address'); + } + }); + + it('rejects zero / negative amount', () => { + const invoice = makeInvoice({ + totals: { net: 0, vat: 0, gross: 0, vatBreakdown: [] }, + }); + try { + buildQRBillData(invoice, makeSettings()); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(QRBillError); + expect((e as QRBillError).reason).toBe('missing-amount'); + } + }); + + it('accepts unparseable client address and omits the debtor block', () => { + // Debtor is optional per spec — we still emit the QR-Bill, the user + // can fill the payer side by hand when paying. + const invoice = makeInvoice({ + clientSnapshot: { name: 'Bar', address: 'nur eine zeile' }, + }); + const data = buildQRBillData(invoice, makeSettings()); + expect(data.debtor).toBeUndefined(); + }); + + it('parses multi-line street with house number on line 1', () => { + const data = buildQRBillData( + makeInvoice(), + makeSettings({ senderAddress: 'Bahnhofstrasse 1\nPostfach\n8000 Zürich' }) + ); + // Lines 1+2 join as street; line 3 is the zip+city. + expect(data.creditor.address).toBe('Bahnhofstrasse 1, Postfach'); + expect(data.creditor.zip).toBe('8000'); + }); + + it('parses 5-digit German postcodes', () => { + const data = buildQRBillData( + makeInvoice({ currency: 'EUR' }), + makeSettings({ + senderIban: 'DE89370400440532013000', + senderAddress: 'Hauptstraße 5\n10115 Berlin', + }) + ); + expect(data.creditor.zip).toBe('10115'); + expect(data.creditor.city).toBe('Berlin'); + }); + + it('uses referenceNumber from invoice when set (preferred over regen)', () => { + const invoice = makeInvoice({ referenceNumber: 'RF18539007547034' }); + const data = buildQRBillData(invoice, makeSettings()); + expect(data.reference).toBe('RF18539007547034'); + }); + + it('regenerates reference when invoice.referenceNumber is null (migration path)', () => { + const invoice = makeInvoice({ referenceNumber: null }); + const data = buildQRBillData(invoice, makeSettings()); + expect(data.reference).toBeTruthy(); + expect(data.reference).toMatch(/^RF/); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/invoices/totals.test.ts b/apps/mana/apps/web/src/lib/modules/invoices/totals.test.ts new file mode 100644 index 000000000..f2c800d1e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/totals.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { computeLineTotal, computeInvoiceTotals, EMPTY_TOTALS } from './totals'; +import type { LocalInvoiceLine } from './types'; + +function line(overrides: Partial = {}): LocalInvoiceLine { + return { + id: 'l', + title: 't', + description: null, + quantity: 1, + unit: null, + unitPrice: 10000, + vatRate: 8.1, + discount: null, + ...overrides, + }; +} + +describe('computeLineTotal', () => { + it('returns net+tax in minor units with no discount', () => { + const { net, tax } = computeLineTotal(line({ quantity: 4, unitPrice: 15000, vatRate: 8.1 })); + expect(net).toBe(60000); + // 60000 * 8.1 / 100 = 4860 + expect(tax).toBe(4860); + }); + + it('applies percentage discount before VAT', () => { + // 10.- at 10% discount = 9.- net; 19% VAT = 1.71 + const { net, tax } = computeLineTotal( + line({ quantity: 1, unitPrice: 1000, vatRate: 19, discount: 10 }) + ); + expect(net).toBe(900); + expect(tax).toBe(171); + }); + + it('rounds tax half-up at the per-line level', () => { + // 123 @ 8.1% = 9.963 → rounds to 10 + const { tax } = computeLineTotal(line({ quantity: 1, unitPrice: 123, vatRate: 8.1 })); + expect(tax).toBe(10); + }); + + it('handles zero VAT rate', () => { + const { net, tax } = computeLineTotal(line({ quantity: 2, unitPrice: 5000, vatRate: 0 })); + expect(net).toBe(10000); + expect(tax).toBe(0); + }); +}); + +describe('computeInvoiceTotals', () => { + it('returns EMPTY_TOTALS-equivalent for no lines', () => { + const totals = computeInvoiceTotals([]); + expect(totals.net).toBe(0); + expect(totals.vat).toBe(0); + expect(totals.gross).toBe(0); + expect(totals.vatBreakdown).toEqual([]); + }); + + it('sums a single-rate invoice', () => { + const totals = computeInvoiceTotals([ + line({ quantity: 2, unitPrice: 10000, vatRate: 8.1 }), + line({ id: 'l2', quantity: 1, unitPrice: 5000, vatRate: 8.1 }), + ]); + expect(totals.net).toBe(25000); // 20000 + 5000 + expect(totals.vat).toBe(1620 + 405); // 2025 + expect(totals.gross).toBe(totals.net + totals.vat); + expect(totals.vatBreakdown).toHaveLength(1); + expect(totals.vatBreakdown[0]).toEqual({ rate: 8.1, base: 25000, tax: 2025 }); + }); + + it('groups by VAT rate and sorts breakdown ascending', () => { + const totals = computeInvoiceTotals([ + line({ quantity: 1, unitPrice: 10000, vatRate: 8.1 }), + line({ id: 'l2', quantity: 1, unitPrice: 5000, vatRate: 2.6 }), + line({ id: 'l3', quantity: 1, unitPrice: 2000, vatRate: 8.1 }), + ]); + expect(totals.vatBreakdown.map((b) => b.rate)).toEqual([2.6, 8.1]); + const [low, high] = totals.vatBreakdown; + expect(low.base).toBe(5000); + expect(high.base).toBe(12000); + }); + + it('preserves the invariant: breakdown sums equal invoice totals', () => { + // Critical for the PDF footer + dashboard — if this drifts, users + // see "sum of VAT rows" ≠ "VAT total" and lose trust. + const lines = [ + line({ quantity: 3.5, unitPrice: 7777, vatRate: 8.1 }), + line({ id: 'l2', quantity: 1, unitPrice: 3333, vatRate: 2.6, discount: 15 }), + line({ id: 'l3', quantity: 2, unitPrice: 999, vatRate: 0 }), + ]; + const totals = computeInvoiceTotals(lines); + const breakdownBase = totals.vatBreakdown.reduce((s, b) => s + b.base, 0); + const breakdownTax = totals.vatBreakdown.reduce((s, b) => s + b.tax, 0); + expect(breakdownBase).toBe(totals.net); + expect(breakdownTax).toBe(totals.vat); + }); + + it('EMPTY_TOTALS matches an empty computation', () => { + expect(computeInvoiceTotals([])).toEqual(EMPTY_TOTALS); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/invoices/widgets/InvoicesOpenWidget.svelte b/apps/mana/apps/web/src/lib/modules/invoices/widgets/InvoicesOpenWidget.svelte new file mode 100644 index 000000000..97aff8357 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/widgets/InvoicesOpenWidget.svelte @@ -0,0 +1,146 @@ + + +
+
+

+ + Rechnungen +

+ Alle → +
+ + {#if loading} +
+ {#each Array(3) as _} +
+ {/each} +
+ {:else if invoices.length === 0} +
+

Noch keine Rechnungen gestellt.

+ + Erste Rechnung + +
+ {:else} +
+
+
Offen
+
+ {formatAmount(openSum, openCurrency)} +
+
+
0} + > +
Überfällig
+
0}> + {formatAmount(overdueSum, openCurrency)} +
+ {#if overdueCount > 0} +
{overdueCount} Rechnungen
+ {/if} +
+
+ + {#if topOverdue.length > 0} + + {/if} + {/if} +
diff --git a/apps/mana/apps/web/src/lib/types/dashboard.test.ts b/apps/mana/apps/web/src/lib/types/dashboard.test.ts index a16d570f5..5444b0193 100644 --- a/apps/mana/apps/web/src/lib/types/dashboard.test.ts +++ b/apps/mana/apps/web/src/lib/types/dashboard.test.ts @@ -56,6 +56,7 @@ describe('WIDGET_REGISTRY', () => { 'food', 'plants', 'period', + 'body', undefined, ]; for (const widget of WIDGET_REGISTRY) { diff --git a/apps/mana/apps/web/src/lib/types/dashboard.ts b/apps/mana/apps/web/src/lib/types/dashboard.ts index 6965a115e..779bd324e 100644 --- a/apps/mana/apps/web/src/lib/types/dashboard.ts +++ b/apps/mana/apps/web/src/lib/types/dashboard.ts @@ -32,7 +32,8 @@ export type WidgetType = | 'activity-feed' // TimeBlocks: recent activity across modules | 'period' // Period: current phase + days until next period | 'news-unread' // News: latest unread curated articles - | 'body-stats'; // Body: latest weight + active workout summary + | 'body-stats' // Body: latest weight + active workout summary + | 'invoices-open'; // Invoices: open/overdue totals + oldest overdue /** * Widget size - maps to CSS Grid columns @@ -362,6 +363,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [ allowMultiple: false, requiredBackend: 'body', }, + { + type: 'invoices-open', + nameKey: 'dashboard.widgets.invoices_open.title', + descriptionKey: 'dashboard.widgets.invoices_open.description', + icon: '📄', + defaultSize: 'medium', + allowMultiple: false, + }, ]; /** diff --git a/docs/plans/invoices-module.md b/docs/plans/invoices-module.md index 7fe8efbff..18cf61f66 100644 --- a/docs/plans/invoices-module.md +++ b/docs/plans/invoices-module.md @@ -2,9 +2,29 @@ ## Status (2026-04-20) -**M0 Planning** — dieser Plan. Noch kein Code. +**M1–M7 DONE** — solo-tauglich, ohne Logo-Upload + AI-Tools. -Nächster Schritt: M1 Skelett (Modul registriert, Dexie-Table, Guest-Seed, leere ListView). +| Milestone | Commit | Notes | +|---|---|---| +| M1 Skelett | `2cf89ce26` | Modul registriert, Dexie v27, Encryption-Registry, leere ListView | +| M2 CRUD | `8d00ee069` | Store, Totals, Status-Maschine, ListView mit Stats-Cards, Routes | +| M3 Settings | in M2 | SenderProfileForm fertig, **Logo-Upload offen** (uload-Integration nötig) | +| M4 PDF Basic | `2dc298a79` | pdf-lib, pagination, preview + download | +| M5 QR-Rechnung | `5af23d30b` | swissqrbill/svg + PNG-Overlay, SCOR auto, address-parser-Heuristik | +| M6 Versand | `08b7ac16b` | SendModal mit mailto + PDF-Download + Confirm → status sent | +| M7 Dashboard | pending commit | InvoicesOpenWidget mit Offen/Überfällig + Top-3 älteste überfällig | + +**Tests (45+ green):** totals math, SCOR + address-parser (swissqrbill `isSCORReferenceValid` roundtrip), mail template + mailto encoding. + +**Offene Arbeit vor "produktiv einsetzbar":** +- **M3-Polish:** Logo-Upload via uload → erscheint im PDF-Header +- **M8 AI-Tools:** `create_invoice` (propose), `mark_paid` (propose), `list_invoices` (auto), `get_invoice_stats` (auto) — braucht Planner-Drift-Guard-Update +- **Nummernkreis-Multi-Device-Kollision:** dokumentiert als gap (pragmatisch, solo-MVP-tauglich) +- **Strukturierte Adressen:** Heuristik-Parser deckt Standard-CH/DE ab; komplexere Adressen fehlen → User sieht in Warning, was fehlt +- **Bezahlte Rechnung → finance-Transaktion:** Cross-Modul-Hook, Phase 3 +- **camt.053 Bankabgleich:** Phase 2 + +Nächster Schritt: M3 Logo-Upload ODER M8 AI-Tools ODER Real-World-Dogfooding (erste eigene Rechnung an einen echten Kunden). ---