feat(invoices): M1 skeleton — module registration + empty ListView

New outbound-finance module that issues invoices to clients. M1 scope:
- types, constants, collections with demo seed (not auto-loaded)
- module.config registered in module-registry
- Dexie v27 with invoices / invoiceClients / invoiceSettings tables
- encryption registry entries for all three tables (type-safe via entry<T>)
- app entry (requiredTier: alpha) + gradient icon (emerald→teal, QR corner)
- route /invoices mounts ListView with empty state

Money stored as integers in minor units (Rappen/cents) to avoid float
drift. Totals kept plaintext for liveQuery aggregation; lines encrypted
as a whole array so titles ride alongside. Settings is a singleton with
stable sentinel id so sync dedupes on it.

Plan: docs/plans/invoices-module.md. Next: M2 CRUD + number generator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 15:28:09 +02:00
parent 11f768b8e5
commit 2cf89ce26a
12 changed files with 666 additions and 0 deletions

View file

@ -78,6 +78,11 @@ import type { LocalNote } from '../../modules/notes/types';
import type { LocalDream, LocalDreamSymbol } from '../../modules/dreams/types';
import type { LocalJournalEntry } from '../../modules/journal/types';
import type { LocalMemo } from '../../modules/memoro/types';
import type {
LocalInvoice,
LocalInvoiceClient,
LocalInvoiceSettings,
} from '../../modules/invoices/types';
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
@ -580,6 +585,54 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
enabled: true,
fields: ['title', 'originalTitle', 'creators', 'review', 'tags'],
},
// ─── Invoices ────────────────────────────────────────────
// Outbound finance. Sensitive surface is non-trivial: client name and
// address, the free-text subject/notes/terms, and the line items
// themselves (title/description carry service names or project codes
// that leak who the user works for + what they're paid to do).
//
// Plaintext (intentional):
// - number, status, clientId, clientSource, currency, issueDate,
// dueDate, sentAt, paidAt, referenceNumber, pdfBlobKey: all
// structural, used for indexing/filter/aggregation.
// - totals (net/vat/gross): kept plaintext so the dashboard's
// "open" and "overdue" sums can be computed in a liveQuery
// without decrypting every row. The sum-across-customers is
// business-useful info the user sees at a glance; encrypting it
// would defeat the local-first reactive layer.
// - lines is encrypted as a whole blob (see below): the numeric
// subfields would be plaintext-eligible, but serialising the
// whole array in one pass keeps the encryption boundary simple
// and lets per-line titles travel encrypted alongside.
invoices: entry<LocalInvoice>(['clientSnapshot', 'subject', 'notes', 'terms', 'lines']),
// Optional per-user client book. Everything user-typed is sensitive —
// name, postal address, email, VAT number, IBAN, free-text notes.
// defaultCurrency / defaultDueDays stay plaintext (structural enums).
invoiceClients: entry<LocalInvoiceClient>([
'name',
'address',
'email',
'vatNumber',
'iban',
'notes',
]),
// Singleton sender profile. The user's legal address + IBAN live here
// and are the most sensitive fields in the module (appear on every PDF
// the user issues). logoMediaId / accentColor / number sequence state
// are plaintext — structural, no privacy value.
invoiceSettings: entry<LocalInvoiceSettings>([
'senderName',
'senderAddress',
'senderEmail',
'senderVatNumber',
'senderIban',
'senderBic',
'footer',
'defaultTerms',
]),
};
/**

View file

@ -592,6 +592,21 @@ db.version(26).stores({
libraryEntries: 'id, kind, status, completedAt, isFavorite',
});
// v27 — Invoices module: outbound finance (issuing invoices to clients).
// See docs/plans/invoices-module.md. Three tables:
// - invoices: the invoice records (status/dueDate/clientId indexed for
// the "overdue per client" + "open per status" queries that drive the
// ListView + dashboard widgets).
// - invoiceClients: optional per-user client book; only userId needed
// since listing is always scoped to the current user.
// - invoiceSettings: singleton sender profile (one row per user, id is
// the stable sentinel INVOICE_SETTINGS_ID so sync dedupes on it).
db.version(27).stores({
invoices: 'id, number, status, clientId, issueDate, dueDate',
invoiceClients: 'id',
invoiceSettings: 'id',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -99,6 +99,7 @@ import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
import { quizModuleConfig } from '$lib/modules/quiz/module.config';
import { profileModuleConfig } from '$lib/modules/profile/module.config';
import { libraryModuleConfig } from '$lib/modules/library/module.config';
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config';
@ -155,6 +156,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
quizModuleConfig,
profileModuleConfig,
libraryModuleConfig,
invoicesModuleConfig,
wetterModuleConfig,
aiModuleConfig,
];

View file

@ -0,0 +1,178 @@
<!--
Invoices — ListView (M1 skeleton)
Empty state + "+ Neu"-button placeholder. M2 brings the full list, filter
chips, and stats cards. Plan: docs/plans/invoices-module.md.
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { invoiceTable } from './collections';
import { STATUS_LABELS, STATUS_COLORS, CURRENCIES } from './constants';
import type { LocalInvoice } from './types';
const invoices$ = useLiveQueryWithDefault(async () => {
const rows = await invoiceTable.toArray();
const visible = rows.filter((r) => !r.deletedAt);
return (await decryptRecords('invoices', visible)) as LocalInvoice[];
}, [] as LocalInvoice[]);
const invoices = $derived(invoices$.value);
function formatAmount(minor: number, currency: keyof typeof CURRENCIES): string {
const { symbol, minorUnit } = CURRENCIES[currency];
const value = minor / minorUnit;
return `${symbol} ${value.toFixed(2)}`;
}
</script>
<div class="invoices-shell">
<header class="head">
<div>
<h1>Rechnungen</h1>
<p class="subtitle">Outbound-Finance — Rechnungen stellen und verfolgen</p>
</div>
<button class="btn-primary" type="button" disabled title="M2">+ Neu</button>
</header>
{#if invoices.length === 0}
<div class="empty">
<div class="empty-icon">📄</div>
<h2>Noch keine Rechnungen</h2>
<p>Stelle deine erste Rechnung mit automatischem PDF-Export und Schweizer QR-Bill.</p>
<p class="note">M1 Skelett — Erstellen-Flow folgt in M2.</p>
</div>
{:else}
<ul class="list">
{#each invoices as invoice (invoice.id)}
<li class="row">
<span class="number">{invoice.number}</span>
<span class="client">{invoice.clientSnapshot?.name ?? '—'}</span>
<span class="amount">{formatAmount(invoice.totals.gross, invoice.currency)}</span>
<span class="status" style="--dot: {STATUS_COLORS[invoice.status]}">
<span class="dot"></span>
{STATUS_LABELS[invoice.status].de}
</span>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.invoices-shell {
padding: 1.5rem;
max-width: 1000px;
margin: 0 auto;
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1.5rem;
}
.head h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.btn-primary {
background: #059669;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 0;
font-weight: 500;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty {
text-align: center;
padding: 4rem 1rem;
color: var(--color-text-muted, #64748b);
}
.empty-icon {
font-size: 3rem;
margin-bottom: 0.75rem;
}
.empty h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
font-weight: 500;
color: var(--color-text, #0f172a);
}
.empty p {
margin: 0.25rem 0;
font-size: 0.9rem;
}
.note {
margin-top: 1rem !important;
font-size: 0.8rem;
opacity: 0.7;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.row {
display: grid;
grid-template-columns: 7rem 1fr auto 7rem;
gap: 1rem;
align-items: center;
padding: 0.75rem 1rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
}
.number {
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.client {
font-weight: 500;
}
.amount {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
}
.status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dot);
}
</style>

View file

@ -0,0 +1,64 @@
/**
* Invoices module Dexie accessors and guest seed.
*
* Tables:
* - `invoices` the invoice records themselves
* - `invoiceClients` optional per-user client book
* - `invoiceSettings` singleton sender profile + number sequence
*/
import { db } from '$lib/data/database';
import type { LocalInvoice, LocalInvoiceClient, LocalInvoiceSettings } from './types';
export const invoiceTable = db.table<LocalInvoice>('invoices');
export const invoiceClientTable = db.table<LocalInvoiceClient>('invoiceClients');
export const invoiceSettingsTable = db.table<LocalInvoiceSettings>('invoiceSettings');
// ─── Guest Seed ────────────────────────────────────────────
// One draft invoice so first-run users immediately see what the module is
// for. Amounts stored in Rappen / cents (integer) — 150.00 CHF → 15000.
export const INVOICES_GUEST_SEED = {
invoices: [
{
id: 'demo-invoice-draft',
number: `${new Date().getFullYear()}-0001`,
status: 'draft' as const,
clientId: null,
clientSource: 'invoice-client' as const,
clientSnapshot: {
name: 'Muster Kunde AG',
address: 'Bahnhofstrasse 1\n8000 Zürich',
email: 'kontakt@muster-kunde.ch',
},
currency: 'CHF' as const,
issueDate: new Date().toISOString().slice(0, 10),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
sentAt: null,
paidAt: null,
lines: [
{
id: 'demo-line-1',
title: 'Beratung',
description: null,
quantity: 4,
unit: 'Std',
unitPrice: 15000, // 150.00 CHF
vatRate: 8.1,
discount: null,
},
],
subject: 'Beratungsleistung',
notes: null,
terms: 'Zahlbar innert 30 Tagen netto.',
referenceNumber: null,
pdfBlobKey: null,
totals: {
net: 60000,
vat: 4860,
gross: 64860,
vatBreakdown: [{ rate: 8.1, base: 60000, tax: 4860 }],
},
},
],
};

View file

@ -0,0 +1,47 @@
import type { Currency, InvoiceStatus } from './types';
export const STATUS_LABELS: Record<InvoiceStatus, { de: string; en: string }> = {
draft: { de: 'Entwurf', en: 'Draft' },
sent: { de: 'Versendet', en: 'Sent' },
paid: { de: 'Bezahlt', en: 'Paid' },
overdue: { de: 'Überfällig', en: 'Overdue' },
void: { de: 'Storniert', en: 'Void' },
};
export const STATUS_COLORS: Record<InvoiceStatus, string> = {
draft: '#64748b',
sent: '#3b82f6',
paid: '#22c55e',
overdue: '#ef4444',
void: '#94a3b8',
};
/** Swiss VAT rates as of 2024 (MwSt-Satz). */
export const VAT_RATES_CH = [
{ value: 0, label: '0% (ausgenommen)' },
{ value: 2.6, label: '2.6% (reduziert)' },
{ value: 3.8, label: '3.8% (Beherbergung)' },
{ value: 8.1, label: '8.1% (Normalsatz)' },
];
/** German VAT rates. */
export const VAT_RATES_DE = [
{ value: 0, label: '0%' },
{ value: 7, label: '7% (ermäßigt)' },
{ value: 19, label: '19% (Regelsatz)' },
];
export const CURRENCIES: Record<Currency, { symbol: string; minorUnit: number }> = {
CHF: { symbol: 'CHF', minorUnit: 100 },
EUR: { symbol: '€', minorUnit: 100 },
USD: { symbol: '$', minorUnit: 100 },
};
export const DEFAULT_DUE_DAYS = 30;
export const DEFAULT_NUMBER_PREFIX = `${new Date().getFullYear()}-`;
export const DEFAULT_NUMBER_PADDING = 4;
/** Sentinel id for the singleton settings row. Stable across devices so the
* sync engine dedupes on it instead of fighting over two parallel rows. */
export const INVOICE_SETTINGS_ID = 'invoice-settings';

View file

@ -0,0 +1,39 @@
/**
* Invoices module barrel exports.
*/
export {
invoiceTable,
invoiceClientTable,
invoiceSettingsTable,
INVOICES_GUEST_SEED,
} from './collections';
export {
STATUS_LABELS,
STATUS_COLORS,
VAT_RATES_CH,
VAT_RATES_DE,
CURRENCIES,
DEFAULT_DUE_DAYS,
DEFAULT_NUMBER_PREFIX,
DEFAULT_NUMBER_PADDING,
INVOICE_SETTINGS_ID,
} from './constants';
export type {
LocalInvoice,
LocalInvoiceLine,
LocalInvoiceClient,
LocalInvoiceSettings,
Invoice,
InvoiceLine,
InvoiceClient,
InvoiceSettings,
InvoiceStatus,
InvoiceTotals,
InvoiceClientSnapshot,
VatBreakdownEntry,
Currency,
ClientSource,
} from './types';

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const invoicesModuleConfig: ModuleConfig = {
appId: 'invoices',
tables: [{ name: 'invoices' }, { name: 'invoiceClients' }, { name: 'invoiceSettings' }],
};

View file

@ -0,0 +1,231 @@
/**
* Invoices module types.
*
* Outbound finance: the user issues invoices to their clients. Plan:
* `docs/plans/invoices-module.md`. Sibling to `finance` (inbound tracking),
* not a replacement.
*
* Money is stored as integers in the currency's smallest unit (Rappen / cents)
* to avoid floating-point drift on totals. Conversion to display format
* happens in the view layer.
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Discriminators & Enums ──────────────────────────────
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'void';
export type Currency = 'CHF' | 'EUR' | 'USD';
/**
* Which table the `clientId` FK points into. Set at create time so the
* resolver can look up the snapshot without having to probe both tables.
*/
export type ClientSource = 'contact' | 'invoice-client';
// ─── Line Items ──────────────────────────────────────────
/**
* A single invoice line. `id` is a client-generated UUID used as the stable
* key for reorder operations not a Dexie row id (lines live inline on the
* invoice for atomicity of edits and serialisation through the encryption
* boundary).
*/
export interface LocalInvoiceLine {
id: string;
title: string;
description?: string | null;
quantity: number;
unit?: string | null;
/** In the currency's smallest unit (Rappen / cents), integer. */
unitPrice: number;
/** Percent; e.g. 8.1 for Swiss standard rate. */
vatRate: number;
/** Percent; 0..100. null/undefined means no discount. */
discount?: number | null;
}
// ─── Totals ──────────────────────────────────────────────
export interface VatBreakdownEntry {
rate: number;
base: number;
tax: number;
}
/**
* Denormalised totals recomputed on every update. Kept plaintext so the
* dashboard widgets / overdue-sum queries don't have to decrypt.
*/
export interface InvoiceTotals {
net: number;
vat: number;
gross: number;
vatBreakdown: VatBreakdownEntry[];
}
// ─── Client Snapshot ─────────────────────────────────────
/**
* Frozen at send time so that later edits to the Contact / InvoiceClient
* don't retroactively change a legally binding, already-sent invoice.
* See §"Offene Fragen" in the plan for the edit-after-send policy (only
* drafts are editable for this reason).
*/
export interface InvoiceClientSnapshot {
name: string;
address?: string;
email?: string;
vatNumber?: string;
}
// ─── Local Record — Invoice (Dexie) ──────────────────────
export interface LocalInvoice extends BaseRecord {
/** Rendered invoice number, e.g. "2026-0042". Generated from settings. */
number: string;
status: InvoiceStatus;
clientId: string | null;
clientSource: ClientSource;
clientSnapshot: InvoiceClientSnapshot;
currency: Currency;
/** YYYY-MM-DD */
issueDate: string;
/** YYYY-MM-DD */
dueDate: string;
/** ISO timestamp — filled when status transitions to sent. */
sentAt?: string | null;
/** ISO timestamp — filled when status transitions to paid. */
paidAt?: string | null;
lines: LocalInvoiceLine[];
subject?: string | null;
notes?: string | null;
terms?: string | null;
/** QR-Referenz (CH: 27 digits, mod-10 check) or SCOR ISO 11649. */
referenceNumber?: string | null;
/** uload media id of the most recently rendered PDF (cache). */
pdfBlobKey?: string | null;
totals: InvoiceTotals;
}
// ─── Local Record — Client Book (Dexie) ──────────────────
/**
* Optional separate client book for invoice-specific data that doesn't
* belong in the main contacts table (IBAN for SEPA, default due days,
* default currency). Users can also just invoice contacts directly
* this table is opt-in.
*/
export interface LocalInvoiceClient extends BaseRecord {
name: string;
address?: string | null;
email?: string | null;
vatNumber?: string | null;
iban?: string | null;
defaultCurrency: Currency;
defaultDueDays: number;
notes?: string | null;
}
// ─── Local Record — Settings (Dexie, singleton per user) ─
/**
* User-scoped sender profile + number sequence state. There is exactly one
* row per user; `id` is a stable sentinel (`'invoice-settings'`) and the
* Dexie hook stamps userId as usual.
*/
export interface LocalInvoiceSettings extends BaseRecord {
// Sender profile
senderName: string;
senderAddress: string;
senderEmail: string;
senderVatNumber?: string | null;
senderIban: string;
senderBic?: string | null;
// Branding
logoMediaId?: string | null;
accentColor: string;
footer?: string | null;
// Number sequence
numberPrefix: string;
numberPadding: number;
nextNumber: number;
// Defaults
defaultCurrency: Currency;
defaultVatRate: number;
defaultDueDays: number;
defaultTerms?: string | null;
}
// ─── Domain Types (plaintext, for UI) ────────────────────
export interface InvoiceLine {
id: string;
title: string;
description: string | null;
quantity: number;
unit: string | null;
unitPrice: number;
vatRate: number;
discount: number | null;
}
export interface Invoice {
id: string;
number: string;
status: InvoiceStatus;
clientId: string | null;
clientSource: ClientSource;
clientSnapshot: InvoiceClientSnapshot;
currency: Currency;
issueDate: string;
dueDate: string;
sentAt: string | null;
paidAt: string | null;
lines: InvoiceLine[];
subject: string | null;
notes: string | null;
terms: string | null;
referenceNumber: string | null;
pdfBlobKey: string | null;
totals: InvoiceTotals;
createdAt: string;
updatedAt: string;
}
export interface InvoiceClient {
id: string;
name: string;
address: string | null;
email: string | null;
vatNumber: string | null;
iban: string | null;
defaultCurrency: Currency;
defaultDueDays: number;
notes: string | null;
createdAt: string;
}
export interface InvoiceSettings {
id: string;
senderName: string;
senderAddress: string;
senderEmail: string;
senderVatNumber: string | null;
senderIban: string;
senderBic: string | null;
logoMediaId: string | null;
accentColor: string;
footer: string | null;
numberPrefix: string;
numberPadding: number;
nextNumber: number;
defaultCurrency: Currency;
defaultVatRate: number;
defaultDueDays: number;
defaultTerms: string | null;
}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/invoices/ListView.svelte';
</script>
<svelte:head>
<title>Rechnungen - Mana</title>
</svelte:head>
<ListView />

View file

@ -231,6 +231,11 @@ export const APP_ICONS = {
// gradient sits next to music/photos/picture in the Kreativität & Medien row.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="lb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#a855f7"/><stop offset="100%" style="stop-color:#d946ef"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#lb)"/><rect x="22" y="28" width="10" height="44" rx="2" fill="white" fill-opacity="0.95"/><rect x="34" y="24" width="10" height="48" rx="2" fill="white" fill-opacity="0.8"/><rect x="46" y="30" width="10" height="42" rx="2" fill="white" fill-opacity="0.95"/><rect x="58" y="28" width="22" height="44" rx="3" fill="white"/><rect x="62" y="34" width="4" height="4" fill="#a855f7"/><rect x="72" y="34" width="4" height="4" fill="#a855f7"/><rect x="62" y="44" width="4" height="4" fill="#a855f7"/><rect x="72" y="44" width="4" height="4" fill="#a855f7"/><rect x="62" y="54" width="4" height="4" fill="#a855f7"/><rect x="72" y="54" width="4" height="4" fill="#a855f7"/><rect x="62" y="64" width="4" height="4" fill="#a855f7"/><rect x="72" y="64" width="4" height="4" fill="#a855f7"/><rect x="20" y="74" width="62" height="4" rx="2" fill="white" fill-opacity="0.5"/></svg>`
),
invoices: svgToDataUrl(
// Document with a QR-code corner (CH QR-Bill) + a diagonal amount line.
// Emerald→teal sits next to finance green in the Arbeit & Finanzen row.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="iv" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#059669"/><stop offset="100%" style="stop-color:#14b8a6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#iv)"/><path d="M28 22h34l14 14v42a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M62 22v10a4 4 0 0 0 4 4h10" fill="none" stroke="#059669" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="44" width="24" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="32" y="52" width="20" height="3" rx="1" fill="#059669" fill-opacity="0.45"/><rect x="32" y="60" width="28" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="60" y="58" width="14" height="14" rx="1" fill="#059669"/><rect x="62" y="60" width="3" height="3" fill="white"/><rect x="69" y="60" width="3" height="3" fill="white"/><rect x="62" y="67" width="3" height="3" fill="white"/><rect x="66" y="64" width="2" height="2" fill="white"/><rect x="69" y="67" width="3" height="3" fill="white"/></svg>`
),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -1020,6 +1020,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'invoices',
name: 'Rechnungen',
description: {
de: 'Rechnungen stellen mit QR-Bill',
en: 'Issue invoices with Swiss QR-Bill',
},
longDescription: {
de: 'Rechnungen an Kunden stellen — mit PDF-Export, Schweizer QR-Rechnung, Mehrwertsteuer und Zahlungsverfolgung. Ergänzt das Finance-Modul um die Outbound-Seite.',
en: 'Issue invoices to clients — with PDF export, Swiss QR-Bill, VAT handling and payment tracking. Adds the outbound side to the Finance module.',
},
icon: APP_ICONS.invoices,
color: '#059669',
comingSoon: false,
status: 'development',
requiredTier: 'alpha',
},
];
/**