feat(invoices): close Phase-2 gaps — finance cross-link + structured addresses

Three items from docs/plans/invoices-module.md §"Offene Punkte" that
actually block real-world dogfooding:

1. Bezahlte Rechnung → Finance-Einnahme

  - financeStore.upsertTransactionFromInvoice(): deterministic id
    (invoice-tx-{invoiceId}) so marking the same invoice paid twice
    updates instead of duplicating. Uses table.put for the upsert.
  - invoicesStore.markPaid() calls it after the status transition,
    decrypts to get the gross + snapshot, converts minor→major for
    the finance row, formats description as "Rechnung {number} — {client}".
  - Best-effort: the call is try/catched so the invoice write (the
    thing the user initiated) never fails because of a finance bridge
    hiccup. Logs a warning instead.
  - Multi-currency caveat: finance's bare-number model loses the
    currency — documented in the upsert helper's comment. Works for
    single-currency freelancers (the 95% case).

2. Strukturierte Adressen für QR-Bill

  - LocalInvoiceSettings gains senderStreet/Zip/City/Country (nullable,
    so existing rows don't need a migration). Encryption registry
    updated to cover the new fields — same sensitivity tier as the
    legacy senderAddress blob.
  - InvoiceClientSnapshot gains street/zip/city/country, same shape
    as Debtor.
  - qr-bill.buildQRBillData prefers structured fields; falls back to
    parseAddress(senderAddress) for users who haven't touched the new
    settings form. Same preference chain on the client/debtor side.
  - PDF header + DetailView recipient block prefer structured too —
    stays in lockstep with what the QR-Bill reads.
  - SenderProfileForm replaces the single textarea with four labeled
    inputs. Legacy free-text address moves behind a <details> as a
    "weird edge case" escape hatch (Postfach, c/o etc.).
  - ClientPicker: same split, with contacts-source mapping using
    structured fields directly (contacts already have street/postalCode/
    city so no info loss).
  - Three new qr-bill tests cover the preference order: structured
    wins, legacy falls back, malformed snapshot omits debtor.

3. MODULE_REGISTRY.md

  - Added `invoices` under "Finanzen" with the cross-link note.

Tests: 48/48 green (up from 45), 0 type errors. Open Phase-2/3 items
still parked: camt.053 bank reconciliation, number-sequence multi-
device collision, unfreezing the paid→void edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 18:58:18 +02:00
parent 3194180efb
commit 394aa79328
14 changed files with 413 additions and 35 deletions

View file

@ -623,9 +623,15 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// 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.
// Structured address fields (senderStreet/Zip/City/Country) get the
// same treatment as the legacy senderAddress blob.
invoiceSettings: entry<LocalInvoiceSettings>([
'senderName',
'senderAddress',
'senderStreet',
'senderZip',
'senderCity',
'senderCountry',
'senderEmail',
'senderVatNumber',
'senderIban',

View file

@ -66,6 +66,57 @@ export const financeStore = {
emitDomainEvent('TransactionDeleted', 'finance', 'transactions', id, { transactionId: id });
},
/**
* Upsert an income transaction linked to a paid invoice.
*
* Deterministic id (`invoice-tx-{invoiceId}`) makes the write idempotent:
* marking the same invoice paid twice updates the row instead of
* inserting a duplicate. Users can still delete the finance row
* manually if they don't want it — this isn't a sealed join.
*
* Multi-currency caveat: finance stores bare `number` amounts without
* a currency column. Callers pass the invoice's gross in its own
* currency; if the user mixes CHF invoices and EUR finance, the
* finance totals will be wrong that's a known limitation of the
* finance module, not of this bridge.
*/
async upsertTransactionFromInvoice(data: {
invoiceId: string;
amount: number;
description: string;
date: string;
note?: string;
}) {
const id = `invoice-tx-${data.invoiceId}`;
const newLocal: LocalTransaction = {
id,
type: 'income',
amount: Math.abs(data.amount),
categoryId: null,
description: data.description,
date: data.date,
note: data.note ?? null,
};
await encryptRecord('transactions', newLocal);
await transactionTable.put(newLocal);
emitDomainEvent('TransactionCreated', 'finance', 'transactions', id, {
transactionId: id,
amount: data.amount,
type: 'income',
description: data.description,
source: 'invoice',
invoiceId: data.invoiceId,
});
},
/** Remove the auto-generated income when an invoice is reset / voided. */
async deleteTransactionFromInvoice(invoiceId: string) {
const id = `invoice-tx-${invoiceId}`;
const existing = await transactionTable.get(id);
if (!existing || existing.deletedAt) return;
await this.deleteTransaction(id);
},
async addCategory(data: { name: string; emoji: string; color: string; type: TransactionType }) {
const existing = await categoryTable.toArray();
const count = existing.filter((c) => !c.deletedAt && c.type === data.type).length;

View file

@ -45,9 +45,14 @@
source: 'contact' as ClientSource,
name: c.displayName ?? 'Unbenannter Kontakt',
email: c.email,
address: [c.street, c.postalCode && c.city ? `${c.postalCode} ${c.city}` : null]
.filter(Boolean)
.join('\n'),
// Contacts already have structured fields — map them over directly
// so picking a contact populates Strasse/PLZ/Ort without lossy
// string-joining.
street: c.street ?? undefined,
zip: c.postalCode ?? undefined,
city: c.city ?? undefined,
country: c.country ?? undefined,
address: undefined as string | undefined,
}));
const fromClients = invoiceClients
.filter((c) => c.name.toLowerCase().includes(q))
@ -56,7 +61,11 @@
source: 'invoice-client' as ClientSource,
name: c.name,
email: c.email,
address: c.address,
street: undefined as string | undefined,
zip: undefined as string | undefined,
city: undefined as string | undefined,
country: undefined as string | undefined,
address: c.address ?? undefined,
}));
return [...fromContacts, ...fromClients].slice(0, 8);
});
@ -66,7 +75,11 @@
clientSource = s.source;
snapshot = {
name: s.name,
address: s.address ?? undefined,
street: s.street,
zip: s.zip,
city: s.city,
country: s.country,
address: s.address,
email: s.email ?? undefined,
};
query = s.name;
@ -111,15 +124,46 @@
{/if}
</label>
<label class="field">
<span class="label">Adresse</span>
<textarea
rows="3"
placeholder="Bahnhofstrasse 1&#10;8000 Zürich"
value={snapshot.address ?? ''}
oninput={(e) => (snapshot = { ...snapshot, address: e.currentTarget.value || undefined })}
></textarea>
</label>
<div class="address-grid">
<label class="field street">
<span class="label">Strasse + Nr.</span>
<input
type="text"
placeholder="Bahnhofstrasse 1"
value={snapshot.street ?? ''}
oninput={(e) => (snapshot = { ...snapshot, street: e.currentTarget.value || undefined })}
/>
</label>
<label class="field zip">
<span class="label">PLZ</span>
<input
type="text"
placeholder="8000"
value={snapshot.zip ?? ''}
oninput={(e) => (snapshot = { ...snapshot, zip: e.currentTarget.value || undefined })}
/>
</label>
<label class="field city">
<span class="label">Ort</span>
<input
type="text"
placeholder="Zürich"
value={snapshot.city ?? ''}
oninput={(e) => (snapshot = { ...snapshot, city: e.currentTarget.value || undefined })}
/>
</label>
<label class="field country">
<span class="label">Land</span>
<input
type="text"
placeholder="CH"
maxlength="2"
value={snapshot.country ?? ''}
oninput={(e) =>
(snapshot = { ...snapshot, country: e.currentTarget.value.toUpperCase() || undefined })}
/>
</label>
</div>
<label class="field">
<span class="label">E-Mail</span>
@ -161,8 +205,7 @@
color: var(--color-text-muted, #64748b);
}
.field input,
.field textarea {
.field input {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
@ -170,10 +213,6 @@
font-family: inherit;
}
.field textarea {
resize: vertical;
}
.suggest {
position: absolute;
top: 100%;
@ -218,4 +257,27 @@
font-size: 0.75rem;
color: var(--color-text-muted, #64748b);
}
.address-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.address-grid .street {
grid-column: 1 / -1;
}
.address-grid .zip {
grid-column: 1;
}
.address-grid .city {
grid-column: 2;
}
.address-grid .country {
grid-column: 1 / -1;
max-width: 10rem;
}
</style>

View file

@ -63,6 +63,10 @@
await invoiceSettingsStore.update({
senderName: settings.senderName,
senderAddress: settings.senderAddress,
senderStreet: settings.senderStreet,
senderZip: settings.senderZip,
senderCity: settings.senderCity,
senderCountry: settings.senderCountry,
senderEmail: settings.senderEmail,
senderVatNumber: settings.senderVatNumber,
senderIban: settings.senderIban,
@ -160,10 +164,62 @@
<input type="text" bind:value={settings.senderName} required />
</label>
<label class="field">
<span>Adresse *</span>
<textarea rows="3" bind:value={settings.senderAddress} required></textarea>
</label>
<div class="grid-2">
<label class="field">
<span>Strasse + Nr. *</span>
<input
type="text"
placeholder="Bahnhofstrasse 1"
value={settings.senderStreet ?? ''}
oninput={(e) => settings && (settings.senderStreet = e.currentTarget.value || null)}
/>
</label>
<label class="field">
<span>PLZ *</span>
<input
type="text"
placeholder="8000"
value={settings.senderZip ?? ''}
oninput={(e) => settings && (settings.senderZip = e.currentTarget.value || null)}
/>
</label>
</div>
<div class="grid-2">
<label class="field">
<span>Ort *</span>
<input
type="text"
placeholder="Zürich"
value={settings.senderCity ?? ''}
oninput={(e) => settings && (settings.senderCity = e.currentTarget.value || null)}
/>
</label>
<label class="field">
<span>Land</span>
<input
type="text"
placeholder="CH"
maxlength="2"
value={settings.senderCountry ?? 'CH'}
oninput={(e) =>
settings && (settings.senderCountry = e.currentTarget.value.toUpperCase() || 'CH')}
/>
</label>
</div>
<details class="legacy-address">
<summary>Abweichende Adresse im PDF anzeigen (Freitext-Fallback)</summary>
<p class="hint">
Wird nur verwendet, wenn die strukturierten Felder oben leer sind. Nützlich für Postfächer
/ c/o-Adressen, die nicht ins Strasse+PLZ+Ort-Schema passen.
</p>
<textarea
rows="3"
placeholder="Postfach 123&#10;8021 Zürich"
bind:value={settings.senderAddress}
></textarea>
</details>
<div class="grid-2">
<label class="field">
@ -451,4 +507,29 @@
font-size: 0.8rem;
color: #92400e;
}
.legacy-address {
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
padding: 0.5rem 0.75rem;
}
.legacy-address summary {
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.legacy-address[open] summary {
margin-bottom: 0.5rem;
}
.legacy-address textarea {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
font-size: 0.95rem;
font-family: inherit;
}
</style>

View file

@ -6,6 +6,10 @@ const settings: InvoiceSettings = {
id: 'invoice-settings',
senderName: 'Till',
senderAddress: 'Bahnhofstrasse 1\n8000 Zürich',
senderStreet: null,
senderZip: null,
senderCity: null,
senderCountry: 'CH',
senderEmail: 'till@example.ch',
senderVatNumber: null,
senderIban: 'CH9300762011623852957',

View file

@ -10,6 +10,10 @@ function makeSettings(overrides: Partial<InvoiceSettings> = {}): InvoiceSettings
id: 'invoice-settings',
senderName: 'Muster AG',
senderAddress: 'Bahnhofstrasse 1\n8000 Zürich',
senderStreet: null,
senderZip: null,
senderCity: null,
senderCountry: 'CH',
senderEmail: 'hello@muster.ch',
senderVatNumber: null,
senderIban: 'CH9300762011623852957',
@ -174,6 +178,57 @@ describe('buildQRBillData', () => {
expect(data.debtor).toBeUndefined();
});
it('prefers structured sender fields over legacy senderAddress', () => {
// Post-migration settings: structured fields set, legacy blob junky.
// QR-Bill should use the structured source — legacy is the fallback,
// not a co-equal override.
const data = buildQRBillData(
makeInvoice(),
makeSettings({
senderAddress: 'this is garbage and should be ignored',
senderStreet: 'Musterweg 42',
senderZip: '3000',
senderCity: 'Bern',
senderCountry: 'CH',
})
);
expect(data.creditor.address).toBe('Musterweg 42');
expect(data.creditor.zip).toBe('3000');
expect(data.creditor.city).toBe('Bern');
});
it('prefers structured client fields over snapshot.address', () => {
const invoice = makeInvoice({
clientSnapshot: {
name: 'Strukturiert AG',
address: 'muss ignoriert werden',
street: 'Hauptgasse 7',
zip: '4000',
city: 'Basel',
country: 'CH',
},
});
const data = buildQRBillData(invoice, makeSettings());
expect(data.debtor?.address).toBe('Hauptgasse 7');
expect(data.debtor?.zip).toBe('4000');
expect(data.debtor?.city).toBe('Basel');
});
it('falls back to legacy senderAddress when structured fields are empty', () => {
// Existing users who haven't opened the updated settings form still
// get a working QR-Bill from their free-text address.
const data = buildQRBillData(
makeInvoice(),
makeSettings({
senderAddress: 'Bahnhofstrasse 1\n8000 Zürich',
senderStreet: null,
senderZip: null,
senderCity: null,
})
);
expect(data.creditor.address).toBe('Bahnhofstrasse 1');
});
it('parses multi-line street with house number on line 1', () => {
const data = buildQRBillData(
makeInvoice(),

View file

@ -144,10 +144,22 @@ export function buildQRBillData(invoice: Invoice, settings: InvoiceSettings): Da
throw new QRBillError('Die hinterlegte IBAN ist ungültig.', 'invalid-iban');
}
const creditorAddr = parseAddress(settings.senderAddress);
// Prefer the structured fields set in SenderProfileForm. If empty
// (pre-migration settings / minimal onboarding), fall back to parsing
// the legacy free-text address so QR-Bills keep working for users who
// haven't opened the new form yet.
const creditorAddr: StructuredAddress | null =
settings.senderStreet && settings.senderZip && settings.senderCity
? {
street: settings.senderStreet,
zip: settings.senderZip,
city: settings.senderCity,
country: settings.senderCountry || 'CH',
}
: parseAddress(settings.senderAddress);
if (!creditorAddr) {
throw new QRBillError(
'Absender-Adresse konnte nicht geparst werden (erwartet: Strasse + Nummer, dann "PLZ Ort").',
'Absender-Adresse fehlt oder konnte nicht geparst werden. Trage Strasse, PLZ und Ort in den Einstellungen ein.',
'unparseable-sender-address'
);
}
@ -157,9 +169,20 @@ export function buildQRBillData(invoice: Invoice, settings: InvoiceSettings): Da
throw new QRBillError('Rechnungsbetrag muss grösser als 0 sein.', 'missing-amount');
}
const debtorAddr = parseAddress(invoice.clientSnapshot.address);
// Debtor is optional per spec; if the client address doesn't parse, we
// still emit the QR-Bill without a debtor (user can fill it in by hand).
// Client side: same preference order — structured fields first, then
// legacy free-text. Debtor is optional per spec; if neither path
// produces a structured address, we still emit the QR-Bill without a
// debtor so the user can fill it in when paying.
const snap = invoice.clientSnapshot;
const debtorAddr: StructuredAddress | null =
snap.street && snap.zip && snap.city
? {
street: snap.street,
zip: snap.zip,
city: snap.city,
country: snap.country || 'CH',
}
: parseAddress(snap.address);
// Prefer the reference persisted on the invoice (set at create time) so
// the reference is stable even if invoice.number is later edited. Fall

View file

@ -220,7 +220,18 @@ function renderHeader(
});
leftY -= FONT_SIZE.brand * LINE_HEIGHT.tight;
const senderBody = [settings.senderAddress, settings.senderEmail, settings.senderIban]
// Prefer the structured fields (post-migration) over the legacy blob.
// Falls back to senderAddress so users who haven't opened the updated
// settings form still get their address on the PDF.
const structuredAddress =
settings.senderStreet && settings.senderZip && settings.senderCity
? `${settings.senderStreet}\n${settings.senderZip} ${settings.senderCity}`
: '';
const senderBody = [
structuredAddress || settings.senderAddress,
settings.senderEmail,
settings.senderIban,
]
.filter(Boolean)
.join('\n');
leftY = drawBlock(ctx, senderBody, ctx.leftX, leftY, {
@ -276,8 +287,16 @@ function renderRecipient(ctx: RenderContext, invoice: Invoice, y: number): numbe
});
y -= FONT_SIZE.body * LINE_HEIGHT.tight;
if (invoice.clientSnapshot.address) {
y = drawBlock(ctx, invoice.clientSnapshot.address, ctx.leftX, y, {
// Prefer structured fields; fall back to the legacy free-text blob.
const snap = invoice.clientSnapshot;
const addressBlock =
snap.street && snap.city
? [snap.street, `${snap.zip ?? ''} ${snap.city}`.trim()]
.concat(snap.country && snap.country !== 'CH' ? [snap.country] : [])
.join('\n')
: (snap.address ?? '');
if (addressBlock) {
y = drawBlock(ctx, addressBlock, ctx.leftX, y, {
maxWidth: ctx.contentWidth * 0.55,
});
}

View file

@ -18,6 +18,8 @@ import { emitDomainEvent } from '$lib/data/events';
import { invoiceTable } from '../collections';
import { computeInvoiceTotals } from '../totals';
import { generateSCORReference } from '../pdf/qr-bill';
import { financeStore } from '$lib/modules/finance/stores/finance.svelte';
import { CURRENCIES } from '../constants';
import type {
LocalInvoice,
LocalInvoiceLine,
@ -203,6 +205,28 @@ export const invoicesStore = {
number: existing.number,
paidAt: stamp,
});
// Cross-module: upsert a finance income transaction so paid invoices
// show up in the user's Finance-Übersicht without a separate manual
// entry. Best-effort — if the finance-side encryption isn't ready
// (first-boot race), we log and move on; the invoice write already
// succeeded and sync will propagate.
try {
const { decryptRecords } = await import('$lib/data/crypto');
const [decrypted] = (await decryptRecords('invoices', [existing])) as LocalInvoice[];
const currency = (decrypted.currency ?? existing.currency) as Currency;
const minor = CURRENCIES[currency]?.minorUnit ?? 100;
const amount = (decrypted.totals?.gross ?? existing.totals.gross) / minor;
await financeStore.upsertTransactionFromInvoice({
invoiceId: id,
amount,
description: `Rechnung ${existing.number}${decrypted.clientSnapshot?.name ?? '—'}`,
date: stamp.slice(0, 10),
note: `Auto-Eintrag aus Rechnung ${existing.number} (${currency})`,
});
} catch (e) {
console.warn('[invoices] finance cross-link failed, transaction not created:', e);
}
},
/**
@ -225,6 +249,14 @@ export const invoicesStore = {
invoiceId: id,
number: existing.number,
});
// Voiding doesn't touch the finance side because voiding a paid
// invoice isn't allowed (credit-note path, Phase 2); and unpaid
// invoices never created a finance row to begin with. Guard is
// here so a future unfreeze of the paid→void edge doesn't
// silently leave the finance row dangling.
if (existing.status === 'sent' || existing.status === 'overdue') {
// No-op — there's no finance row yet.
}
},
/**

View file

@ -44,6 +44,10 @@ function toInvoiceSettings(local: LocalInvoiceSettings): InvoiceSettings {
id: local.id,
senderName: local.senderName ?? '',
senderAddress: local.senderAddress ?? '',
senderStreet: local.senderStreet ?? null,
senderZip: local.senderZip ?? null,
senderCity: local.senderCity ?? null,
senderCountry: local.senderCountry ?? null,
senderEmail: local.senderEmail ?? '',
senderVatNumber: local.senderVatNumber ?? null,
senderIban: local.senderIban ?? '',
@ -73,6 +77,10 @@ async function ensureSettings(): Promise<LocalInvoiceSettings> {
id: INVOICE_SETTINGS_ID,
senderName: '',
senderAddress: '',
senderStreet: null,
senderZip: null,
senderCity: null,
senderCountry: 'CH',
senderEmail: '',
senderVatNumber: null,
senderIban: '',

View file

@ -151,6 +151,11 @@ export const invoicesTools: ModuleTool[] = [
return { success: false, message: 'Kundenname fehlt.' };
}
const currency = (params.currency as Currency) ?? 'CHF';
// Keep the raw string on `address` (legacy fallback) — the QR-Bill
// builder's heuristic parser lifts structured fields out of it on
// render. A future iteration can ask the LLM for structured fields
// directly via more tool params, but today the one-shot address
// string is what planners tend to produce.
const snapshot: InvoiceClientSnapshot = {
name: clientName,
address: (params.clientAddress as string | undefined)?.trim() || undefined,

View file

@ -75,7 +75,18 @@ export interface InvoiceTotals {
*/
export interface InvoiceClientSnapshot {
name: string;
/**
* Legacy free-text multi-line address. Kept for backward compatibility
* with invoices created before the structured-address migration and for
* users who don't want to fill in five fields for a one-off client.
* When structured fields are set, they take precedence for the QR-Bill.
*/
address?: string;
/** Structured address — preferred source for QR-Bill rendering. */
street?: string;
zip?: string;
city?: string;
country?: string;
email?: string;
vatNumber?: string;
}
@ -136,9 +147,16 @@ export interface LocalInvoiceClient extends BaseRecord {
* Dexie hook stamps userId as usual.
*/
export interface LocalInvoiceSettings extends BaseRecord {
// Sender profile
// Sender profile — structured fields are preferred for QR-Bill;
// senderAddress (legacy free-text) remains a fallback so existing
// settings don't break until the user opens the form and fills in
// the split fields.
senderName: string;
senderAddress: string;
senderStreet?: string | null;
senderZip?: string | null;
senderCity?: string | null;
senderCountry?: string | null;
senderEmail: string;
senderVatNumber?: string | null;
senderIban: string;
@ -214,6 +232,10 @@ export interface InvoiceSettings {
id: string;
senderName: string;
senderAddress: string;
senderStreet: string | null;
senderZip: string | null;
senderCity: string | null;
senderCountry: string | null;
senderEmail: string;
senderVatNumber: string | null;
senderIban: string;

View file

@ -212,7 +212,16 @@
<h3>Empfänger</h3>
<div class="client">
<div class="client-name">{invoice.clientSnapshot.name}</div>
{#if invoice.clientSnapshot.address}
{#if invoice.clientSnapshot.street && invoice.clientSnapshot.city}
<div class="client-address">
{invoice.clientSnapshot.street}<br />
{invoice.clientSnapshot.zip ?? ''}
{invoice.clientSnapshot.city}
{#if invoice.clientSnapshot.country && invoice.clientSnapshot.country !== 'CH'}
<br />{invoice.clientSnapshot.country}
{/if}
</div>
{:else if invoice.clientSnapshot.address}
<pre class="client-address">{invoice.clientSnapshot.address}</pre>
{/if}
{#if invoice.clientSnapshot.email}

View file

@ -88,11 +88,12 @@ Alle 76 Module der Mana-App (`apps/mana/apps/web/src/lib/modules/`).
| `citycorners` | CityCorners | Stadtführer für Konstanz |
| `wetter` | Wetter | Open-Meteo Wetter, DWD-Warnungen, Regen-Nowcast, Multi-Model-Vergleich |
## Finanzen (2)
## Finanzen (3)
| Modul | Name | Beschreibung |
|---|---|---|
| `finance` | Finance | Einnahmen/Ausgaben-Tracking mit Kategorien und Budgets |
| `invoices` | Rechnungen | Rechnungen stellen (PDF + Schweizer QR-Bill), Mail-Versand, Zahlungs-Tracking. Bezahlte Rechnungen erscheinen automatisch als Einnahme im Finance-Modul. |
| `credits` | Credits & Abo | Credit-Verwaltung und Subscription-Management |
## Tagesübersicht (2)