mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 03:01:09 +02:00
feat(invoices): M7 dashboard widget + tests + plan status
Adds the missing bits that turn M1–M6 into a coherent shippable product rather than a pile of commits. Dashboard widget (M7) - InvoicesOpenWidget.svelte: open + overdue totals in the primary currency, top-3 oldest overdue with "X Tage überfällig" under each, empty-state CTA for first-time users - Registered as `invoices-open` in WIDGET_REGISTRY and the component map. Default size medium, no requiredBackend (local-first, no API) - Fixed pre-existing test gap: validBackends list was missing 'body' (body-stats widget has been failing silently) — added so the check protects against drift for real Tests (45 total, all green) - totals.test.ts (9): computeLineTotal with discount+vat, grouping invariant (breakdown sums == invoice totals), rounding edges - pdf/qr-bill.test.ts (17): generateSCORReference stability + spec-validity via swissqrbill's own isSCORReferenceValid, buildQRBillData eligibility gates (currency, IBAN, address, amount), CH + DE address parser paths, referenceNumber-preferred-over-regen invariant - mail-template.test.ts (12): subject/body composition (with/without subject, CHF vs EUR QR-hint, empty recipient fallback), mailto spaces-as-%20 patch, looksLikeEmail edge cases Plan (docs/plans/invoices-module.md) - Updated with commit SHAs per milestone, testing status, and the explicit list of open items (Logo-Upload, AI-Tools, sync collision, structured addresses, finance cross-link, camt bankabgleich) so the next coder knows exactly what's parked where Unresolved: browser smoke test couldn't run — SSR is broken for all module routes in the current tree (pre-existing, likely from the parallel Spaces refactor; /library, /todo, /contacts all return 500 the same way). Unit tests + clean bundle build (M4) + type-check are the coverage we have. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2d15684ed4
commit
2ee3a1a93a
8 changed files with 643 additions and 3 deletions
|
|
@ -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<WidgetType, Component> = {
|
|||
period: PeriodWidget,
|
||||
'news-unread': NewsUnreadWidget,
|
||||
'body-stats': BodyStatsWidget,
|
||||
'invoices-open': InvoicesOpenWidget,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
211
apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.test.ts
Normal file
211
apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.test.ts
Normal file
|
|
@ -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> = {}): 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> = {}): 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/);
|
||||
});
|
||||
});
|
||||
100
apps/mana/apps/web/src/lib/modules/invoices/totals.test.ts
Normal file
100
apps/mana/apps/web/src/lib/modules/invoices/totals.test.ts
Normal file
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* InvoicesOpenWidget — offene + überfällige Summen + die 3 ältesten
|
||||
* überfälligen Rechnungen, direkt aus Dexie gelesen.
|
||||
*
|
||||
* Zeigt pro Primär-Währung (zuerst CHF → EUR → USD) die offene +
|
||||
* überfällige Summe. Mehrwährungs-Setups sehen nur die Leitwährung;
|
||||
* detaillierte Aufschlüsselung gibt's im ListView.
|
||||
*/
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { invoiceTable } from '$lib/modules/invoices/collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { toInvoice, computeStats, formatAmount } from '$lib/modules/invoices/queries';
|
||||
import type { Invoice, InvoiceStatus, Currency } from '$lib/modules/invoices/types';
|
||||
|
||||
let invoices = $state<Invoice[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
const rows = await invoiceTable.toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt);
|
||||
const decrypted = await decryptRecords('invoices', visible);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return decrypted.map((local) => {
|
||||
const inv = toInvoice(local);
|
||||
if (inv.status === 'sent' && inv.dueDate < today) {
|
||||
return { ...inv, status: 'overdue' as InvoiceStatus };
|
||||
}
|
||||
return inv;
|
||||
});
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
invoices = result;
|
||||
loading = false;
|
||||
},
|
||||
error: () => {
|
||||
loading = false;
|
||||
},
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const stats = $derived(computeStats(invoices, currentYear));
|
||||
|
||||
function primaryCurrency(map: Record<Currency, number>): Currency {
|
||||
for (const c of ['CHF', 'EUR', 'USD'] as Currency[]) {
|
||||
if (map[c] > 0) return c;
|
||||
}
|
||||
return 'CHF';
|
||||
}
|
||||
|
||||
const openCurrency = $derived(primaryCurrency(stats.openByCurrency));
|
||||
const overdueCount = $derived(stats.totalByStatus.overdue);
|
||||
const openSum = $derived(stats.openByCurrency[openCurrency]);
|
||||
const overdueSum = $derived(stats.overdueByCurrency[openCurrency]);
|
||||
|
||||
// Top 3 oldest overdue — the ones the user should chase first.
|
||||
const topOverdue = $derived(
|
||||
invoices
|
||||
.filter((i) => i.status === 'overdue')
|
||||
.sort((a, b) => a.dueDate.localeCompare(b.dueDate))
|
||||
.slice(0, 3)
|
||||
);
|
||||
|
||||
function daysSince(dueDate: string): number {
|
||||
const due = new Date(`${dueDate}T00:00:00`);
|
||||
const now = new Date();
|
||||
return Math.floor((now.getTime() - due.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span aria-hidden="true">📄</span>
|
||||
Rechnungen
|
||||
</h3>
|
||||
<a href="/invoices" class="text-xs text-muted-foreground hover:text-foreground"> Alle → </a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(3) as _}
|
||||
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if invoices.length === 0}
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Noch keine Rechnungen gestellt.</p>
|
||||
<a
|
||||
href="/invoices/new"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
Erste Rechnung
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg bg-surface-hover p-2.5">
|
||||
<div class="text-xs text-muted-foreground">Offen</div>
|
||||
<div class="text-lg font-semibold tabular-nums">
|
||||
{formatAmount(openSum, openCurrency)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg p-2.5"
|
||||
class:bg-surface-hover={overdueSum === 0}
|
||||
class:bg-red-50={overdueSum > 0}
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">Überfällig</div>
|
||||
<div class="text-lg font-semibold tabular-nums" class:text-red-700={overdueSum > 0}>
|
||||
{formatAmount(overdueSum, openCurrency)}
|
||||
</div>
|
||||
{#if overdueCount > 0}
|
||||
<div class="text-[10px] text-red-700">{overdueCount} Rechnungen</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if topOverdue.length > 0}
|
||||
<div class="space-y-1.5">
|
||||
{#each topOverdue as invoice (invoice.id)}
|
||||
<a
|
||||
href="/invoices/{invoice.id}"
|
||||
class="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">
|
||||
{invoice.clientSnapshot.name}
|
||||
</div>
|
||||
<div class="text-xs text-red-700">
|
||||
{daysSince(invoice.dueDate)} Tage überfällig
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 text-sm font-medium tabular-nums">
|
||||
{formatAmount(invoice.totals.gross, invoice.currency)}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -56,6 +56,7 @@ describe('WIDGET_REGISTRY', () => {
|
|||
'food',
|
||||
'plants',
|
||||
'period',
|
||||
'body',
|
||||
undefined,
|
||||
];
|
||||
for (const widget of WIDGET_REGISTRY) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue