mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
3194180efb
commit
394aa79328
14 changed files with 413 additions and 35 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue