Initial commit

This commit is contained in:
fds2610 2026-03-25 00:19:33 +01:00
commit 87415548e8
61 changed files with 7103 additions and 0 deletions

View file

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(docker compose:*)",
"Bash(docker-compose --version)"
]
}
}

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
# Datenbank
DB_HOST=db
DB_NAME=steuerkram
DB_USER=steuerkram
DB_PASS=steuerkram
# HTTP Basic Auth
AUTH_USER=admin
AUTH_PASS=changeme
# App
APP_SECRET=change-this-to-a-random-string

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
vendor/
data/mariadb/
uploads/
backups/
.env
*.log

37
Dockerfile Normal file
View file

@ -0,0 +1,37 @@
FROM php:8.3-apache
# System dependencies for mPDF
RUN apt-get update && apt-get install -y \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
zip \
unzip \
curl \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo pdo_mysql zip gd \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Copy composer files first for layer caching
COPY composer.json composer.lock* ./
RUN composer install --no-dev --no-interaction --optimize-autoloader 2>/dev/null || true
# Copy application
COPY . .
# Apache configuration
COPY apache/steuerkram.conf /etc/apache2/sites-available/000-default.conf
RUN a2enmod rewrite headers
# Permissions
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/public/static/uploads \
&& chmod -R 755 /var/www/html/data
EXPOSE 80

24
apache/steuerkram.conf Normal file
View file

@ -0,0 +1,24 @@
<VirtualHost *:80>
DocumentRoot /var/www/html/public
ServerName localhost
<Directory /var/www/html/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# Deny access to src/ and migrations/
<Directory /var/www/html/src>
Require all denied
</Directory>
<Directory /var/www/html/migrations>
Require all denied
</Directory>
<Directory /var/www/html/data>
Require all denied
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

17
composer.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "steuerkram/steuerkram",
"description": "Self-hosted German accounting and tax filing application",
"type": "project",
"require": {
"php": ">=8.3",
"mpdf/mpdf": "^8.2"
},
"autoload": {
"psr-4": {
"Steuerkram\\": "src/"
}
},
"config": {
"optimize-autoloader": true
}
}

43
docker-compose.yml Normal file
View file

@ -0,0 +1,43 @@
services:
web:
build: .
ports:
- "8080:80"
volumes:
- ./public:/var/www/html/public
- ./src:/var/www/html/src
- ./migrations:/var/www/html/migrations
- uploads:/var/www/html/public/static/uploads
- ./data:/var/www/html/data
environment:
DB_HOST: db
DB_NAME: steuerkram
DB_USER: steuerkram
DB_PASS: steuerkram
AUTH_USER: admin
AUTH_PASS: changeme
APP_SECRET: change-this-in-production
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: rootpass
MARIADB_DATABASE: steuerkram
MARIADB_USER: steuerkram
MARIADB_PASSWORD: steuerkram
volumes:
- ./data/mariadb:/var/lib/mysql
- ./migrations:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
volumes:
uploads:

View file

@ -0,0 +1,251 @@
-- Steuerkram - Initial Schema
-- GoBD-konform: Buchungen sind unveränderlich, vollständiges Audit-Log
SET NAMES utf8mb4;
SET time_zone = '+00:00';
-- ============================================================
-- Geschäftsjahre
-- ============================================================
CREATE TABLE IF NOT EXISTS fiscal_years (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
year SMALLINT UNSIGNED NOT NULL UNIQUE,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'GoBD: Jahr gesperrt nach Abschluss',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- Kontenrahmen (SKR03 und anpassbar)
-- ============================================================
CREATE TABLE IF NOT EXISTS accounts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
kontonummer VARCHAR(10) NOT NULL UNIQUE,
bezeichnung VARCHAR(255) NOT NULL,
kontenart ENUM('Aktiv','Passiv','Aufwand','Ertrag','Neutral') NOT NULL,
beschreibung TEXT,
steuercode VARCHAR(20) NULL COMMENT 'z.B. USt19, VSt19, USt7, VSt7',
ustva_kz VARCHAR(10) NULL COMMENT 'ELSTER Kennzahl z.B. 81, 35',
is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = SKR03-Standard',
aktiv TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_kontonummer (kontonummer),
INDEX idx_kontenart (kontenart)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- Firmendaten / Einstellungen (Singleton, id=1)
-- ============================================================
CREATE TABLE IF NOT EXISTS company_settings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
firma VARCHAR(255) NOT NULL DEFAULT '',
inhaber VARCHAR(255) NOT NULL DEFAULT '',
strasse VARCHAR(255) NOT NULL DEFAULT '',
plz VARCHAR(10) NOT NULL DEFAULT '',
ort VARCHAR(255) NOT NULL DEFAULT '',
telefon VARCHAR(50),
email VARCHAR(255),
website VARCHAR(255),
ust_id VARCHAR(20) COMMENT 'Umsatzsteuer-ID DE...',
steuernummer VARCHAR(30) COMMENT 'Steuernummer beim Finanzamt',
finanzamt VARCHAR(255),
iban VARCHAR(34),
bic VARCHAR(11),
bank_name VARCHAR(255),
logo_path VARCHAR(500) COMMENT 'Pfad zum Logo-PNG',
ustva_steuernummer VARCHAR(20) COMMENT 'ELSTER-Format: FFFBBBBBBUUUUP',
ustva_bundesland VARCHAR(2) COMMENT 'z.B. BY, NW, BE',
invoice_nr_prefix VARCHAR(10) NOT NULL DEFAULT 'AR',
invoice_nr_next INT UNSIGNED NOT NULL DEFAULT 1,
invoice_nr_year SMALLINT UNSIGNED NOT NULL DEFAULT 0,
beleg_nr_prefix VARCHAR(10) NOT NULL DEFAULT 'BLG',
beleg_nr_next INT UNSIGNED NOT NULL DEFAULT 1,
payment_days_default INT UNSIGNED NOT NULL DEFAULT 14,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO company_settings (id, firma, inhaber, strasse, plz, ort) VALUES (1, '', '', '', '', '');
-- ============================================================
-- Kunden
-- ============================================================
CREATE TABLE IF NOT EXISTS customers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
kundennummer VARCHAR(20) NOT NULL UNIQUE,
firma VARCHAR(255),
anrede VARCHAR(20),
vorname VARCHAR(100),
nachname VARCHAR(100) NOT NULL,
strasse VARCHAR(255) NOT NULL,
plz VARCHAR(10) NOT NULL,
ort VARCHAR(255) NOT NULL,
land VARCHAR(2) NOT NULL DEFAULT 'DE',
email VARCHAR(255),
telefon VARCHAR(50),
ust_id VARCHAR(20) COMMENT 'Umsatzsteuer-ID des Kunden (B2B)',
steuernummer VARCHAR(30),
payment_days INT UNSIGNED NOT NULL DEFAULT 14,
notes TEXT,
archived TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_kundennummer (kundennummer),
INDEX idx_archived (archived)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- Belege (Dokumente / Quittungen)
-- ============================================================
CREATE TABLE IF NOT EXISTS belege (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
beleg_nr VARCHAR(30) NOT NULL UNIQUE,
filename VARCHAR(500) NOT NULL COMMENT 'Originaldateiname',
storage_path VARCHAR(500) NOT NULL COMMENT 'Pfad unter public/static/uploads/',
mime_type VARCHAR(100) NOT NULL,
file_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256 für GoBD-Integrität',
beleg_datum DATE NULL COMMENT 'Datum auf dem Dokument',
eingangs_datum DATE NOT NULL COMMENT 'Eingangsdatum',
rechnungs_nr VARCHAR(100) COMMENT 'Rechnungsnummer des Lieferanten',
betrag_brutto DECIMAL(12,2) COMMENT 'Bruttobetrag lt. Dokument',
belegtyp ENUM('Eingangsrechnung','Ausgangsrechnung','Quittung','Kontoauszug','Sonstiges') NOT NULL DEFAULT 'Eingangsrechnung',
notes TEXT,
booked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = alle Buchungszeilen erfasst',
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'GoBD: gesperrt mit Geschäftsjahr',
fiscal_year_id INT UNSIGNED NULL REFERENCES fiscal_years(id),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_beleg_datum (beleg_datum),
INDEX idx_fiscal_year (fiscal_year_id),
INDEX idx_booked (booked)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- Buchungen (Journaleinträge, doppelte Buchführung)
-- GoBD: KEINE UPDATE/DELETE auf gesperrte Buchungen
-- ============================================================
CREATE TABLE IF NOT EXISTS bookings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
buchungs_nr VARCHAR(30) NOT NULL UNIQUE,
beleg_id INT UNSIGNED NOT NULL,
buchungs_datum DATE NOT NULL,
soll_konto_id INT UNSIGNED NOT NULL,
haben_konto_id INT UNSIGNED NOT NULL,
betrag_netto DECIMAL(12,2) NOT NULL,
ust_satz DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT 'Steuersatz in % (0, 7, 19)',
betrag_ust DECIMAL(12,2) NOT NULL DEFAULT 0.00,
betrag_brutto DECIMAL(12,2) NOT NULL,
buchungstext VARCHAR(500) NOT NULL,
is_reversal TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = Stornobuchung',
reversed_by INT UNSIGNED NULL COMMENT 'ID der Stornobuchung',
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'GoBD: unveränderlich',
fiscal_year_id INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_booking_beleg FOREIGN KEY (beleg_id) REFERENCES belege(id),
CONSTRAINT fk_booking_soll FOREIGN KEY (soll_konto_id) REFERENCES accounts(id),
CONSTRAINT fk_booking_haben FOREIGN KEY (haben_konto_id) REFERENCES accounts(id),
CONSTRAINT fk_booking_year FOREIGN KEY (fiscal_year_id) REFERENCES fiscal_years(id),
INDEX idx_buchungs_datum (buchungs_datum),
INDEX idx_fiscal_year (fiscal_year_id),
INDEX idx_soll_konto (soll_konto_id),
INDEX idx_haben_konto (haben_konto_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- GoBD-Trigger: Gesperrte Buchungen dürfen nicht geändert werden
DELIMITER $$
CREATE TRIGGER bookings_before_update
BEFORE UPDATE ON bookings
FOR EACH ROW
BEGIN
IF OLD.locked = 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'GoBD-Verletzung: Gesperrte Buchung kann nicht geändert werden.';
END IF;
END$$
CREATE TRIGGER bookings_before_delete
BEFORE DELETE ON bookings
FOR EACH ROW
BEGIN
IF OLD.locked = 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'GoBD-Verletzung: Gesperrte Buchung kann nicht gelöscht werden.';
END IF;
END$$
DELIMITER ;
-- ============================================================
-- Ausgangsrechnungen
-- ============================================================
CREATE TABLE IF NOT EXISTS invoices (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
rechnungs_nr VARCHAR(30) NULL UNIQUE COMMENT 'NULL solange Entwurf',
customer_id INT UNSIGNED NOT NULL,
betreff VARCHAR(500) NOT NULL,
leistungszeitraum_von DATE NULL,
leistungszeitraum_bis DATE NULL,
rechnungs_datum DATE NOT NULL,
faellig_datum DATE NOT NULL,
status ENUM('Entwurf','Gesendet','Bezahlt','Storniert') NOT NULL DEFAULT 'Entwurf',
betrag_netto DECIMAL(12,2) NOT NULL DEFAULT 0.00,
betrag_ust DECIMAL(12,2) NOT NULL DEFAULT 0.00,
betrag_brutto DECIMAL(12,2) NOT NULL DEFAULT 0.00,
beleg_id INT UNSIGNED NULL REFERENCES belege(id),
notes TEXT,
fiscal_year_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_invoice_customer FOREIGN KEY (customer_id) REFERENCES customers(id),
INDEX idx_status (status),
INDEX idx_rechnungs_datum (rechnungs_datum)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- Rechnungspositionen
-- ============================================================
CREATE TABLE IF NOT EXISTS invoice_lines (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
invoice_id INT UNSIGNED NOT NULL,
position SMALLINT UNSIGNED NOT NULL DEFAULT 1,
bezeichnung VARCHAR(500) NOT NULL,
menge DECIMAL(10,3) NOT NULL DEFAULT 1.000,
einheit VARCHAR(20) NOT NULL DEFAULT 'Stk.',
einzelpreis DECIMAL(12,2) NOT NULL,
ust_satz DECIMAL(5,2) NOT NULL DEFAULT 19.00,
betrag_netto DECIMAL(12,2) NOT NULL,
betrag_ust DECIMAL(12,2) NOT NULL,
betrag_brutto DECIMAL(12,2) NOT NULL,
CONSTRAINT fk_line_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
INDEX idx_invoice_id (invoice_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- UStVA Einreichungen
-- ============================================================
CREATE TABLE IF NOT EXISTS ustva_submissions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
year SMALLINT UNSIGNED NOT NULL,
month TINYINT UNSIGNED NOT NULL COMMENT '1-12',
xml_content MEDIUMTEXT NOT NULL COMMENT 'Generiertes ELSTER-XML',
field_values JSON COMMENT 'Kennzahlen als JSON',
submitted_at DATETIME NULL COMMENT 'NULL = noch nicht eingereicht',
status ENUM('Erstellt','Eingereicht') NOT NULL DEFAULT 'Erstellt',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_year_month (year, month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- Audit-Log (GoBD-Pflicht)
-- ============================================================
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
table_name VARCHAR(50) NOT NULL,
record_id INT UNSIGNED NOT NULL,
action ENUM('INSERT','UPDATE','DELETE','LOCK') NOT NULL,
old_values JSON,
new_values JSON,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
INDEX idx_table_record (table_name, record_id),
INDEX idx_timestamp (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View file

@ -0,0 +1,123 @@
-- SKR03 Kontenrahmen - Wichtigste Konten für EÜR / Freiberufler
-- Kontenarten: Aktiv, Passiv, Aufwand, Ertrag, Neutral
-- steuercode: USt19, USt7, VSt19, VSt7, NULL (kein Steuercode)
-- ustva_kz: ELSTER-Kennzahl für UStVA
INSERT INTO accounts (kontonummer, bezeichnung, kontenart, steuercode, ustva_kz, is_system) VALUES
-- ============================================================
-- KLASSE 0: Anlagevermögen
-- ============================================================
('0410', 'EDV-Software', 'Aktiv', NULL, NULL, 1),
('0420', 'Büroeinrichtung', 'Aktiv', NULL, NULL, 1),
('0430', 'Technische Anlagen und Maschinen', 'Aktiv', NULL, NULL, 1),
('0480', 'Betriebs- und Geschäftsausstattung', 'Aktiv', NULL, NULL, 1),
('0490', 'Geringwertige Wirtschaftsgüter', 'Aktiv', NULL, NULL, 1),
-- ============================================================
-- KLASSE 1: Umlaufvermögen / Geldkonten
-- ============================================================
('1000', 'Kasse', 'Aktiv', NULL, NULL, 1),
('1200', 'Bank', 'Aktiv', NULL, NULL, 1),
('1361', 'Abziehbare Vorsteuer 19 %', 'Aktiv', 'VSt19', '66', 1),
('1371', 'Abziehbare Vorsteuer 7 %', 'Aktiv', 'VSt7', '66', 1),
('1400', 'Forderungen aus Lieferungen und Leistungen', 'Aktiv', NULL, NULL, 1),
('1460', 'Forderungen aus Lieferungen und Leistungen (steuerfrei)', 'Aktiv', NULL, NULL, 1),
('1576', 'Abziehbare Vorsteuer 19 % (Eingangsrechnungen)', 'Aktiv', 'VSt19', '66', 1),
('1571', 'Abziehbare Vorsteuer 7 % (Eingangsrechnungen)', 'Aktiv', 'VSt7', '66', 1),
-- ============================================================
-- KLASSE 1: Verbindlichkeiten (Passiv)
-- ============================================================
('1600', 'Verbindlichkeiten aus Lieferungen und Leistungen', 'Passiv', NULL, NULL, 1),
('1700', 'Sonstige Verbindlichkeiten', 'Passiv', NULL, NULL, 1),
('1741', 'Umsatzsteuer 19 %', 'Passiv', 'USt19', '83', 1),
('1742', 'Umsatzsteuer 7 %', 'Passiv', 'USt7', '84', 1),
('1771', 'Umsatzsteuer-Vorauszahlungen', 'Passiv', NULL, NULL, 1),
('1780', 'Umsatzsteuer-Vorauszahlungen lfd. Jahr', 'Passiv', NULL, NULL, 1),
-- ============================================================
-- KLASSE 3: Wareneinkauf (Aufwand)
-- ============================================================
('3100', 'Wareneinkauf 19 % Vorsteuer', 'Aufwand', 'VSt19', '66', 1),
('3110', 'Wareneinkauf 7 % Vorsteuer', 'Aufwand', 'VSt7', '66', 1),
('3120', 'Wareneinkauf ohne Vorsteuer', 'Aufwand', NULL, NULL, 1),
-- ============================================================
-- KLASSE 4: Betriebliche Aufwendungen
-- ============================================================
('4100', 'Gehälter', 'Aufwand', NULL, NULL, 1),
('4110', 'Löhne', 'Aufwand', NULL, NULL, 1),
('4120', 'Gesetzliche soziale Aufwendungen', 'Aufwand', NULL, NULL, 1),
('4130', 'Freiwillige soziale Aufwendungen', 'Aufwand', NULL, NULL, 1),
('4140', 'Aufwendungen für Altersversorgung', 'Aufwand', NULL, NULL, 1),
('4200', 'Raumkosten (Miete)', 'Aufwand', 'VSt19', '66', 1),
('4210', 'Heizung', 'Aufwand', 'VSt19', '66', 1),
('4240', 'Gas, Strom, Wasser', 'Aufwand', 'VSt19', '66', 1),
('4250', 'Reinigung', 'Aufwand', 'VSt19', '66', 1),
('4260', 'Gebäudeversicherungen', 'Aufwand', NULL, NULL, 1),
('4300', 'Betriebliche Versicherungen', 'Aufwand', NULL, NULL, 1),
('4320', 'Beiträge', 'Aufwand', NULL, NULL, 1),
('4330', 'Berufsgenossenschaft', 'Aufwand', NULL, NULL, 1),
('4360', 'Beiträge zu Berufsverbänden', 'Aufwand', NULL, NULL, 1),
('4380', 'Verwaltungs-Gemeinkosten', 'Aufwand', 'VSt19', '66', 1),
('4390', 'Aufwand für Arbeitnehmer-Überlassung', 'Aufwand', 'VSt19', '66', 1),
('4400', 'PKW-Kosten', 'Aufwand', 'VSt19', '66', 1),
('4410', 'LKW-Kosten', 'Aufwand', 'VSt19', '66', 1),
('4430', 'Kraftfahrzeugsteuer', 'Aufwand', NULL, NULL, 1),
('4460', 'Reisekosten Arbeitnehmer', 'Aufwand', 'VSt19', '66', 1),
('4500', 'Werbekosten', 'Aufwand', 'VSt19', '66', 1),
('4510', 'Kosten der Warenabgabe (Muster, Proben)', 'Aufwand', 'VSt19', '66', 1),
('4520', 'Repräsentationskosten', 'Aufwand', 'VSt19', '66', 1),
('4530', 'Messekosten', 'Aufwand', 'VSt19', '66', 1),
('4540', 'Bewirtungskosten', 'Aufwand', 'VSt19', '66', 1),
('4560', 'Buchführungskosten', 'Aufwand', 'VSt19', '66', 1),
('4570', 'Jahresabschluss- und Prüfungskosten', 'Aufwand', 'VSt19', '66', 1),
('4580', 'Beratungskosten', 'Aufwand', 'VSt19', '66', 1),
('4600', 'Bürobedarf', 'Aufwand', 'VSt19', '66', 1),
('4610', 'Zeitschriften, Bücher', 'Aufwand', 'VSt7', '66', 1),
('4620', 'Portokosten', 'Aufwand', NULL, NULL, 1),
('4630', 'Telefon', 'Aufwand', 'VSt19', '66', 1),
('4640', 'Internetkosten', 'Aufwand', 'VSt19', '66', 1),
('4650', 'Büromaschinen (Wartung/Reparatur)', 'Aufwand', 'VSt19', '66', 1),
('4660', 'EDV-Kosten (Software, Lizenzen)', 'Aufwand', 'VSt19', '66', 1),
('4670', 'Betriebliche Fortbildungskosten', 'Aufwand', 'VSt19', '66', 1),
('4680', 'Fachliteratur', 'Aufwand', 'VSt7', '66', 1),
('4700', 'Abschreibungen auf Anlagevermögen', 'Aufwand', NULL, NULL, 1),
('4710', 'Abschreibungen auf GWG', 'Aufwand', NULL, NULL, 1),
('4800', 'Bankgebühren', 'Aufwand', NULL, NULL, 1),
('4820', 'Zinsen und ähnliche Aufwendungen', 'Aufwand', NULL, NULL, 1),
('4830', 'Mahngebühren, Inkassokosten', 'Aufwand', NULL, NULL, 1),
('4900', 'Sonstige betriebliche Aufwendungen', 'Aufwand', 'VSt19', '66', 1),
('4920', 'Gewerbesteuer', 'Aufwand', NULL, NULL, 1),
('4930', 'Kraftfahrzeugsteuer (betrieblich)', 'Aufwand', NULL, NULL, 1),
('4950', 'Sonstige Steuern', 'Aufwand', NULL, NULL, 1),
('4960', 'Sonstige betriebliche Aufwendungen (ohne VSt)', 'Aufwand', NULL, NULL, 1),
('4970', 'Aufwendungen aus Verlust Forderungen', 'Aufwand', NULL, NULL, 1),
-- ============================================================
-- KLASSE 8: Erträge
-- ============================================================
('8000', 'Erlöse aus Lieferungen und Leistungen 19 % USt', 'Ertrag', 'USt19', '81', 1),
('8100', 'Erlöse aus Lieferungen und Leistungen 7 % USt', 'Ertrag', 'USt7', '86', 1),
('8120', 'Erlöse aus Lieferungen und Leistungen (steuerfrei)', 'Ertrag', NULL, '35', 1),
('8125', 'Steuerfreie EU-Lieferungen', 'Ertrag', NULL, '41', 1),
('8130', 'Sonstige steuerfreie Umsätze', 'Ertrag', NULL, NULL, 1),
('8200', 'Erlöse aus sonstigen Leistungen 19 % USt', 'Ertrag', 'USt19', '81', 1),
('8300', 'Erlöse aus sonstigen Leistungen 7 % USt', 'Ertrag', 'USt7', '86', 1),
('8400', 'Erlöse aus Anlagenabgängen (steuerpflichtig)', 'Ertrag', 'USt19', '81', 1),
('8500', 'Zinsen und ähnliche Erträge', 'Ertrag', NULL, NULL, 1),
('8600', 'Erträge aus Beteiligungen', 'Ertrag', NULL, NULL, 1),
('8900', 'Sonstige betriebliche Erträge', 'Ertrag', NULL, NULL, 1),
('8910', 'Sonstige betriebliche Erträge (steuerfrei)', 'Ertrag', NULL, NULL, 1),
('8920', 'Erträge aus Auflösung Rückstellungen', 'Ertrag', NULL, NULL, 1),
('8940', 'Erträge aus Kursgewinnen', 'Ertrag', NULL, NULL, 1),
-- ============================================================
-- KLASSE 9: Eigenkapital / Privat (Neutral)
-- ============================================================
('9000', 'Eigenkapital', 'Neutral', NULL, NULL, 1),
('9100', 'Privatentnahmen', 'Neutral', NULL, NULL, 1),
('9200', 'Privateinlagen', 'Neutral', NULL, NULL, 1),
('9300', 'Gewinnvortrag', 'Neutral', NULL, NULL, 1),
('9400', 'Verlustvortrag', 'Neutral', NULL, NULL, 1);

View file

@ -0,0 +1,6 @@
-- Migration 003: Test-Modus Einstellung
ALTER TABLE company_settings
ADD COLUMN IF NOT EXISTS test_mode TINYINT(1) NOT NULL DEFAULT 1
COMMENT '1 = Testinstallation (Löschen erlaubt), 0 = Produktiv (GoBD-strikt)';
UPDATE company_settings SET test_mode = 1 WHERE id = 1;

10
public/.htaccess Normal file
View file

@ -0,0 +1,10 @@
Options -Indexes
RewriteEngine On
# Serve existing static files directly
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Route everything else through index.php
RewriteRule ^ index.php [L]

96
public/index.php Normal file
View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
define('BASE_DIR', dirname(__DIR__));
require BASE_DIR . '/vendor/autoload.php';
use Steuerkram\Auth;
use Steuerkram\Config;
use Steuerkram\Router;
use Steuerkram\Controllers\AccountController;
use Steuerkram\Controllers\BelegController;
use Steuerkram\Controllers\BookingController;
use Steuerkram\Controllers\CustomerController;
use Steuerkram\Controllers\InvoiceController;
use Steuerkram\Controllers\UstvaController;
use Steuerkram\Controllers\EuerController;
use Steuerkram\Controllers\SettingsController;
Config::load();
Auth::require();
$router = new Router();
// ── Dashboard ────────────────────────────────────────────────
$router->get('/', fn($p) => (new AccountController())->dashboard($p));
$router->get('/dashboard', fn($p) => (new AccountController())->dashboard($p));
// ── Kontenrahmen ─────────────────────────────────────────────
$router->get('/accounts', fn($p) => (new AccountController())->index($p));
$router->get('/accounts/new', fn($p) => (new AccountController())->create($p));
$router->post('/accounts', fn($p) => (new AccountController())->store($p));
$router->get('/accounts/{id}/edit', fn($p) => (new AccountController())->edit($p));
$router->post('/accounts/{id}', fn($p) => (new AccountController())->update($p));
$router->post('/accounts/{id}/delete', fn($p) => (new AccountController())->destroy($p));
$router->get('/accounts/search', fn($p) => (new AccountController())->search($p));
// ── Belege ───────────────────────────────────────────────────
$router->get('/belege', fn($p) => (new BelegController())->index($p));
$router->get('/belege/upload', fn($p) => (new BelegController())->uploadForm($p));
$router->post('/belege/upload', fn($p) => (new BelegController())->upload($p));
$router->get('/belege/{id}', fn($p) => (new BelegController())->show($p));
$router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p));
$router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p));
$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p));
// ── Buchungen ────────────────────────────────────────────────
$router->get('/journal', fn($p) => (new BookingController())->journal($p));
$router->get('/journal/export', fn($p) => (new BookingController())->export($p));
$router->post('/belege/{beleg_id}/bookings', fn($p) => (new BookingController())->store($p));
$router->post('/bookings/{id}/reverse', fn($p) => (new BookingController())->reverse($p));
$router->post('/bookings/{id}/delete', fn($p) => (new BookingController())->destroy($p));
// ── Kunden ───────────────────────────────────────────────────
$router->get('/customers', fn($p) => (new CustomerController())->index($p));
$router->get('/customers/new', fn($p) => (new CustomerController())->create($p));
$router->post('/customers', fn($p) => (new CustomerController())->store($p));
$router->get('/customers/{id}', fn($p) => (new CustomerController())->show($p));
$router->get('/customers/{id}/edit', fn($p) => (new CustomerController())->edit($p));
$router->post('/customers/{id}', fn($p) => (new CustomerController())->update($p));
$router->post('/customers/{id}/archive', fn($p) => (new CustomerController())->archive($p));
// ── Rechnungen ───────────────────────────────────────────────
$router->get('/invoices', fn($p) => (new InvoiceController())->index($p));
$router->get('/invoices/new', fn($p) => (new InvoiceController())->create($p));
$router->post('/invoices', fn($p) => (new InvoiceController())->store($p));
$router->get('/invoices/{id}', fn($p) => (new InvoiceController())->show($p));
$router->get('/invoices/{id}/edit', fn($p) => (new InvoiceController())->edit($p));
$router->post('/invoices/{id}', fn($p) => (new InvoiceController())->update($p));
$router->post('/invoices/{id}/finalize', fn($p) => (new InvoiceController())->finalize($p));
$router->get('/invoices/{id}/pdf', fn($p) => (new InvoiceController())->pdf($p));
$router->post('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p));
$router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p));
$router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->cancel($p));
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
// ── UStVA ────────────────────────────────────────────────────
$router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p));
$router->get('/tax/ustva/{year}/{month}', fn($p) => (new UstvaController())->show($p));
$router->post('/tax/ustva/{year}/{month}/save', fn($p) => (new UstvaController())->save($p));
$router->get('/tax/ustva/{year}/{month}/xml', fn($p) => (new UstvaController())->downloadXml($p));
// ── EÜR ─────────────────────────────────────────────────────
$router->get('/tax/euer', fn($p) => (new EuerController())->index($p));
$router->get('/tax/euer/{year}', fn($p) => (new EuerController())->show($p));
$router->get('/tax/euer/{year}/pdf', fn($p) => (new EuerController())->pdf($p));
// ── Einstellungen ────────────────────────────────────────────
$router->get('/settings', fn($p) => (new SettingsController())->show($p));
$router->post('/settings', fn($p) => (new SettingsController())->update($p));
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
$router->dispatch();

453
public/static/css/app.css Normal file
View file

@ -0,0 +1,453 @@
/* ============================================================
Steuerkram – Haupt-Stylesheet
============================================================ */
:root {
--color-bg: #f4f6f9;
--color-surface: #ffffff;
--color-border: #d1d9e0;
--color-primary: #2563eb;
--color-primary-h: #1d4ed8;
--color-danger: #dc2626;
--color-danger-h: #b91c1c;
--color-success: #16a34a;
--color-warning: #d97706;
--color-text: #1e293b;
--color-muted: #64748b;
--color-nav-bg: #1e293b;
--color-nav-text: #cbd5e1;
--color-nav-hover: #334155;
--color-nav-active:#2563eb;
--radius: 6px;
--shadow-sm: 0 1px 3px rgba(0,0,0,.08);
--shadow: 0 2px 8px rgba(0,0,0,.10);
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--font-mono: 'Cascadia Code', 'Consolas', monospace;
--nav-width: 220px;
}
/* ── Reset ─────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; }
body { font-family: var(--font); background: var(--color-bg); color: var(--color-text); }
a { color: var(--color-primary); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; }
/* ── Layout ─────────────────────────────────────────────────── */
.app-layout {
display: grid;
grid-template-columns: var(--nav-width) 1fr;
min-height: 100vh;
}
/* ── Sidebar Navigation ─────────────────────────────────────── */
.sidebar {
background: var(--color-nav-bg);
color: var(--color-nav-text);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-brand {
padding: 1.25rem 1rem;
font-size: 1.1rem;
font-weight: 700;
color: #fff;
border-bottom: 1px solid #334155;
letter-spacing: .02em;
}
.sidebar-brand span { color: var(--color-nav-active); }
.sidebar-section {
padding: .5rem 0 .25rem;
}
.sidebar-label {
font-size: .65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .08em;
color: #475569;
padding: .5rem 1rem .25rem;
}
.sidebar nav a {
display: flex;
align-items: center;
gap: .6rem;
padding: .55rem 1rem;
color: var(--color-nav-text);
font-size: .875rem;
border-left: 3px solid transparent;
transition: background .15s, color .15s;
}
.sidebar nav a:hover {
background: var(--color-nav-hover);
color: #fff;
text-decoration: none;
}
.sidebar nav a.active {
background: rgba(37,99,235,.15);
color: #93c5fd;
border-left-color: var(--color-nav-active);
}
.sidebar nav a svg {
width: 16px; height: 16px; flex-shrink: 0;
}
/* ── Main content ───────────────────────────────────────────── */
.main-content {
display: flex;
flex-direction: column;
min-height: 100vh;
overflow: hidden;
}
.topbar {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: .75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.topbar-title {
font-size: 1.1rem;
font-weight: 600;
}
.page-body {
padding: 1.5rem;
flex: 1;
}
/* ── Flash Messages ─────────────────────────────────────────── */
.flash-list { margin-bottom: 1rem; }
.flash {
padding: .75rem 1rem;
border-radius: var(--radius);
margin-bottom: .5rem;
font-size: .875rem;
display: flex;
align-items: center;
gap: .5rem;
}
.flash-success { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
.flash-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
.flash-warning { background: #fef9c3; color: #854d0e; border: 1px solid #fef08a; }
.flash-info { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; }
/* ── Cards ──────────────────────────────────────────────────── */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.card-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.card-title { font-size: 1rem; font-weight: 600; }
.card-body { padding: 1.25rem; }
/* ── Buttons ─────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .45rem .9rem;
font-size: .875rem;
font-weight: 500;
border-radius: var(--radius);
border: 1px solid transparent;
cursor: pointer;
transition: background .15s, border-color .15s, opacity .15s;
text-decoration: none !important;
white-space: nowrap;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--color-primary); color: #fff; }
.btn-primary:hover { background: var(--color-primary-h); }
.btn-secondary { background: var(--color-surface); color: var(--color-text); border-color: var(--color-border); }
.btn-secondary:hover { background: var(--color-bg); }
.btn-danger { background: var(--color-danger); color: #fff; }
.btn-danger:hover { background: var(--color-danger-h); }
.btn-success { background: var(--color-success); color: #fff; }
.btn-success:hover { background: #15803d; }
.btn-sm { padding: .3rem .65rem; font-size: .8rem; }
.btn-lg { padding: .65rem 1.25rem; font-size: 1rem; }
/* ── Forms ───────────────────────────────────────────────────── */
.form-group { margin-bottom: 1rem; }
label {
display: block;
font-size: .8rem;
font-weight: 600;
margin-bottom: .3rem;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: .04em;
}
input[type="text"],
input[type="email"],
input[type="number"],
input[type="date"],
input[type="tel"],
input[type="url"],
input[type="password"],
input[type="search"],
select,
textarea {
width: 100%;
padding: .5rem .75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: .875rem;
font-family: var(--font);
background: var(--color-surface);
color: var(--color-text);
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37,99,235,.12);
}
.input-error { border-color: var(--color-danger) !important; }
.error-text { font-size: .8rem; color: var(--color-danger); margin-top: .25rem; }
textarea { min-height: 80px; resize: vertical; }
.form-grid { display: grid; gap: 1rem; }
.form-grid-2 { grid-template-columns: 1fr 1fr; }
.form-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.form-grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
.form-row {
display: flex;
gap: .75rem;
align-items: flex-end;
}
.form-hint { font-size: .78rem; color: var(--color-muted); margin-top: .2rem; }
/* ── Tables ──────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
thead th {
background: var(--color-bg);
padding: .6rem 1rem;
text-align: left;
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--color-muted);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--color-border); }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: #f8fafc; }
td { padding: .65rem 1rem; vertical-align: middle; }
.td-right { text-align: right; }
.td-mono { font-family: var(--font-mono); font-size: .82rem; }
.td-muted { color: var(--color-muted); font-size: .82rem; }
/* ── Badges ──────────────────────────────────────────────────── */
.badge {
display: inline-block;
padding: .15rem .6rem;
border-radius: 999px;
font-size: .72rem;
font-weight: 600;
white-space: nowrap;
}
.badge-green { background: #dcfce7; color: #166534; }
.badge-red { background: #fee2e2; color: #991b1b; }
.badge-yellow { background: #fef9c3; color: #854d0e; }
.badge-blue { background: #dbeafe; color: #1e40af; }
.badge-gray { background: #f1f5f9; color: #475569; }
.badge-purple { background: #ede9fe; color: #6d28d9; }
/* ── Toolbar / Filter bar ────────────────────────────────────── */
.toolbar {
display: flex;
flex-wrap: wrap;
gap: .75rem;
align-items: center;
margin-bottom: 1rem;
}
.toolbar-left { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
.toolbar-right { margin-left: auto; display: flex; gap: .5rem; }
/* ── Stats / Dashboard Cards ─────────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
}
.stat-label { font-size: .78rem; color: var(--color-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: .4rem; }
.stat-value { font-size: 1.6rem; font-weight: 700; }
.stat-meta { font-size: .78rem; color: var(--color-muted); margin-top: .25rem; }
/* ── Beleg Detail (Split-Pane) ───────────────────────────────── */
.beleg-layout {
display: grid;
grid-template-columns: 1fr 420px;
gap: 1.5rem;
align-items: start;
}
.beleg-preview {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
aspect-ratio: unset;
}
.beleg-preview iframe,
.beleg-preview img {
width: 100%;
min-height: 700px;
border: none;
display: block;
}
/* ── Invoice preview (DIN 5008) ──────────────────────────────── */
.invoice-preview {
background: #fff;
padding: 2cm 2cm 2.5cm;
max-width: 21cm;
margin: 0 auto;
box-shadow: var(--shadow);
font-size: 11pt;
line-height: 1.4;
color: #000;
}
/* ── Modals ──────────────────────────────────────────────────── */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
z-index: 100;
justify-content: center;
align-items: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
max-width: 500px;
width: 95%;
padding: 1.5rem;
}
.modal-title { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
.modal-actions { display: flex; gap: .75rem; justify-content: flex-end; margin-top: 1.25rem; }
/* ── Pagination ──────────────────────────────────────────────── */
.pagination {
display: flex;
gap: .25rem;
justify-content: center;
margin-top: 1.25rem;
}
.pagination a, .pagination span {
padding: .35rem .7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: .82rem;
color: var(--color-text);
}
.pagination a:hover { background: var(--color-bg); text-decoration: none; }
.pagination .active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
/* ── Utilities ───────────────────────────────────────────────── */
.text-muted { color: var(--color-muted); }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-sm { font-size: .82rem; }
.text-lg { font-size: 1.1rem; }
.font-bold { font-weight: 700; }
.font-mono { font-family: var(--font-mono); }
.mt-1 { margin-top: .5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mb-1 { margin-bottom: .5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.d-flex { display: flex; }
.gap-1 { gap: .5rem; }
.gap-2 { gap: 1rem; }
.align-center { align-items: center; }
.justify-between { justify-content: space-between; }
.w-full { width: 100%; }
.hidden { display: none; }
/* ── Print ───────────────────────────────────────────────────── */
@media print {
.sidebar, .topbar, .btn, .toolbar, .pagination { display: none !important; }
.main-content { grid-column: 1 / -1; }
.page-body { padding: 0; }
}

267
public/static/js/app.js Normal file
View file

@ -0,0 +1,267 @@
/**
* Steuerkram – App JavaScript
* Kein Framework. Nur native DOM-APIs.
*/
'use strict';
// ── Kontenart-Badge neben Label anzeigen ─────────────────────
function showKontoArt(selectId, badgeId, sel) {
const badge = document.getElementById(badgeId);
if (!badge) return;
const opt = sel.options[sel.selectedIndex];
const art = opt?.dataset?.art || '';
const classes = {
'Aufwand': 'badge-red',
'Ertrag': 'badge-green',
'Aktiv': 'badge-blue',
'Passiv': 'badge-yellow',
'Neutral': 'badge-gray',
};
badge.textContent = art;
badge.className = 'badge ' + (classes[art] || 'badge-gray');
badge.style.display = art ? '' : 'none';
}
// ── Konto-Filter (Select-Filterung per Sucheingabe) ───────────
document.addEventListener('input', (e) => {
if (!e.target.classList.contains('konto-filter')) return;
const q = e.target.value.toLowerCase();
const select = document.getElementById(e.target.dataset.target);
if (!select) return;
for (const opt of select.options) {
if (opt.value === '') { opt.hidden = false; continue; }
opt.hidden = !opt.dataset.search.toLowerCase().includes(q);
}
// Auto-select first visible match
if (q.length > 0) {
for (const opt of select.options) {
if (!opt.hidden && opt.value !== '') { select.value = opt.value; break; }
}
}
});
// ── Modals ────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
// Open modal
const openBtn = e.target.closest('[data-modal-open]');
if (openBtn) {
const id = openBtn.dataset.modalOpen;
document.getElementById(id)?.classList.add('open');
return;
}
// Close modal via backdrop click or close button
const closeBtn = e.target.closest('[data-modal-close]');
if (closeBtn) {
closeBtn.closest('.modal-overlay')?.classList.remove('open');
return;
}
if (e.target.classList.contains('modal-overlay')) {
e.target.classList.remove('open');
}
});
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
}
});
// ── Confirm Delete ────────────────────────────────────────────
document.addEventListener('submit', (e) => {
const form = e.target;
const msg = form.dataset.confirm;
if (msg && !confirm(msg)) {
e.preventDefault();
}
});
// ── Rechnungszeilen – Live-Kalkulation ────────────────────────
function calcInvoiceLine(row) {
const menge = parseFloat(row.querySelector('[name$="[menge]"]')?.value) || 0;
const preis = parseFloat(row.querySelector('[name$="[einzelpreis]"]')?.value) || 0;
const ustSatz = parseFloat(row.querySelector('[name$="[ust_satz]"]')?.value) || 0;
const netto = menge * preis;
const ust = netto * ustSatz / 100;
const brutto = netto + ust;
const fmt = (v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const nettoEl = row.querySelector('.line-netto');
const bruttoEl = row.querySelector('.line-brutto');
if (nettoEl) nettoEl.textContent = fmt(netto) + ' €';
if (bruttoEl) bruttoEl.textContent = fmt(brutto) + ' €';
}
function calcInvoiceTotals() {
let totalNetto = 0;
let totalUst = 0;
let totalBrutto = 0;
document.querySelectorAll('.invoice-line-row').forEach(row => {
const menge = parseFloat(row.querySelector('[name$="[menge]"]')?.value) || 0;
const preis = parseFloat(row.querySelector('[name$="[einzelpreis]"]')?.value) || 0;
const ustSatz = parseFloat(row.querySelector('[name$="[ust_satz]"]')?.value) || 0;
const netto = menge * preis;
const ust = netto * ustSatz / 100;
totalNetto += netto;
totalUst += ust;
totalBrutto += netto + ust;
calcInvoiceLine(row);
});
const fmt = (v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
const elNetto = document.getElementById('total-netto');
const elUst = document.getElementById('total-ust');
const elBrutto = document.getElementById('total-brutto');
if (elNetto) elNetto.textContent = fmt(totalNetto);
if (elUst) elUst.textContent = fmt(totalUst);
if (elBrutto) elBrutto.textContent = fmt(totalBrutto);
}
// Live-Update on input change in invoice form
document.addEventListener('input', (e) => {
if (e.target.closest('.invoice-line-row')) {
calcInvoiceTotals();
}
if (e.target.closest('.invoice-line-row') && e.target.matches('[name$="[ust_satz]"]')) {
calcInvoiceTotals();
}
});
// ── Rechnungszeile hinzufügen ─────────────────────────────────
let lineCounter = 0;
function addInvoiceLine() {
lineCounter++;
const tbody = document.getElementById('invoice-lines-body');
if (!tbody) return;
const idx = tbody.querySelectorAll('.invoice-line-row').length;
const row = document.createElement('tr');
row.className = 'invoice-line-row';
row.innerHTML = `
<td><span class="line-pos text-muted">${idx + 1}</span></td>
<td><input type="text" name="lines[${idx}][bezeichnung]" required placeholder="Leistungsbeschreibung" style="min-width:200px"></td>
<td><input type="number" name="lines[${idx}][menge]" value="1" min="0.001" step="0.001" style="width:80px" required></td>
<td>
<select name="lines[${idx}][einheit]" style="width:80px">
<option value="Stk.">Stk.</option>
<option value="h">h</option>
<option value="Tage">Tage</option>
<option value="Monat">Monat</option>
<option value="Pauschale">Pauschale</option>
<option value="km">km</option>
</select>
</td>
<td>
<select name="lines[${idx}][ust_satz]" style="width:80px">
<option value="19">19 %</option>
<option value="7">7 %</option>
<option value="0">0 %</option>
</select>
</td>
<td><input type="number" name="lines[${idx}][einzelpreis]" value="0.00" min="0" step="0.01" style="width:110px" required></td>
<td class="td-right line-netto text-muted">0,00 €</td>
<td class="td-right line-brutto font-bold">0,00 €</td>
<td>
<button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button>
</td>`;
tbody.appendChild(row);
calcInvoiceTotals();
}
function removeInvoiceLine(btn) {
btn.closest('.invoice-line-row').remove();
// Renumber positions
document.querySelectorAll('.invoice-line-row').forEach((row, i) => {
const posEl = row.querySelector('.line-pos');
if (posEl) posEl.textContent = i + 1;
// Update indices in name attributes
row.querySelectorAll('[name]').forEach(el => {
el.name = el.name.replace(/lines\[\d+\]/, `lines[${i}]`);
});
});
calcInvoiceTotals();
}
// Init totals on page load
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('invoice-lines-body')) {
calcInvoiceTotals();
}
});
// ── Konto-Suche (Typeahead) ───────────────────────────────────
function setupAccountSearch(inputId, hiddenId) {
const input = document.getElementById(inputId);
const hidden = document.getElementById(hiddenId);
if (!input || !hidden) return;
let dropdown = null;
let debounce = null;
input.addEventListener('input', () => {
clearTimeout(debounce);
const q = input.value.trim();
if (q.length < 1) { closeDropdown(); return; }
debounce = setTimeout(async () => {
const res = await fetch(`/accounts/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
renderDropdown(data);
}, 200);
});
function renderDropdown(items) {
closeDropdown();
if (!items.length) return;
dropdown = document.createElement('ul');
dropdown.className = 'account-dropdown';
dropdown.style.cssText = `
position:absolute; z-index:50; background:#fff; border:1px solid var(--color-border);
border-radius:var(--radius); width:100%; max-height:220px; overflow-y:auto;
list-style:none; padding:0; margin:0; box-shadow:var(--shadow);
`;
items.forEach(item => {
const li = document.createElement('li');
li.style.cssText = 'padding:.5rem .75rem; cursor:pointer; font-size:.875rem; border-bottom:1px solid var(--color-border)';
li.innerHTML = `<strong>${item.kontonummer}</strong> – ${item.bezeichnung} <small style="color:var(--color-muted)">(${item.kontenart})</small>`;
li.addEventListener('mousedown', () => {
input.value = `${item.kontonummer} – ${item.bezeichnung}`;
hidden.value = item.id;
closeDropdown();
});
dropdown.appendChild(li);
});
const wrapper = input.closest('.account-search-wrap') || input.parentElement;
wrapper.style.position = 'relative';
wrapper.appendChild(dropdown);
}
function closeDropdown() {
dropdown?.remove();
dropdown = null;
}
input.addEventListener('blur', () => setTimeout(closeDropdown, 150));
}
// ── Flash Message Auto-dismiss ────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.flash').forEach(el => {
setTimeout(() => {
el.style.transition = 'opacity .4s';
el.style.opacity = '0';
setTimeout(() => el.remove(), 400);
}, 4000);
});
});

32
scripts/backup.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
# Steuerkram – Backup-Script
# GoBD: Datensicherung muss regelmäßig erfolgen.
# Usage: ./scripts/backup.sh
set -e
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="./backups"
mkdir -p "$BACKUP_DIR"
echo "Erstelle Backup $TIMESTAMP..."
# MariaDB Dump (aus dem Container)
docker compose exec -T db mysqldump \
--user=steuerkram \
--password=steuerkram \
--single-transaction \
--routines \
--triggers \
steuerkram > "$BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql"
# Uploads sichern
tar -czf "$BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz" ./uploads/
echo "Backup abgeschlossen:"
echo " DB: $BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql"
echo " Uploads: $BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz"
# Alte Backups löschen (älter als 90 Tage)
find "$BACKUP_DIR" -name "steuerkram_*" -mtime +90 -delete
echo "Alte Backups bereinigt (>90 Tage)."

25
src/Auth.php Normal file
View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Steuerkram;
class Auth
{
public static function require(): void
{
$user = Config::get('auth_user');
$pass = Config::get('auth_pass');
if (
!isset($_SERVER['PHP_AUTH_USER']) ||
$_SERVER['PHP_AUTH_USER'] !== $user ||
$_SERVER['PHP_AUTH_PW'] !== $pass
) {
header('WWW-Authenticate: Basic realm="Steuerkram"');
header('HTTP/1.1 401 Unauthorized');
echo '<!DOCTYPE html><html><body><h1>401 Unauthorized</h1></body></html>';
exit;
}
}
}

30
src/Config.php Normal file
View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Steuerkram;
class Config
{
private static array $data = [];
public static function load(): void
{
self::$data = [
'db_host' => $_ENV['DB_HOST'] ?? getenv('DB_HOST') ?: 'db',
'db_name' => $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: 'steuerkram',
'db_user' => $_ENV['DB_USER'] ?? getenv('DB_USER') ?: 'steuerkram',
'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram',
'auth_user' => $_ENV['AUTH_USER'] ?? getenv('AUTH_USER') ?: 'admin',
'auth_pass' => $_ENV['AUTH_PASS'] ?? getenv('AUTH_PASS') ?: 'changeme',
'app_secret' => $_ENV['APP_SECRET'] ?? getenv('APP_SECRET') ?: 'dev-secret',
'upload_dir' => dirname(__DIR__) . '/public/static/uploads',
'base_url' => '',
];
}
public static function get(string $key, mixed $default = null): mixed
{
return self::$data[$key] ?? $default;
}
}

View file

@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Account;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\FiscalYear;
use Steuerkram\View;
class AccountController
{
public function dashboard(array $params): void
{
$year = (int)date('Y');
// Quick stats
$stats = [];
// Umsatz (Ertrag-Konten)
$umsatz = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
FROM bookings bk
JOIN accounts a ON bk.haben_konto_id = a.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE a.kontenart = \'Ertrag\' AND fy.year = ?',
[$year]
);
$stats['umsatz'] = (float)($umsatz['total'] ?? 0);
// Ausgaben (Aufwand-Konten)
$ausgaben = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
FROM bookings bk
JOIN accounts a ON bk.soll_konto_id = a.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE a.kontenart = \'Aufwand\' AND fy.year = ?',
[$year]
);
$stats['ausgaben'] = (float)($ausgaben['total'] ?? 0);
$stats['gewinn'] = $stats['umsatz'] - $stats['ausgaben'];
$stats['year'] = $year;
// Offene Rechnungen
$offen = \Steuerkram\Database::fetchOne(
'SELECT COUNT(*) as cnt, COALESCE(SUM(betrag_brutto),0) as total
FROM invoices WHERE status=\'Gesendet\''
);
$stats['offene_rechnungen'] = (int)($offen['cnt'] ?? 0);
$stats['offene_rechnungen_sum'] = (float)($offen['total'] ?? 0);
// Ungebuchte Belege
$ungebucht = \Steuerkram\Database::fetchOne(
'SELECT COUNT(*) as cnt FROM belege WHERE booked=0'
);
$stats['ungebuchte_belege'] = (int)($ungebucht['cnt'] ?? 0);
// Letzte Buchungen
$recentBookings = \Steuerkram\Database::fetchAll(
'SELECT bk.*, s.kontonummer as soll_nr, s.bezeichnung as soll_name,
h.kontonummer as haben_nr, h.bezeichnung as haben_name,
bl.beleg_nr
FROM bookings bk
JOIN accounts s ON bk.soll_konto_id = s.id
JOIN accounts h ON bk.haben_konto_id = h.id
JOIN belege bl ON bk.beleg_id = bl.id
ORDER BY bk.created_at DESC LIMIT 10'
);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Dashboard',
'content' => $this->renderView('dashboard', [
'stats' => $stats,
'recentBookings' => $recentBookings,
]),
]);
}
public function index(array $params): void
{
$filter = $_GET;
$accounts = Account::all($filter);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Kontenrahmen (SKR03)',
'content' => $this->renderView('accounts/list', [
'accounts' => $accounts,
'filter' => $filter,
]),
'topbarActions' => '<a href="/accounts/new" class="btn btn-primary">+ Konto anlegen</a>',
]);
}
public function create(array $params): void
{
$back = $_GET['from'] === 'settings' ? '/settings' : '/accounts';
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Konto anlegen',
'content' => $this->renderView('accounts/form', [
'account' => [],
'errors' => [],
'action' => '/accounts',
'back' => $back,
]),
]);
}
public function store(array $params): void
{
$data = $_POST;
$errors = $this->validate($data);
if (Account::isKontonummerTaken($data['kontonummer'] ?? '')) {
$errors['kontonummer'] = 'Diese Kontonummer ist bereits vergeben.';
}
$back = $_POST['_back'] ?? '/accounts';
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Konto anlegen',
'content' => $this->renderView('accounts/form', [
'account' => $data,
'errors' => $errors,
'action' => '/accounts',
'back' => $back,
]),
]);
return;
}
Account::create($data);
$back = $_POST['_back'] ?? '/accounts';
View::redirect($back, 'Konto erfolgreich angelegt.');
}
public function edit(array $params): void
{
$account = Account::find((int)$params['id']);
if (!$account) { http_response_code(404); return; }
$back = ($_GET['from'] ?? '') === 'settings' ? '/settings' : '/accounts';
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Konto bearbeiten',
'content' => $this->renderView('accounts/form', [
'account' => $account,
'errors' => [],
'action' => '/accounts/' . $account['id'],
'back' => $back,
]),
]);
}
public function update(array $params): void
{
$data = $_POST;
$id = (int)$params['id'];
$account = Account::find($id);
if (!$account) { http_response_code(404); return; }
$errors = $this->validate($data);
$back = $_POST['_back'] ?? '/accounts';
if (Account::isKontonummerTaken($data['kontonummer'] ?? '', $id)) {
$errors['kontonummer'] = 'Diese Kontonummer ist bereits vergeben.';
}
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Konto bearbeiten',
'content' => $this->renderView('accounts/form', [
'account' => array_merge($account, $data),
'errors' => $errors,
'action' => '/accounts/' . $id,
'back' => $back,
]),
]);
return;
}
Account::update($id, $data);
$back = $_POST['_back'] ?? '/accounts';
View::redirect($back, 'Konto aktualisiert.');
}
public function destroy(array $params): void
{
$id = (int)$params['id'];
$back = $_POST['_back'] ?? '/accounts';
if (!Account::delete($id)) {
View::redirect($back, 'Konto kann nicht gelöscht werden (wird in Buchungen verwendet).', 'error');
return;
}
View::redirect($back, 'Konto gelöscht.');
}
public function search(array $params): void
{
$q = trim($_GET['q'] ?? '');
$results = $q ? Account::search($q) : [];
header('Content-Type: application/json');
echo json_encode($results, JSON_UNESCAPED_UNICODE);
}
private function validate(array $data): array
{
$errors = [];
if (empty($data['kontonummer'])) $errors['kontonummer'] = 'Pflichtfeld';
if (empty($data['bezeichnung'])) $errors['bezeichnung'] = 'Pflichtfeld';
if (empty($data['kontenart'])) $errors['kontenart'] = 'Pflichtfeld';
return $errors;
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Account;
use Steuerkram\Config;
use Steuerkram\View;
class BelegController
{
private const ALLOWED_MIME = [
'application/pdf' => 'pdf',
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/tiff' => 'tif',
'application/xml' => 'xml',
'text/xml' => 'xml',
];
public function index(array $params): void
{
$filter = $_GET;
$page = max(1, (int)($_GET['page'] ?? 1));
$result = Beleg::all($filter, $page);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Belege',
'content' => $this->renderView('belege/list', [
'rows' => $result['rows'],
'total' => $result['total'],
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
]),
'topbarActions' => '<a href="/belege/upload" class="btn btn-primary">+ Beleg hochladen</a>',
]);
}
public function uploadForm(array $params): void
{
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg hochladen',
'content' => $this->renderView('belege/upload', ['errors' => []]),
]);
}
public function upload(array $params): void
{
$errors = [];
if (empty($_FILES['beleg_file']) || $_FILES['beleg_file']['error'] !== UPLOAD_ERR_OK) {
$errors['file'] = 'Keine gültige Datei übermittelt.';
} else {
$file = $_FILES['beleg_file'];
$mime = mime_content_type($file['tmp_name']);
$mimeKey = strtolower($mime);
if (!isset(self::ALLOWED_MIME[$mimeKey])) {
$errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.';
}
}
if ($errors) {
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
http_response_code(422);
header('Content-Type: application/json');
echo json_encode(['error' => implode(' ', $errors)]);
return;
}
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg hochladen',
'content' => $this->renderView('belege/upload', [
'errors' => $errors,
'old' => $_POST,
]),
]);
return;
}
$file = $_FILES['beleg_file'];
$mime = mime_content_type($file['tmp_name']);
$ext = self::ALLOWED_MIME[strtolower($mime)];
$hash = hash_file('sha256', $file['tmp_name']);
$belegNr = Beleg::generateBelegNr();
$year = !empty($_POST['beleg_datum'])
? date('Y', strtotime($_POST['beleg_datum']))
: date('Y');
// Store under uploads/{year}/{belegNr}/original.{ext}
$relDir = "static/uploads/{$year}/{$belegNr}";
$absDir = dirname(__DIR__, 2) . "/public/{$relDir}";
if (!is_dir($absDir)) mkdir($absDir, 0755, true);
$filename = pathinfo($file['name'], PATHINFO_BASENAME);
$storagePath = "{$relDir}/original.{$ext}";
$absPath = dirname(__DIR__, 2) . "/public/{$storagePath}";
move_uploaded_file($file['tmp_name'], $absPath);
$id = Beleg::create([
'beleg_nr' => $belegNr,
'filename' => $filename,
'storage_path' => $storagePath,
'mime_type' => $mime,
'file_hash' => $hash,
'beleg_datum' => $_POST['beleg_datum'] ?? null,
'eingangs_datum'=> $_POST['eingangs_datum'] ?? date('Y-m-d'),
'rechnungs_nr' => $_POST['rechnungs_nr'] ?? null,
'betrag_brutto' => $_POST['betrag_brutto'] ?? null,
'belegtyp' => $_POST['belegtyp'] ?? 'Eingangsrechnung',
'notes' => $_POST['notes'] ?? null,
]);
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
header('Content-Type: application/json');
echo json_encode(['id' => $id, 'beleg_nr' => $belegNr]);
return;
}
View::redirect("/belege/{$id}", "Beleg {$belegNr} erfolgreich hochgeladen.");
}
public function show(array $params): void
{
$beleg = Beleg::find((int)$params['id']);
if (!$beleg) { http_response_code(404); return; }
$bookings = Booking::forBeleg((int)$params['id']);
$integrity = Beleg::verifyIntegrity((int)$params['id']);
$accounts = Account::all(['aktiv' => 1]);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg ' . $beleg['beleg_nr'],
'content' => $this->renderView('belege/detail', [
'beleg' => $beleg,
'bookings' => $bookings,
'integrity' => $integrity,
'accounts' => $accounts,
]),
]);
}
public function serveFile(array $params): void
{
$beleg = Beleg::find((int)$params['id']);
if (!$beleg) { http_response_code(404); return; }
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
if (!file_exists($absPath)) { http_response_code(404); return; }
header('Content-Type: ' . $beleg['mime_type']);
header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"');
header('Content-Length: ' . filesize($absPath));
readfile($absPath);
}
public function updateMeta(array $params): void
{
$id = (int)$params['id'];
$beleg = Beleg::find($id);
if (!$beleg) { http_response_code(404); return; }
if ($beleg['locked']) {
View::redirect("/belege/{$id}", 'GoBD: Gesperrter Beleg kann nicht geändert werden.', 'error');
return;
}
Beleg::updateMeta($id, [
'rechnungs_nr' => trim($_POST['rechnungs_nr'] ?? ''),
'belegtyp' => $_POST['belegtyp'] ?? $beleg['belegtyp'],
'beleg_datum' => $_POST['beleg_datum'] ?: null,
'betrag_brutto' => $_POST['betrag_brutto'] !== '' ? $_POST['betrag_brutto'] : null,
]);
View::redirect("/belege/{$id}", 'Metadaten gespeichert.');
}
public function destroy(array $params): void
{
$id = (int)$params['id'];
if (!Beleg::delete($id)) {
View::redirect('/belege', 'Beleg kann nicht gelöscht werden (gesperrt oder enthält Buchungen).', 'error');
return;
}
View::redirect('/belege', 'Beleg gelöscht.');
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

View file

@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Account;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\FiscalYear;
use Steuerkram\Models\Setting;
use Steuerkram\View;
class BookingController
{
public function journal(array $params): void
{
$filter = $_GET;
$page = max(1, (int)($_GET['page'] ?? 1));
$result = Booking::journal($filter, $page);
$years = FiscalYear::availableYears();
$accounts = Account::all(['aktiv' => 1]);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Buchungsjournal',
'content' => $this->renderView('bookings/journal', [
'rows' => $result['rows'],
'total' => $result['total'],
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
'years' => $years,
'accounts' => $accounts,
'testMode' => Setting::isTestMode(),
]),
'topbarActions' => '<a href="/journal/export?'.http_build_query($filter).'" class="btn btn-secondary">CSV Export</a>',
]);
}
public function export(array $params): void
{
$filter = $_GET;
unset($filter['page']);
$result = Booking::journal($filter, 1, 99999);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="buchungsjournal-' . date('Ymd') . '.csv"');
$out = fopen('php://output', 'w');
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM for Excel
fputcsv($out, [
'Buchungsnr.', 'Datum', 'Belegnr.', 'Soll-Kto.', 'Soll-Bez.',
'Haben-Kto.', 'Haben-Bez.', 'Netto (€)', 'USt (%)', 'USt (€)',
'Brutto (€)', 'Buchungstext', 'Storno'
], ';');
foreach ($result['rows'] as $row) {
fputcsv($out, [
$row['buchungs_nr'],
$row['buchungs_datum'],
$row['beleg_nr'],
$row['soll_nr'],
$row['soll_name'],
$row['haben_nr'],
$row['haben_name'],
number_format((float)$row['betrag_netto'], 2, ',', ''),
number_format((float)$row['ust_satz'], 2, ',', ''),
number_format((float)$row['betrag_ust'], 2, ',', ''),
number_format((float)$row['betrag_brutto'], 2, ',', ''),
$row['buchungstext'],
$row['is_reversal'] ? 'Ja' : 'Nein',
], ';');
}
fclose($out);
}
public function store(array $params): void
{
$belegId = (int)$params['beleg_id'];
$data = $_POST;
$errors = [];
// Validate
if (empty($data['soll_konto_id'])) $errors['soll_konto_id'] = 'Pflichtfeld';
if (empty($data['haben_konto_id'])) $errors['haben_konto_id'] = 'Pflichtfeld';
if (empty($data['buchungs_datum'])) $errors['buchungs_datum'] = 'Pflichtfeld';
if (!isset($data['betrag_netto']) || $data['betrag_netto'] === '')
$errors['betrag_netto'] = 'Pflichtfeld';
if (empty($data['buchungstext'])) $errors['buchungstext'] = 'Pflichtfeld';
if (empty($errors) && $data['soll_konto_id'] === $data['haben_konto_id']) {
$errors['haben_konto_id'] = 'Soll- und Habenkonto dürfen nicht identisch sein.';
}
if ($errors) {
$this->renderBelegDetail($belegId, $errors, $data);
return;
}
try {
$data['beleg_id'] = $belegId;
Booking::create($data);
View::redirect("/belege/{$belegId}", 'Buchung erfasst.');
} catch (\Exception $e) {
$this->renderBelegDetail($belegId, ['_exception' => $e->getMessage()], $data);
}
}
private function renderBelegDetail(int $belegId, array $errors, array $formData = []): void
{
$beleg = \Steuerkram\Models\Beleg::find($belegId);
$bookings = \Steuerkram\Models\Booking::forBeleg($belegId);
$accounts = \Steuerkram\Models\Account::all(['aktiv' => 1]);
$integrity = \Steuerkram\Models\Beleg::verifyIntegrity($belegId);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg ' . ($beleg['beleg_nr'] ?? ''),
'content' => $this->renderView('belege/detail', [
'beleg' => $beleg,
'bookings' => $bookings,
'integrity' => $integrity,
'accounts' => $accounts,
'formErrors' => $errors,
'formData' => $formData,
]),
]);
}
public function destroy(array $params): void
{
if (!Setting::isTestMode()) {
View::redirect('/journal', 'Löschen ist im Produktivmodus nicht erlaubt (GoBD).', 'error');
return;
}
$id = (int)$params['id'];
$booking = Booking::find($id);
if (!$booking) {
View::redirect('/journal', 'Buchung nicht gefunden.', 'error');
return;
}
\Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]);
$redirect = !empty($_POST['beleg_id'])
? '/belege/' . (int)$_POST['beleg_id']
: '/journal';
View::redirect($redirect, 'Buchung gelöscht (Testmodus).');
}
public function reverse(array $params): void
{
$id = (int)$params['id'];
$reason = trim($_POST['reason'] ?? 'Manuelle Stornierung');
try {
Booking::reverse($id, $reason);
$booking = Booking::find($id);
View::redirect("/belege/{$booking['beleg_id']}", 'Buchung wurde storniert.');
} catch (\Exception $e) {
View::redirect('/journal', $e->getMessage(), 'error');
}
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Customer;
use Steuerkram\View;
class CustomerController
{
public function index(array $params): void
{
$filter = $_GET;
$customers = Customer::all($filter);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Kunden',
'content' => $this->renderView('customers/list', [
'customers' => $customers,
'filter' => $filter,
]),
'topbarActions' => '<a href="/customers/new" class="btn btn-primary">+ Kunde anlegen</a>',
]);
}
public function create(array $params): void
{
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Kunde anlegen',
'content' => $this->renderView('customers/form', [
'customer' => [],
'errors' => [],
'action' => '/customers',
]),
]);
}
public function store(array $params): void
{
$data = $_POST;
$errors = $this->validate($data);
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Kunde anlegen',
'content' => $this->renderView('customers/form', [
'customer' => $data,
'errors' => $errors,
'action' => '/customers',
]),
]);
return;
}
Customer::create($data);
View::redirect('/customers', 'Kunde erfolgreich angelegt.');
}
public function show(array $params): void
{
$customer = Customer::find((int)$params['id']);
if (!$customer) { http_response_code(404); return; }
$invoices = \Steuerkram\Database::fetchAll(
'SELECT * FROM invoices WHERE customer_id=? ORDER BY rechnungs_datum DESC LIMIT 20',
[$customer['id']]
);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => Customer::displayName($customer),
'content' => $this->renderView('customers/show', [
'customer' => $customer,
'invoices' => $invoices,
]),
]);
}
public function edit(array $params): void
{
$customer = Customer::find((int)$params['id']);
if (!$customer) { http_response_code(404); return; }
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Kunde bearbeiten',
'content' => $this->renderView('customers/form', [
'customer' => $customer,
'errors' => [],
'action' => '/customers/' . $customer['id'],
]),
]);
}
public function update(array $params): void
{
$id = (int)$params['id'];
$customer = Customer::find($id);
if (!$customer) { http_response_code(404); return; }
$data = $_POST;
$errors = $this->validate($data);
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Kunde bearbeiten',
'content' => $this->renderView('customers/form', [
'customer' => array_merge($customer, $data),
'errors' => $errors,
'action' => '/customers/' . $id,
]),
]);
return;
}
Customer::update($id, $data);
View::redirect('/customers/' . $id, 'Kundendaten aktualisiert.');
}
public function archive(array $params): void
{
Customer::archive((int)$params['id']);
View::redirect('/customers', 'Kunde archiviert.');
}
private function validate(array $data): array
{
$errors = [];
if (empty($data['nachname'])) $errors['nachname'] = 'Pflichtfeld';
if (empty($data['strasse'])) $errors['strasse'] = 'Pflichtfeld';
if (empty($data['plz'])) $errors['plz'] = 'Pflichtfeld';
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
return $errors;
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Setting;
use Steuerkram\Models\FiscalYear;
use Steuerkram\Services\EuerCalculator;
use Steuerkram\Services\PdfEuerService;
use Steuerkram\View;
class EuerController
{
public function index(array $params): void
{
$years = FiscalYear::availableYears();
if (empty($years)) $years = [(int)date('Y')];
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'EÜR – Einnahmen-Überschuss-Rechnung',
'content' => $this->renderView('tax/euer_index', ['years' => $years]),
]);
}
public function show(array $params): void
{
$year = (int)$params['year'];
$calc = new EuerCalculator();
$euer = $calc->calculate($year);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => "EÜR {$year}",
'content' => $this->renderView('tax/euer_show', [
'euer' => $euer,
'year' => $year,
]),
'topbarActions' => '<a href="/tax/euer/' . $year . '/pdf" class="btn btn-secondary">PDF herunterladen</a>',
]);
}
public function pdf(array $params): void
{
$year = (int)$params['year'];
$calc = new EuerCalculator();
$euer = $calc->calculate($year);
$settings = Setting::get();
$svc = new PdfEuerService();
$svc->render($euer, $settings);
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

View file

@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Invoice;
use Steuerkram\Models\Customer;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Setting;
use Steuerkram\Services\PdfInvoiceService;
use Steuerkram\View;
class InvoiceController
{
public function index(array $params): void
{
$filter = $_GET;
$page = max(1, (int)($_GET['page'] ?? 1));
$result = Invoice::all($filter, $page);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Ausgangsrechnungen',
'content' => $this->renderView('invoices/list', [
'rows' => $result['rows'],
'total' => $result['total'],
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
]),
'topbarActions' => '<a href="/invoices/new" class="btn btn-primary">+ Rechnung erstellen</a>',
]);
}
public function create(array $params): void
{
$customers = Customer::all();
$settings = Setting::get();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Neue Rechnung',
'content' => $this->renderView('invoices/form', [
'invoice' => [
'rechnungs_datum' => date('Y-m-d'),
'leistungszeitraum_von' => date('Y-m-01'),
'leistungszeitraum_bis' => date('Y-m-t'),
],
'lines' => [],
'customers' => $customers,
'errors' => [],
'action' => '/invoices',
'settings' => $settings,
]),
]);
}
public function store(array $params): void
{
$data = $_POST;
$errors = $this->validate($data);
if ($errors) {
$customers = Customer::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Neue Rechnung',
'content' => $this->renderView('invoices/form', [
'invoice' => $data,
'lines' => $data['lines'] ?? [],
'customers' => $customers,
'errors' => $errors,
'action' => '/invoices',
'settings' => Setting::get(),
]),
]);
return;
}
$id = Invoice::create($data);
View::redirect("/invoices/{$id}", 'Rechnung als Entwurf gespeichert.');
}
public function show(array $params): void
{
$invoice = Invoice::find((int)$params['id']);
if (!$invoice) { http_response_code(404); return; }
$lines = Invoice::lines((int)$params['id']);
$settings = Setting::get();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
'content' => $this->renderView('invoices/show', [
'invoice' => $invoice,
'lines' => $lines,
'settings' => $settings,
]),
]);
}
public function edit(array $params): void
{
$invoice = Invoice::find((int)$params['id']);
if (!$invoice) { http_response_code(404); return; }
if ($invoice['status'] !== 'Entwurf') {
View::redirect("/invoices/{$invoice['id']}", 'Nur Entwürfe können bearbeitet werden.', 'error');
return;
}
$lines = Invoice::lines((int)$params['id']);
$customers = Customer::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Rechnung bearbeiten',
'content' => $this->renderView('invoices/form', [
'invoice' => $invoice,
'lines' => $lines,
'customers' => $customers,
'errors' => [],
'action' => '/invoices/' . $invoice['id'],
'settings' => Setting::get(),
]),
]);
}
public function update(array $params): void
{
$id = (int)$params['id'];
$invoice = Invoice::find($id);
if (!$invoice) { http_response_code(404); return; }
$data = $_POST;
$errors = $this->validate($data);
if ($errors) {
$customers = Customer::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Rechnung bearbeiten',
'content' => $this->renderView('invoices/form', [
'invoice' => array_merge($invoice, $data),
'lines' => $data['lines'] ?? [],
'customers' => $customers,
'errors' => $errors,
'action' => '/invoices/' . $id,
'settings' => Setting::get(),
]),
]);
return;
}
try {
Invoice::update($id, $data);
View::redirect("/invoices/{$id}", 'Rechnung gespeichert.');
} catch (\Exception $e) {
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
}
}
public function finalize(array $params): void
{
$id = (int)$params['id'];
try {
$nr = Invoice::finalize($id);
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert.");
} catch (\Exception $e) {
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
}
}
public function pdf(array $params): void
{
$invoice = Invoice::find((int)$params['id']);
if (!$invoice) { http_response_code(404); return; }
$lines = Invoice::lines((int)$params['id']);
$settings = Setting::get();
$svc = new PdfInvoiceService();
$svc->render($invoice, $lines, $settings);
}
public function book(array $params): void
{
$id = (int)$params['id'];
$invoice = Invoice::find($id);
if (!$invoice) { http_response_code(404); return; }
if ($invoice['status'] === 'Entwurf') {
View::redirect("/invoices/{$id}", 'Rechnung muss zuerst finalisiert werden.', 'error');
return;
}
if (!empty($invoice['beleg_id'])) {
View::redirect("/invoices/{$id}", 'Rechnung wurde bereits gebucht.', 'warning');
return;
}
try {
\Steuerkram\Database::beginTransaction();
// Create Beleg for this invoice
$belegNr = Beleg::generateBelegNr();
$belegId = Beleg::create([
'beleg_nr' => $belegNr,
'filename' => $invoice['rechnungs_nr'] . '.pdf',
'storage_path' => '', // no physical file for outgoing invoices
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', $invoice['rechnungs_nr']),
'beleg_datum' => $invoice['rechnungs_datum'],
'eingangs_datum' => $invoice['rechnungs_datum'],
'rechnungs_nr' => $invoice['rechnungs_nr'],
'betrag_brutto' => $invoice['betrag_brutto'],
'belegtyp' => 'Ausgangsrechnung',
'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'],
]);
$debitorKonto = \Steuerkram\Database::fetchOne(
'SELECT id FROM accounts WHERE kontonummer = ?', ['1400']
);
// Erlöse-Konten nach USt-Satz: 8000 (19%), 8200 (7%), 8400 (0%/steuerfrei)
$erloesKonten = [];
foreach (\Steuerkram\Database::fetchAll(
"SELECT id, kontonummer FROM accounts WHERE kontonummer IN ('8000','8200','8400')"
) as $row) {
$erloesKonten[$row['kontonummer']] = $row['id'];
}
// Buchungen pro USt-Satz aus den Rechnungszeilen
$lines = Invoice::lines($id);
$gruppenNetto = [];
foreach ($lines as $line) {
$satz = (float)$line['ust_satz'];
$netto = round((float)$line['menge'] * (float)$line['einzelpreis'], 2);
$gruppenNetto[$satz] = ($gruppenNetto[$satz] ?? 0.0) + $netto;
}
foreach ($gruppenNetto as $satz => $netto) {
if (abs($satz - 19) < 0.01) {
$habenId = $erloesKonten['8000'] ?? null;
} elseif (abs($satz - 7) < 0.01) {
$habenId = $erloesKonten['8200'] ?? null;
} else {
$habenId = $erloesKonten['8400'] ?? null;
}
if (!$habenId) {
throw new \RuntimeException("Erlöskonto für USt-Satz {$satz}% nicht gefunden.");
}
Booking::create([
'beleg_id' => $belegId,
'buchungs_datum' => $invoice['rechnungs_datum'],
'soll_konto_id' => $debitorKonto['id'],
'haben_konto_id' => $habenId,
'betrag_netto' => round($netto, 2),
'ust_satz' => $satz,
'buchungstext' => 'Ausgangsrechnung ' . $invoice['rechnungs_nr'],
]);
}
// Link beleg to invoice
\Steuerkram\Database::execute(
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]
);
\Steuerkram\Database::commit();
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gebucht (Beleg ' . $belegNr . ').');
} catch (\Exception $e) {
\Steuerkram\Database::rollBack();
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
}
}
public function markPaid(array $params): void
{
Invoice::markPaid((int)$params['id']);
View::redirect('/invoices/' . $params['id'], 'Rechnung als bezahlt markiert.');
}
public function cancel(array $params): void
{
Invoice::cancel((int)$params['id']);
View::redirect('/invoices/' . $params['id'], 'Rechnung storniert.');
}
public function addLine(array $params): void
{
// This is handled client-side via JS; redirect back
View::redirect('/invoices/' . $params['id'] . '/edit');
}
public function deleteLine(array $params): void
{
\Steuerkram\Database::execute(
'DELETE FROM invoice_lines WHERE id=? AND invoice_id=?',
[(int)$params['line_id'], (int)$params['id']]
);
View::redirect('/invoices/' . $params['id'] . '/edit', 'Position gelöscht.');
}
private function validate(array $data): array
{
$errors = [];
if (empty($data['customer_id'])) $errors['customer_id'] = 'Pflichtfeld';
if (empty($data['betreff'])) $errors['betreff'] = 'Pflichtfeld';
if (empty($data['rechnungs_datum'])) $errors['rechnungs_datum'] = 'Pflichtfeld';
if (empty($data['lines'])) {
$errors['lines'] = 'Mindestens eine Position erforderlich.';
}
return $errors;
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Setting;
use Steuerkram\Models\Account;
use Steuerkram\View;
class SettingsController
{
public function show(array $params): void
{
$settings = Setting::get();
$filter = $_GET;
$accounts = Account::all($filter);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Einstellungen',
'content' => $this->renderView('settings/form', [
'settings' => $settings,
'errors' => [],
'accounts' => $accounts,
'filter' => $filter,
]),
]);
}
public function update(array $params): void
{
$data = $_POST;
$errors = [];
if (empty($data['firma'])) $errors['firma'] = 'Pflichtfeld';
if (empty($data['strasse'])) $errors['strasse'] = 'Pflichtfeld';
if (empty($data['plz'])) $errors['plz'] = 'Pflichtfeld';
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Einstellungen',
'content' => $this->renderView('settings/form', [
'settings' => $data,
'errors' => $errors,
]),
]);
return;
}
Setting::update($data);
View::redirect('/settings', 'Einstellungen gespeichert.');
}
public function uploadLogo(array $params): void
{
if (empty($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
View::redirect('/settings', 'Kein gültiges Bild übermittelt.', 'error');
return;
}
$file = $_FILES['logo'];
$mime = mime_content_type($file['tmp_name']);
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/svg+xml'])) {
View::redirect('/settings', 'Logo muss PNG, JPEG oder SVG sein.', 'error');
return;
}
$ext = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/svg+xml' => 'svg'][$mime];
$relPath = 'static/uploads/logo/logo.' . $ext;
$absDir = dirname(__DIR__, 2) . '/public/static/uploads/logo';
if (!is_dir($absDir)) mkdir($absDir, 0755, true);
move_uploaded_file($file['tmp_name'], dirname(__DIR__, 2) . '/public/' . $relPath);
Setting::setLogo($relPath);
View::redirect('/settings', 'Logo hochgeladen.');
}
public function deleteLogo(array $params): void
{
Setting::deleteLogo();
View::redirect('/settings', 'Logo gelöscht.');
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

View file

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Setting;
use Steuerkram\Database;
use Steuerkram\Services\UstvaCalculator;
use Steuerkram\Services\ElsterXmlService;
use Steuerkram\View;
class UstvaController
{
public function index(array $params): void
{
$submissions = Database::fetchAll(
'SELECT * FROM ustva_submissions ORDER BY year DESC, month DESC'
);
$years = [];
for ($y = (int)date('Y'); $y >= 2020; $y--) $years[] = $y;
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Umsatzsteuervoranmeldung',
'content' => $this->renderView('tax/ustva_index', [
'submissions' => $submissions,
'years' => $years,
]),
]);
}
public function show(array $params): void
{
$year = (int)$params['year'];
$month = (int)$params['month'];
if ($month < 1 || $month > 12) {
View::redirect('/tax/ustva', 'Ungültiger Monat.', 'error');
return;
}
$calc = new UstvaCalculator();
$kz = $calc->calculate($year, $month);
// Load saved submission if exists
$saved = Database::fetchOne(
'SELECT * FROM ustva_submissions WHERE year=? AND month=?', [$year, $month]
);
$settings = Setting::get();
$months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => "UStVA {$months[$month-1]} {$year}",
'content' => $this->renderView('tax/ustva_show', [
'year' => $year,
'month' => $month,
'kz' => $kz,
'saved' => $saved,
'settings' => $settings,
'months' => $months,
]),
'topbarActions' => '<a href="/tax/ustva/' . $year . '/' . $month . '/xml" class="btn btn-secondary">ELSTER XML herunterladen</a>',
]);
}
public function save(array $params): void
{
$year = (int)$params['year'];
$month = (int)$params['month'];
$calc = new UstvaCalculator();
$kz = $calc->calculate($year, $month);
$xmlSvc = new ElsterXmlService();
$xml = $xmlSvc->generate($year, $month, $kz);
// Upsert
$existing = Database::fetchOne(
'SELECT id FROM ustva_submissions WHERE year=? AND month=?', [$year, $month]
);
if ($existing) {
Database::execute(
'UPDATE ustva_submissions SET xml_content=?, field_values=? WHERE year=? AND month=?',
[$xml, json_encode($kz, JSON_UNESCAPED_UNICODE), $year, $month]
);
} else {
Database::execute(
'INSERT INTO ustva_submissions (year, month, xml_content, field_values) VALUES (?,?,?,?)',
[$year, $month, $xml, json_encode($kz, JSON_UNESCAPED_UNICODE)]
);
}
View::redirect("/tax/ustva/{$year}/{$month}", 'UStVA gespeichert.');
}
public function downloadXml(array $params): void
{
$year = (int)$params['year'];
$month = (int)$params['month'];
// Recalculate fresh
$calc = new UstvaCalculator();
$kz = $calc->calculate($year, $month);
$xmlSvc = new ElsterXmlService();
$xml = $xmlSvc->generate($year, $month, $kz);
$filename = sprintf('UStVA_%04d_%02d.xml', $year, $month);
header('Content-Type: application/xml; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($xml));
echo $xml;
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

77
src/Database.php Normal file
View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Steuerkram;
use PDO;
use PDOException;
class Database
{
private static ?PDO $instance = null;
public static function get(): PDO
{
if (self::$instance === null) {
$host = Config::get('db_host');
$name = Config::get('db_name');
$user = Config::get('db_user');
$pass = Config::get('db_pass');
$dsn = "mysql:host={$host};dbname={$name};charset=utf8mb4";
self::$instance = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$instance;
}
// Convenience wrappers
public static function query(string $sql, array $params = []): \PDOStatement
{
$stmt = self::get()->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public static function fetchAll(string $sql, array $params = []): array
{
return self::query($sql, $params)->fetchAll();
}
public static function fetchOne(string $sql, array $params = []): array|false
{
return self::query($sql, $params)->fetch();
}
public static function execute(string $sql, array $params = []): int
{
$stmt = self::query($sql, $params);
return $stmt->rowCount();
}
public static function lastInsertId(): string
{
return self::get()->lastInsertId();
}
public static function beginTransaction(): void
{
self::get()->beginTransaction();
}
public static function commit(): void
{
self::get()->commit();
}
public static function rollBack(): void
{
self::get()->rollBack();
}
}

112
src/Models/Account.php Normal file
View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class Account
{
public static function all(array $filter = []): array
{
$where = ['1=1'];
$params = [];
if (!empty($filter['search'])) {
$where[] = '(kontonummer LIKE ? OR bezeichnung LIKE ?)';
$params[] = '%' . $filter['search'] . '%';
$params[] = '%' . $filter['search'] . '%';
}
if (!empty($filter['kontenart'])) {
$where[] = 'kontenart = ?';
$params[] = $filter['kontenart'];
}
if (isset($filter['aktiv'])) {
$where[] = 'aktiv = ?';
$params[] = (int)$filter['aktiv'];
}
$sql = 'SELECT * FROM accounts WHERE ' . implode(' AND ', $where) . ' ORDER BY kontonummer';
return Database::fetchAll($sql, $params);
}
public static function find(int $id): array|false
{
return Database::fetchOne('SELECT * FROM accounts WHERE id = ?', [$id]);
}
public static function findByKontonummer(string $nr): array|false
{
return Database::fetchOne('SELECT * FROM accounts WHERE kontonummer = ?', [$nr]);
}
public static function search(string $q, int $limit = 20): array
{
return Database::fetchAll(
'SELECT id, kontonummer, bezeichnung, kontenart FROM accounts
WHERE aktiv = 1 AND (kontonummer LIKE ? OR bezeichnung LIKE ?)
ORDER BY kontonummer LIMIT ?',
['%' . $q . '%', '%' . $q . '%', $limit]
);
}
public static function create(array $data): int
{
Database::execute(
'INSERT INTO accounts (kontonummer, bezeichnung, kontenart, beschreibung, steuercode, ustva_kz)
VALUES (?, ?, ?, ?, ?, ?)',
[
$data['kontonummer'],
$data['bezeichnung'],
$data['kontenart'],
$data['beschreibung'] ?? null,
$data['steuercode'] ?? null,
$data['ustva_kz'] ?? null,
]
);
return (int)Database::lastInsertId();
}
public static function update(int $id, array $data): void
{
Database::execute(
'UPDATE accounts SET kontonummer=?, bezeichnung=?, kontenart=?, beschreibung=?, steuercode=?, ustva_kz=?, aktiv=?
WHERE id=?',
[
$data['kontonummer'],
$data['bezeichnung'],
$data['kontenart'],
$data['beschreibung'] ?? null,
$data['steuercode'] ?? null,
$data['ustva_kz'] ?? null,
(int)($data['aktiv'] ?? 1),
$id,
]
);
}
public static function delete(int $id): bool
{
// Check if any bookings reference this account
$used = Database::fetchOne(
'SELECT COUNT(*) as cnt FROM bookings WHERE soll_konto_id = ? OR haben_konto_id = ?',
[$id, $id]
);
if ($used && $used['cnt'] > 0) {
return false; // cannot delete
}
Database::execute('DELETE FROM accounts WHERE id = ? AND is_system = 0', [$id]);
return true;
}
public static function isKontonummerTaken(string $nr, ?int $excludeId = null): bool
{
if ($excludeId) {
$row = Database::fetchOne('SELECT id FROM accounts WHERE kontonummer = ? AND id != ?', [$nr, $excludeId]);
} else {
$row = Database::fetchOne('SELECT id FROM accounts WHERE kontonummer = ?', [$nr]);
}
return (bool)$row;
}
}

218
src/Models/Beleg.php Normal file
View file

@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class Beleg
{
public static function all(array $filter = [], int $page = 1, int $perPage = 30): array
{
$where = ['1=1'];
$params = [];
if (!empty($filter['search'])) {
$where[] = '(b.beleg_nr LIKE ? OR b.filename LIKE ? OR b.rechnungs_nr LIKE ? OR b.notes LIKE ?)';
$s = '%' . $filter['search'] . '%';
$params = array_merge($params, [$s, $s, $s, $s]);
}
if (!empty($filter['year'])) {
$where[] = 'YEAR(b.beleg_datum) = ?';
$params[] = (int)$filter['year'];
}
if (!empty($filter['month'])) {
$where[] = 'MONTH(b.beleg_datum) = ?';
$params[] = (int)$filter['month'];
}
if (isset($filter['booked']) && $filter['booked'] !== '') {
$where[] = 'b.booked = ?';
$params[] = (int)$filter['booked'];
}
if (!empty($filter['belegtyp'])) {
$where[] = 'b.belegtyp = ?';
$params[] = $filter['belegtyp'];
}
$offset = ($page - 1) * $perPage;
$sql = 'SELECT b.*,
COALESCE(b.betrag_brutto,
(SELECT SUM(bk.betrag_brutto) FROM bookings bk
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0)
) AS betrag_brutto,
fy.year as fiscal_year_nr
FROM belege b
LEFT JOIN fiscal_years fy ON b.fiscal_year_id = fy.id
WHERE ' . implode(' AND ', $where) . '
ORDER BY b.eingangs_datum DESC, b.id DESC
LIMIT ? OFFSET ?';
$params[] = $perPage;
$params[] = $offset;
$rows = Database::fetchAll($sql, $params);
// Count total
$countSql = 'SELECT COUNT(*) as cnt FROM belege b WHERE ' . implode(' AND ', $where);
$countParams = array_slice($params, 0, -2);
$total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0);
return ['rows' => $rows, 'total' => $total, 'pages' => (int)ceil($total / $perPage)];
}
public static function find(int $id): array|false
{
return Database::fetchOne(
'SELECT b.*,
COALESCE(b.betrag_brutto,
(SELECT SUM(bk.betrag_brutto) FROM bookings bk
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0)
) AS betrag_brutto,
fy.year as fiscal_year_nr
FROM belege b
LEFT JOIN fiscal_years fy ON b.fiscal_year_id = fy.id
WHERE b.id = ?',
[$id]
);
}
public static function create(array $data): int
{
// Auto-assign fiscal year
$fyId = null;
if (!empty($data['beleg_datum'])) {
$year = (int)date('Y', strtotime($data['beleg_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
Database::execute(
'INSERT INTO belege (beleg_nr, filename, storage_path, mime_type, file_hash,
beleg_datum, eingangs_datum, rechnungs_nr, betrag_brutto, belegtyp, notes, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$data['beleg_nr'],
$data['filename'],
$data['storage_path'],
$data['mime_type'],
$data['file_hash'],
$data['beleg_datum'] ?: null,
$data['eingangs_datum'],
$data['rechnungs_nr'] ?? null,
$data['betrag_brutto'] ?? null,
$data['belegtyp'] ?? 'Eingangsrechnung',
$data['notes'] ?? null,
$fyId,
]
);
return (int)Database::lastInsertId();
}
public static function update(int $id, array $data): void
{
$beleg = self::find($id);
if ($beleg && $beleg['locked']) {
throw new \RuntimeException('GoBD: Gesperrter Beleg kann nicht geändert werden.');
}
// Update fiscal year if date changed
$fyId = null;
if (!empty($data['beleg_datum'])) {
$year = (int)date('Y', strtotime($data['beleg_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
Database::execute(
'UPDATE belege SET beleg_datum=?, rechnungs_nr=?, betrag_brutto=?, belegtyp=?, notes=?,
booked=?, fiscal_year_id=?, updated_at=NOW()
WHERE id=? AND locked=0',
[
$data['beleg_datum'] ?: null,
$data['rechnungs_nr'] ?? null,
$data['betrag_brutto'] ?? null,
$data['belegtyp'] ?? 'Eingangsrechnung',
$data['notes'] ?? null,
(int)($data['booked'] ?? 0),
$fyId,
$id,
]
);
}
public static function updateMeta(int $id, array $data): void
{
$fyId = null;
if (!empty($data['beleg_datum'])) {
$year = (int)date('Y', strtotime($data['beleg_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
$fyId = $fy['id'];
}
Database::execute(
'UPDATE belege SET
rechnungs_nr = ?,
belegtyp = ?,
beleg_datum = ?,
betrag_brutto = ?,
fiscal_year_id = COALESCE(?, fiscal_year_id),
updated_at = NOW()
WHERE id = ? AND locked = 0',
[
$data['rechnungs_nr'] ?: null,
$data['belegtyp'],
$data['beleg_datum'] ?: null,
$data['betrag_brutto'] !== null && $data['betrag_brutto'] !== '' ? (float)$data['betrag_brutto'] : null,
$fyId,
$id,
]
);
}
public static function markBooked(int $id, bool $booked): void
{
Database::execute('UPDATE belege SET booked=?, updated_at=NOW() WHERE id=?', [(int)$booked, $id]);
}
public static function delete(int $id): bool
{
$beleg = self::find($id);
if (!$beleg) return false;
if ($beleg['locked']) return false;
// Check no bookings exist
$cnt = Database::fetchOne('SELECT COUNT(*) as cnt FROM bookings WHERE beleg_id=?', [$id]);
if ($cnt && $cnt['cnt'] > 0) return false;
// Delete file
$fullPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
if (file_exists($fullPath)) unlink($fullPath);
Database::execute('DELETE FROM belege WHERE id=?', [$id]);
return true;
}
public static function generateBelegNr(): string
{
$settings = Database::fetchOne('SELECT beleg_nr_prefix, beleg_nr_next FROM company_settings WHERE id=1');
$prefix = $settings['beleg_nr_prefix'] ?? 'BLG';
$next = (int)($settings['beleg_nr_next'] ?? 1);
$year = date('Y');
$nr = sprintf('%s-%s-%04d', $prefix, $year, $next);
Database::execute('UPDATE company_settings SET beleg_nr_next = beleg_nr_next + 1 WHERE id=1');
return $nr;
}
public static function verifyIntegrity(int $id): bool
{
$beleg = self::find($id);
if (!$beleg) return false;
$fullPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
if (!file_exists($fullPath)) return false;
return hash_file('sha256', $fullPath) === $beleg['file_hash'];
}
}

200
src/Models/Booking.php Normal file
View file

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class Booking
{
public static function forBeleg(int $belegId): array
{
return Database::fetchAll(
'SELECT bk.*,
s.kontonummer as soll_nr, s.bezeichnung as soll_name,
h.kontonummer as haben_nr, h.bezeichnung as haben_name
FROM bookings bk
JOIN accounts s ON bk.soll_konto_id = s.id
JOIN accounts h ON bk.haben_konto_id = h.id
WHERE bk.beleg_id = ?
ORDER BY bk.buchungs_datum, bk.id',
[$belegId]
);
}
public static function journal(array $filter = [], int $page = 1, int $perPage = 50): array
{
$where = ['1=1'];
$params = [];
if (!empty($filter['year'])) {
$where[] = 'fy.year = ?';
$params[] = (int)$filter['year'];
}
if (!empty($filter['month'])) {
$where[] = 'MONTH(bk.buchungs_datum) = ?';
$params[] = (int)$filter['month'];
}
if (!empty($filter['konto'])) {
$where[] = '(bk.soll_konto_id = ? OR bk.haben_konto_id = ?)';
$params[] = (int)$filter['konto'];
$params[] = (int)$filter['konto'];
}
if (!empty($filter['search'])) {
$where[] = 'bk.buchungstext LIKE ?';
$params[] = '%' . $filter['search'] . '%';
}
$offset = ($page - 1) * $perPage;
$sql = 'SELECT bk.*,
s.kontonummer as soll_nr, s.bezeichnung as soll_name,
h.kontonummer as haben_nr, h.bezeichnung as haben_name,
bl.beleg_nr, fy.year as fiscal_year_nr
FROM bookings bk
JOIN accounts s ON bk.soll_konto_id = s.id
JOIN accounts h ON bk.haben_konto_id = h.id
JOIN belege bl ON bk.beleg_id = bl.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE ' . implode(' AND ', $where) . '
ORDER BY bk.buchungs_datum DESC, bk.id DESC
LIMIT ? OFFSET ?';
$params[] = $perPage;
$params[] = $offset;
$rows = Database::fetchAll($sql, $params);
$countSql = 'SELECT COUNT(*) as cnt FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE ' . implode(' AND ', $where);
$countParams = array_slice($params, 0, -2);
$total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0);
return ['rows' => $rows, 'total' => $total, 'pages' => (int)ceil($total / $perPage)];
}
public static function find(int $id): array|false
{
return Database::fetchOne('SELECT * FROM bookings WHERE id = ?', [$id]);
}
public static function create(array $data): int
{
// Determine fiscal year
$year = (int)date('Y', strtotime($data['buchungs_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
if ($fy['locked']) {
throw new \RuntimeException("Geschäftsjahr {$year} ist gesperrt. Neue Buchungen nicht möglich.");
}
// Calculate amounts
$netto = round((float)$data['betrag_netto'], 2);
$ust = round((float)($data['ust_satz'] ?? 0) / 100 * $netto, 2);
$brutto = round($netto + $ust, 2);
$nr = self::generateBuchungsNr();
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, soll_konto_id, haben_konto_id,
betrag_netto, ust_satz, betrag_ust, betrag_brutto, buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$nr,
(int)$data['beleg_id'],
$data['buchungs_datum'],
(int)$data['soll_konto_id'],
(int)$data['haben_konto_id'],
$netto,
(float)($data['ust_satz'] ?? 0),
$ust,
$brutto,
$data['buchungstext'],
$fy['id'],
]
);
$id = (int)Database::lastInsertId();
// Mark Beleg as booked
Database::execute('UPDATE belege SET booked=1, updated_at=NOW() WHERE id=?', [(int)$data['beleg_id']]);
return $id;
}
public static function reverse(int $id, string $reason): int
{
$orig = self::find($id);
if (!$orig) throw new \RuntimeException('Buchung nicht gefunden.');
if ($orig['is_reversal']) throw new \RuntimeException('Stornobuchungen können nicht storniert werden.');
// Create reversal: swap Soll/Haben, same amounts
$reversalData = [
'beleg_id' => $orig['beleg_id'],
'buchungs_datum' => date('Y-m-d'),
'soll_konto_id' => $orig['haben_konto_id'],
'haben_konto_id' => $orig['soll_konto_id'],
'betrag_netto' => $orig['betrag_netto'],
'ust_satz' => $orig['ust_satz'],
'buchungstext' => 'STORNO zu ' . $orig['buchungs_nr'] . ': ' . $reason,
];
$year = (int)date('Y', strtotime($reversalData['buchungs_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
$nr = self::generateBuchungsNr();
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, soll_konto_id, haben_konto_id,
betrag_netto, ust_satz, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
[
$nr,
$reversalData['beleg_id'],
$reversalData['buchungs_datum'],
$reversalData['soll_konto_id'],
$reversalData['haben_konto_id'],
$orig['betrag_netto'],
$orig['ust_satz'],
$orig['betrag_ust'],
$orig['betrag_brutto'],
$reversalData['buchungstext'],
$fy['id'],
]
);
$reversalId = (int)Database::lastInsertId();
// Link original to reversal
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]);
return $reversalId;
}
public static function generateBuchungsNr(): string
{
$year = date('Y');
$result = Database::fetchOne(
'SELECT COUNT(*) as cnt FROM bookings WHERE YEAR(buchungs_datum) = ?',
[$year]
);
$next = (int)($result['cnt'] ?? 0) + 1;
return sprintf('BU-%s-%06d', $year, $next);
}
public static function sumByYear(int $year): array
{
return Database::fetchAll(
'SELECT bk.soll_konto_id, bk.haben_konto_id,
SUM(bk.betrag_netto) as sum_netto,
SUM(bk.betrag_ust) as sum_ust,
SUM(bk.betrag_brutto) as sum_brutto
FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ?
GROUP BY bk.soll_konto_id, bk.haben_konto_id',
[$year]
);
}
}

115
src/Models/Customer.php Normal file
View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class Customer
{
public static function all(array $filter = []): array
{
$where = ['1=1'];
$params = [];
$archived = $filter['archived'] ?? 0;
$where[] = 'archived = ?';
$params[] = (int)$archived;
if (!empty($filter['search'])) {
$where[] = '(kundennummer LIKE ? OR firma LIKE ? OR nachname LIKE ? OR email LIKE ?)';
$s = '%' . $filter['search'] . '%';
$params = array_merge($params, [$s, $s, $s, $s]);
}
return Database::fetchAll(
'SELECT * FROM customers WHERE ' . implode(' AND ', $where) . ' ORDER BY kundennummer',
$params
);
}
public static function find(int $id): array|false
{
return Database::fetchOne('SELECT * FROM customers WHERE id = ?', [$id]);
}
public static function create(array $data): int
{
$nr = self::generateKundennummer();
Database::execute(
'INSERT INTO customers (kundennummer, firma, anrede, vorname, nachname, strasse, plz, ort, land,
email, telefon, ust_id, steuernummer, payment_days, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$data['kundennummer'] ?? $nr,
$data['firma'] ?? null,
$data['anrede'] ?? null,
$data['vorname'] ?? null,
$data['nachname'],
$data['strasse'],
$data['plz'],
$data['ort'],
$data['land'] ?? 'DE',
$data['email'] ?? null,
$data['telefon'] ?? null,
$data['ust_id'] ?? null,
$data['steuernummer'] ?? null,
(int)($data['payment_days'] ?? 14),
$data['notes'] ?? null,
]
);
return (int)Database::lastInsertId();
}
public static function update(int $id, array $data): void
{
Database::execute(
'UPDATE customers SET firma=?, anrede=?, vorname=?, nachname=?, strasse=?, plz=?, ort=?, land=?,
email=?, telefon=?, ust_id=?, steuernummer=?, payment_days=?, notes=?, updated_at=NOW()
WHERE id=?',
[
$data['firma'] ?? null,
$data['anrede'] ?? null,
$data['vorname'] ?? null,
$data['nachname'],
$data['strasse'],
$data['plz'],
$data['ort'],
$data['land'] ?? 'DE',
$data['email'] ?? null,
$data['telefon'] ?? null,
$data['ust_id'] ?? null,
$data['steuernummer'] ?? null,
(int)($data['payment_days'] ?? 14),
$data['notes'] ?? null,
$id,
]
);
}
public static function archive(int $id): void
{
Database::execute('UPDATE customers SET archived=1, updated_at=NOW() WHERE id=?', [$id]);
}
public static function displayName(array $customer): string
{
if (!empty($customer['firma'])) {
return $customer['firma'];
}
$parts = array_filter([
$customer['anrede'] ?? null,
$customer['vorname'] ?? null,
$customer['nachname'],
]);
return implode(' ', $parts);
}
public static function generateKundennummer(): string
{
$result = Database::fetchOne('SELECT MAX(CAST(SUBSTRING(kundennummer, 3) AS UNSIGNED)) as max_nr FROM customers WHERE kundennummer REGEXP \'^KD[0-9]+$\'');
$next = (int)($result['max_nr'] ?? 0) + 1;
return sprintf('KD%04d', $next);
}
}

51
src/Models/FiscalYear.php Normal file
View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class FiscalYear
{
public static function all(): array
{
return Database::fetchAll('SELECT * FROM fiscal_years ORDER BY year DESC');
}
public static function find(int $id): array|false
{
return Database::fetchOne('SELECT * FROM fiscal_years WHERE id = ?', [$id]);
}
public static function findByYear(int $year): array|false
{
return Database::fetchOne('SELECT * FROM fiscal_years WHERE year = ?', [$year]);
}
public static function findOrCreateByYear(int $year): array
{
$fy = self::findByYear($year);
if ($fy) return $fy;
Database::execute(
'INSERT INTO fiscal_years (year, start_date, end_date) VALUES (?, ?, ?)',
[$year, "{$year}-01-01", "{$year}-12-31"]
);
return self::findByYear($year);
}
public static function currentYear(): int
{
return (int)date('Y');
}
public static function availableYears(): array
{
$years = Database::fetchAll(
'SELECT DISTINCT YEAR(buchungs_datum) as year FROM bookings ORDER BY year DESC'
);
return array_column($years, 'year');
}
}

259
src/Models/Invoice.php Normal file
View file

@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class Invoice
{
public static function all(array $filter = [], int $page = 1, int $perPage = 30): array
{
$where = ['1=1'];
$params = [];
if (!empty($filter['status'])) {
$where[] = 'i.status = ?';
$params[] = $filter['status'];
}
if (!empty($filter['year'])) {
$where[] = 'YEAR(i.rechnungs_datum) = ?';
$params[] = (int)$filter['year'];
}
if (!empty($filter['customer_id'])) {
$where[] = 'i.customer_id = ?';
$params[] = (int)$filter['customer_id'];
}
if (!empty($filter['search'])) {
$where[] = '(i.rechnungs_nr LIKE ? OR i.betreff LIKE ? OR c.nachname LIKE ? OR c.firma LIKE ?)';
$s = '%' . $filter['search'] . '%';
$params = array_merge($params, [$s, $s, $s, $s]);
}
$offset = ($page - 1) * $perPage;
$sql = 'SELECT i.*, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer
FROM invoices i
JOIN customers c ON i.customer_id = c.id
WHERE ' . implode(' AND ', $where) . '
ORDER BY i.rechnungs_datum DESC, i.id DESC
LIMIT ? OFFSET ?';
$params[] = $perPage;
$params[] = $offset;
$rows = Database::fetchAll($sql, $params);
$countSql = 'SELECT COUNT(*) as cnt FROM invoices i JOIN customers c ON i.customer_id = c.id WHERE ' . implode(' AND ', $where);
$countParams = array_slice($params, 0, -2);
$total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0);
return ['rows' => $rows, 'total' => $total, 'pages' => (int)ceil($total / $perPage)];
}
public static function find(int $id): array|false
{
return Database::fetchOne(
'SELECT i.*, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer,
c.strasse, c.plz, c.ort, c.land, c.email, c.payment_days
FROM invoices i
JOIN customers c ON i.customer_id = c.id
WHERE i.id = ?',
[$id]
);
}
public static function lines(int $invoiceId): array
{
return Database::fetchAll(
'SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY position',
[$invoiceId]
);
}
public static function create(array $data): int
{
$customer = Customer::find((int)$data['customer_id']);
$payDays = (int)($data['payment_days'] ?? $customer['payment_days'] ?? 14);
$datum = $data['rechnungs_datum'] ?? date('Y-m-d');
$faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days"));
Database::execute(
'INSERT INTO invoices (customer_id, betreff, leistungszeitraum_von, leistungszeitraum_bis,
rechnungs_datum, faellig_datum, status, notes)
VALUES (?, ?, ?, ?, ?, ?, \'Entwurf\', ?)',
[
(int)$data['customer_id'],
$data['betreff'],
$data['leistungszeitraum_von'] ?: null,
$data['leistungszeitraum_bis'] ?: null,
$datum,
$faellig,
$data['notes'] ?? null,
]
);
$id = (int)Database::lastInsertId();
// Create lines
if (!empty($data['lines'])) {
self::saveLines($id, $data['lines']);
self::recalcTotals($id);
}
return $id;
}
public static function update(int $id, array $data): void
{
$inv = self::find($id);
if ($inv && $inv['status'] !== 'Entwurf') {
throw new \RuntimeException('Nur Entwürfe können bearbeitet werden.');
}
$customer = Customer::find((int)$data['customer_id']);
$payDays = (int)($data['payment_days'] ?? $customer['payment_days'] ?? 14);
$datum = $data['rechnungs_datum'];
$faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days"));
Database::execute(
'UPDATE invoices SET customer_id=?, betreff=?, leistungszeitraum_von=?, leistungszeitraum_bis=?,
rechnungs_datum=?, faellig_datum=?, notes=?, updated_at=NOW()
WHERE id=? AND status=\'Entwurf\'',
[
(int)$data['customer_id'],
$data['betreff'],
$data['leistungszeitraum_von'] ?: null,
$data['leistungszeitraum_bis'] ?: null,
$datum,
$faellig,
$data['notes'] ?? null,
$id,
]
);
// Replace lines
Database::execute('DELETE FROM invoice_lines WHERE invoice_id=?', [$id]);
if (!empty($data['lines'])) {
self::saveLines($id, $data['lines']);
}
self::recalcTotals($id);
}
public static function finalize(int $id): string
{
$inv = self::find($id);
if (!$inv) throw new \RuntimeException('Rechnung nicht gefunden.');
if ($inv['status'] !== 'Entwurf') throw new \RuntimeException('Rechnung ist kein Entwurf mehr.');
$nr = self::generateRechnungsNr();
// Set fiscal year
$year = (int)date('Y', strtotime($inv['rechnungs_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
Database::execute(
'UPDATE invoices SET rechnungs_nr=?, status=\'Gesendet\', fiscal_year_id=?, updated_at=NOW() WHERE id=?',
[$nr, $fy['id'], $id]
);
return $nr;
}
public static function markPaid(int $id): void
{
Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]);
}
public static function cancel(int $id): void
{
$invoice = self::find($id);
if (!$invoice) return;
// Stornobuchungen für alle gebuchten Buchungen des Belegs erzeugen
if (!empty($invoice['beleg_id'])) {
$bookings = Database::fetchAll(
'SELECT id FROM bookings WHERE beleg_id = ? AND is_reversal = 0 AND reversed_by IS NULL',
[(int)$invoice['beleg_id']]
);
foreach ($bookings as $bk) {
Booking::reverse((int)$bk['id'], 'Storno Rechnung ' . ($invoice['rechnungs_nr'] ?? $id));
}
}
Database::execute('UPDATE invoices SET status=\'Storniert\', updated_at=NOW() WHERE id=?', [$id]);
}
private static function saveLines(int $invoiceId, array $lines): void
{
foreach ($lines as $i => $line) {
if (empty($line['bezeichnung'])) continue;
$menge = round((float)$line['menge'], 3);
$preis = round((float)$line['einzelpreis'], 2);
$ustSatz = round((float)$line['ust_satz'], 2);
$netto = round($menge * $preis, 2);
$ust = round($netto * $ustSatz / 100, 2);
$brutto = round($netto + $ust, 2);
Database::execute(
'INSERT INTO invoice_lines (invoice_id, position, bezeichnung, menge, einheit,
einzelpreis, ust_satz, betrag_netto, betrag_ust, betrag_brutto)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$invoiceId,
$i + 1,
$line['bezeichnung'],
$menge,
$line['einheit'] ?? 'Stk.',
$preis,
$ustSatz,
$netto,
$ust,
$brutto,
]
);
}
}
private static function recalcTotals(int $id): void
{
$result = Database::fetchOne(
'SELECT SUM(betrag_netto) as netto, SUM(betrag_ust) as ust, SUM(betrag_brutto) as brutto
FROM invoice_lines WHERE invoice_id = ?',
[$id]
);
Database::execute(
'UPDATE invoices SET betrag_netto=?, betrag_ust=?, betrag_brutto=?, updated_at=NOW() WHERE id=?',
[
round((float)$result['netto'], 2),
round((float)$result['ust'], 2),
round((float)$result['brutto'], 2),
$id,
]
);
}
public static function generateRechnungsNr(): string
{
$settings = Database::fetchOne(
'SELECT invoice_nr_prefix, invoice_nr_next, invoice_nr_year FROM company_settings WHERE id=1'
);
$prefix = $settings['invoice_nr_prefix'] ?? 'AR';
$next = (int)($settings['invoice_nr_next'] ?? 1);
$savedYear = (int)($settings['invoice_nr_year'] ?? 0);
$year = (int)date('Y');
// Reset counter on new year
if ($savedYear !== $year) {
$next = 1;
}
$nr = sprintf('%s-%s-%04d', $prefix, $year, $next);
Database::execute(
'UPDATE company_settings SET invoice_nr_next=?, invoice_nr_year=? WHERE id=1',
[$next + 1, $year]
);
return $nr;
}
}

73
src/Models/Setting.php Normal file
View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class Setting
{
public static function get(): array
{
return Database::fetchOne('SELECT * FROM company_settings WHERE id = 1') ?: [];
}
public static function isTestMode(): bool
{
$s = self::get();
return (bool)($s['test_mode'] ?? 1);
}
public static function update(array $data): void
{
Database::execute(
'UPDATE company_settings SET
firma=?, inhaber=?, strasse=?, plz=?, ort=?,
telefon=?, email=?, website=?,
ust_id=?, steuernummer=?, finanzamt=?,
iban=?, bic=?, bank_name=?,
ustva_steuernummer=?, ustva_bundesland=?,
invoice_nr_prefix=?, beleg_nr_prefix=?,
payment_days_default=?, test_mode=?
WHERE id=1',
[
$data['firma'] ?? '',
$data['inhaber'] ?? '',
$data['strasse'] ?? '',
$data['plz'] ?? '',
$data['ort'] ?? '',
$data['telefon'] ?? null,
$data['email'] ?? null,
$data['website'] ?? null,
$data['ust_id'] ?? null,
$data['steuernummer'] ?? null,
$data['finanzamt'] ?? null,
$data['iban'] ?? null,
$data['bic'] ?? null,
$data['bank_name'] ?? null,
$data['ustva_steuernummer'] ?? null,
$data['ustva_bundesland'] ?? null,
$data['invoice_nr_prefix'] ?? 'AR',
$data['beleg_nr_prefix'] ?? 'BLG',
(int)($data['payment_days_default'] ?? 14),
isset($data['test_mode']) ? 1 : 0,
]
);
}
public static function setLogo(string $path): void
{
Database::execute('UPDATE company_settings SET logo_path=? WHERE id=1', [$path]);
}
public static function deleteLogo(): void
{
$settings = self::get();
if (!empty($settings['logo_path'])) {
$fullPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path'];
if (file_exists($fullPath)) unlink($fullPath);
}
Database::execute('UPDATE company_settings SET logo_path=NULL WHERE id=1');
}
}

82
src/Router.php Normal file
View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Steuerkram;
class Router
{
private array $routes = [];
public function add(string $method, string $pattern, callable $handler): void
{
$this->routes[] = [
'method' => strtoupper($method),
'pattern' => $pattern,
'handler' => $handler,
];
}
public function get(string $pattern, callable $handler): void
{
$this->add('GET', $pattern, $handler);
}
public function post(string $pattern, callable $handler): void
{
$this->add('POST', $pattern, $handler);
}
public function put(string $pattern, callable $handler): void
{
$this->add('PUT', $pattern, $handler);
}
public function delete(string $pattern, callable $handler): void
{
$this->add('DELETE', $pattern, $handler);
}
public function dispatch(): void
{
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
// Support method override via hidden _method field or X-HTTP-Method-Override header
if ($method === 'POST') {
$override = $_POST['_method'] ?? $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? null;
if ($override) {
$method = strtoupper($override);
}
}
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$uri = '/' . trim($uri, '/');
if ($uri === '') $uri = '/';
foreach ($this->routes as $route) {
if ($route['method'] !== $method) continue;
$params = $this->match($route['pattern'], $uri);
if ($params !== null) {
($route['handler'])($params);
return;
}
}
http_response_code(404);
$view = new View();
$view->render('layout/404.php', ['uri' => $uri]);
}
private function match(string $pattern, string $uri): ?array
{
// Convert route pattern to regex: {id} → named capture
$regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern);
$regex = '#^' . $regex . '$#';
if (preg_match($regex, $uri, $matches)) {
return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
}
return null;
}
}

View file

@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Models\Setting;
/**
* Erzeugt eine ELSTER-konforme XML-Datei für die UStVA.
* Kompatibel mit dem Elster Online Portal (Dateiupload, keine ERiC-Signatur nötig).
* Schema: ElsterOnline v11
*/
class ElsterXmlService
{
public function generate(int $year, int $month, array $kz): string
{
$settings = Setting::get();
$steuernummer = preg_replace('/[^0-9]/', '', $settings['ustva_steuernummer'] ?? '');
$bundesland = strtoupper($settings['ustva_bundesland'] ?? 'DE');
$erstelltAm = date('Y-m-d\TH:i:s');
$zeitraum = sprintf('%04d%02d', $year, $month);
$firma = htmlspecialchars($settings['firma'] ?? '', ENT_XML1);
$email = htmlspecialchars($settings['email'] ?? '', ENT_XML1);
// Build Kz fields XML
$kzXml = '';
$kzMap = [
'09' => 'Kz09', // Steuernummer (wird im Header gesetzt)
'10' => 'Kz10', // Voranmeldungszeitraum
'22' => 'Kz22', // Berichtigungsvoranmeldung (0=nein)
'81' => 'Kz81',
'86' => 'Kz86',
'35' => 'Kz35',
'41' => 'Kz41',
'44' => 'Kz44',
'83' => 'Kz83',
'84' => 'Kz84',
'66' => 'Kz66',
'61' => 'Kz61',
'62' => 'Kz62',
'69' => 'Kz69',
];
// Helper: format amount for ELSTER (integer cents, no decimals for Beträge; or in EUR with 2 dec)
$fmtBetrag = fn($v) => $v !== null ? number_format((float)$v, 2, '.', '') : '0.00';
$fmtSteuer = fn($v) => $v !== null ? number_format((float)$v, 2, '.', '') : '0.00';
$fields = [];
// Steuernummer und Voranmeldungszeitraum
$fields['09'] = $steuernummer;
$fields['10'] = $zeitraum;
$fields['22'] = '0'; // Keine Berichtigungsvoranmeldung
foreach ($kz as $nr => $data) {
if ($data['betrag'] !== null && $data['betrag'] != 0) {
$fields[$nr . '_betrag'] = $fmtBetrag($data['betrag']);
}
if ($data['steuer'] !== null && $data['steuer'] != 0) {
$fields[$nr . '_steuer'] = $fmtSteuer($data['steuer']);
}
}
// Build XML
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$elster = $dom->createElementNS('http://www.elster.de/elsterxml/schema/v11', 'Elster');
$dom->appendChild($elster);
// TransferHeader
$th = $dom->createElement('TransferHeader');
$th->appendChild($dom->createElement('Version', '11'));
$th->appendChild($dom->createElement('TransferTicket', ''));
$th->appendChild($dom->createElement('Testmerker', '700000004'));
$th->appendChild($dom->createElement('HerstellerID', '74931'));
$th->appendChild($dom->createElement('DatenArt', 'UStVA'));
$th->appendChild($dom->createElement('Vorgang', 'send-Auth'));
$elster->appendChild($th);
// DatenTeil
$dt = $dom->createElement('DatenTeil');
$elster->appendChild($dt);
$nb = $dom->createElement('Nutzdatenblock');
$dt->appendChild($nb);
// NutzdatenHeader
$ndh = $dom->createElement('NutzdatenHeader');
$ndh->setAttribute('version', '11');
$ndh->appendChild($dom->createElement('NutzdatenTicket', '1'));
$empf = $dom->createElement('Empfaenger');
$empf->setAttribute('id', 'F');
$empf->nodeValue = $this->getFinanzamtCode($bundesland);
$ndh->appendChild($empf);
$hersteller = $dom->createElement('Hersteller');
$hersteller->appendChild($dom->createElement('ProduktName', 'Steuerkram'));
$hersteller->appendChild($dom->createElement('ProduktVersion', '1.0'));
$ndh->appendChild($hersteller);
$nb->appendChild($ndh);
// Nutzdaten
$nd = $dom->createElement('Nutzdaten');
$nb->appendChild($nd);
// Anmeldungssteuern
$anm = $dom->createElement('Anmeldungssteuern');
$anm->setAttribute('art', 'UStVA');
$anm->setAttribute('version', sprintf('%04d%02d', $year, $month));
$nd->appendChild($anm);
// DatenLieferant
$dl = $dom->createElement('DatenLieferant');
$dl->appendChild($dom->createElement('Name', $firma));
$dl->appendChild($dom->createElement('Strasse', htmlspecialchars($settings['strasse'] ?? '', ENT_XML1)));
$dl->appendChild($dom->createElement('PLZ', htmlspecialchars($settings['plz'] ?? '', ENT_XML1)));
$dl->appendChild($dom->createElement('Ort', htmlspecialchars($settings['ort'] ?? '', ENT_XML1)));
if ($email) $dl->appendChild($dom->createElement('Email', $email));
$anm->appendChild($dl);
// Erstellungsdatum
$anm->appendChild($dom->createElement('Erstellungsdatum', date('Ymd')));
// Steuerfall
$sf = $dom->createElement('Steuerfall');
$anm->appendChild($sf);
$ustva = $dom->createElement('Umsatzsteuervoranmeldung');
$sf->appendChild($ustva);
// Jahr, Zeitraum
$ustva->appendChild($dom->createElement('Jahr', (string)$year));
$ustva->appendChild($dom->createElement('Zeitraum', sprintf('%02d', $month)));
// Steuernummer
$ustva->appendChild($dom->createElement('Steuernummer', $steuernummer));
// Kz-Felder
// Kz81 (Umsatz 19%)
if (!empty($kz['81']['betrag'])) {
$kzEl = $dom->createElement('Kz81', (string)round($kz['81']['betrag']));
$ustva->appendChild($kzEl);
}
// Kz86 (Umsatz 7%)
if (!empty($kz['86']['betrag'])) {
$kzEl = $dom->createElement('Kz86', (string)round($kz['86']['betrag']));
$ustva->appendChild($kzEl);
}
// Kz35 (steuerfreie Umsätze)
if (!empty($kz['35']['betrag'])) {
$kzEl = $dom->createElement('Kz35', (string)round($kz['35']['betrag']));
$ustva->appendChild($kzEl);
}
// Kz83 (USt 19%)
$kzEl = $dom->createElement('Kz83', (string)(round($kz['83']['steuer'] * 100)));
$ustva->appendChild($kzEl);
// Kz84 (USt 7%)
$kzEl = $dom->createElement('Kz84', (string)(round($kz['84']['steuer'] * 100)));
$ustva->appendChild($kzEl);
// Kz66 (Vorsteuer gesamt)
if (!empty($kz['66']['steuer'])) {
$kzEl = $dom->createElement('Kz66', (string)(round($kz['66']['steuer'] * 100)));
$ustva->appendChild($kzEl);
}
// Kz69 (Vorauszahlung / Überschuss)
$kzEl = $dom->createElement('Kz69', (string)(round($kz['69']['steuer'] * 100)));
$ustva->appendChild($kzEl);
return $dom->saveXML();
}
private function getFinanzamtCode(string $bundesland): string
{
// Standard-Finanzamt-Code pro Bundesland für ELSTER
$codes = [
'BW' => '2800', 'BY' => '9200', 'BE' => '1100', 'BB' => '3000',
'HB' => '2400', 'HH' => '2200', 'HE' => '2600', 'MV' => '4000',
'NI' => '2300', 'NW' => '5100', 'RP' => '2700', 'SL' => '1001',
'SN' => '3200', 'ST' => '3100', 'SH' => '2100', 'TH' => '4100',
];
return $codes[$bundesland] ?? '9999';
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
/**
* Berechnet die Einnahmen-Überschuss-Rechnung (EÜR) für ein Geschäftsjahr.
* Gruppiert alle Buchungen nach Konten und summiert Einnahmen / Ausgaben.
*/
class EuerCalculator
{
public function calculate(int $year): array
{
$rows = Database::fetchAll(
'SELECT
a.id,
a.kontonummer,
a.bezeichnung,
a.kontenart,
SUM(CASE WHEN bk.haben_konto_id = a.id THEN bk.betrag_netto ELSE 0 END) as haben_summe,
SUM(CASE WHEN bk.soll_konto_id = a.id THEN bk.betrag_netto ELSE 0 END) as soll_summe,
COUNT(bk.id) as buchungsanzahl
FROM accounts a
JOIN bookings bk ON (bk.haben_konto_id = a.id OR bk.soll_konto_id = a.id)
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ? AND bk.is_reversal = 0
GROUP BY a.id, a.kontonummer, a.bezeichnung, a.kontenart
HAVING buchungsanzahl > 0
ORDER BY a.kontonummer',
[$year]
);
$einnahmen = [];
$ausgaben = [];
foreach ($rows as $row) {
$nettoWert = round((float)$row['haben_summe'] - (float)$row['soll_summe'], 2);
if ($row['kontenart'] === 'Ertrag') {
$einnahmen[] = array_merge($row, ['netto' => abs($nettoWert)]);
} elseif ($row['kontenart'] === 'Aufwand') {
$ausgaben[] = array_merge($row, ['netto' => abs($nettoWert)]);
}
}
$totalEinnahmen = array_sum(array_column($einnahmen, 'netto'));
$totalAusgaben = array_sum(array_column($ausgaben, 'netto'));
$gewinn = round($totalEinnahmen - $totalAusgaben, 2);
return [
'year' => $year,
'einnahmen' => $einnahmen,
'ausgaben' => $ausgaben,
'total_einnahmen' => round($totalEinnahmen, 2),
'total_ausgaben' => round($totalAusgaben, 2),
'gewinn' => $gewinn,
];
}
}

View file

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Mpdf\Mpdf;
class PdfEuerService
{
public function render(array $euer, array $settings): void
{
$html = $this->buildHtml($euer, $settings);
$mpdf = new Mpdf([
'mode' => 'utf-8',
'format' => 'A4',
'margin_top' => 20,
'margin_bottom' => 20,
'margin_left' => 20,
'margin_right' => 20,
]);
$mpdf->SetTitle('EÜR ' . $euer['year']);
$mpdf->SetAuthor($settings['firma'] ?? '');
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
$mpdf->Output('EÜR-' . $euer['year'] . '.pdf', 'D');
}
private function buildHtml(array $euer, array $settings): string
{
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
$firma = $h($settings['firma'] ?? '');
$year = $euer['year'];
// Build table rows
$rows = '';
$maxRows = max(count($euer['einnahmen']), count($euer['ausgaben']));
for ($i = 0; $i < $maxRows; $i++) {
$e = $euer['einnahmen'][$i] ?? null;
$a = $euer['ausgaben'][$i] ?? null;
$rows .= '<tr>';
$rows .= $e
? '<td class="konto-nr">' . $h($e['kontonummer']) . '</td><td class="konto-bez">' . $h($e['bezeichnung']) . '</td><td class="konto-val">' . $fmt($e['netto']) . '</td>'
: '<td colspan="3">&nbsp;</td>';
$rows .= '<td class="sep"></td>';
$rows .= $a
? '<td class="konto-nr">' . $h($a['kontonummer']) . '</td><td class="konto-bez">' . $h($a['bezeichnung']) . '</td><td class="konto-val">' . $fmt($a['netto']) . '</td>'
: '<td colspan="3">&nbsp;</td>';
$rows .= '</tr>';
}
$gewinnLabel = $euer['gewinn'] >= 0 ? 'Überschuss (Gewinn)' : 'Fehlbetrag (Verlust)';
$gewinnClass = $euer['gewinn'] >= 0 ? 'positive' : 'negative';
return <<<HTML
<h1>Einnahmen-Überschuss-Rechnung {$year}</h1>
<p class="subtitle">{$firma} · Geschäftsjahr {$year}</p>
<table class="euer-table">
<thead>
<tr>
<th colspan="3">Betriebseinnahmen</th>
<th class="sep"></th>
<th colspan="3">Betriebsausgaben</th>
</tr>
<tr class="sub-header">
<th>Kto.</th><th>Bezeichnung</th><th>Betrag</th>
<th class="sep"></th>
<th>Kto.</th><th>Bezeichnung</th><th>Betrag</th>
</tr>
</thead>
<tbody>
{$rows}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2"><strong>Summe Einnahmen</strong></td>
<td class="konto-val"><strong>{$fmt($euer['total_einnahmen'])}</strong></td>
<td class="sep"></td>
<td colspan="2"><strong>Summe Ausgaben</strong></td>
<td class="konto-val"><strong>{$fmt($euer['total_ausgaben'])}</strong></td>
</tr>
<tr class="gewinn-row {$gewinnClass}">
<td colspan="7" class="gewinn-cell">
<strong>{$gewinnLabel}:</strong>
<span class="gewinn-val">{$fmt($euer['gewinn'])}</span>
</td>
</tr>
</tfoot>
</table>
<p class="generated-note">Erstellt am {$h(date('d.m.Y H:i'))} · Steuerkram</p>
HTML;
}
private function buildCss(): string
{
return <<<CSS
body { font-family: DejaVu Sans, sans-serif; font-size: 9pt; }
h1 { font-size: 14pt; margin-bottom: 2mm; }
.subtitle { font-size: 9pt; color: #555; margin-bottom: 8mm; }
.euer-table { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
.euer-table thead th { background: #1e293b; color: #fff; padding: 2mm; text-align: left; }
.euer-table .sub-header th { background: #334155; color: #e2e8f0; font-size: 7.5pt; }
.euer-table tbody tr:nth-child(even) { background: #f8f8f8; }
.euer-table tbody td { padding: 1.5mm 2mm; border-bottom: 0.2pt solid #e0e0e0; }
.konto-nr { width: 12mm; color: #555; }
.konto-bez { }
.konto-val { width: 24mm; text-align: right; font-family: DejaVu Sans Mono, monospace; }
.sep { width: 3mm; background: #fff; }
.total-row td { padding: 2mm; background: #e8ecf0; border-top: 1pt solid #555; font-size: 9pt; }
.gewinn-row td { padding: 3mm; }
.gewinn-row.positive td { background: #dcfce7; }
.gewinn-row.negative td { background: #fee2e2; }
.gewinn-cell { text-align: right; font-size: 11pt; }
.gewinn-val { margin-left: 4mm; }
.generated-note { font-size: 7pt; color: #888; margin-top: 8mm; }
CSS;
}
}

View file

@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Mpdf\Mpdf;
use Mpdf\Config\ConfigVariables;
use Mpdf\Config\FontVariables;
class PdfInvoiceService
{
public function render(array $invoice, array $lines, array $settings): void
{
$html = $this->buildHtml($invoice, $lines, $settings);
$mpdf = new Mpdf([
'mode' => 'utf-8',
'format' => 'A4',
'margin_top' => 15,
'margin_bottom' => 25,
'margin_left' => 20,
'margin_right' => 20,
]);
$mpdf->SetTitle('Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'));
$mpdf->SetAuthor($settings['firma'] ?? '');
$mpdf->SetHTMLFooter($this->buildFooter($settings));
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
$mpdf->Output($filename, 'D'); // D = Download
}
private function buildHtml(array $inv, array $lines, array $settings): string
{
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
$fmtNum = fn($v) => number_format((float)$v, 3, ',', '.');
$fmtDate = fn($v) => $v ? date('d.m.Y', strtotime($v)) : '–';
// Customer address
$customerName = !empty($inv['firma']) ? $inv['firma'] : trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . $inv['nachname']);
// Logo
$logoHtml = '';
if (!empty($settings['logo_path'])) {
$logoPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path'];
if (file_exists($logoPath)) {
$logoHtml = '<img src="' . $h($settings['logo_path']) . '" alt="Logo" class="logo">';
}
}
// Leistungszeitraum
$lz = '';
if (!empty($inv['leistungszeitraum_von'])) {
$lz = $fmtDate($inv['leistungszeitraum_von']);
if (!empty($inv['leistungszeitraum_bis']) && $inv['leistungszeitraum_bis'] !== $inv['leistungszeitraum_von']) {
$lz .= ' – ' . $fmtDate($inv['leistungszeitraum_bis']);
}
}
// Build positions table
$posRows = '';
foreach ($lines as $line) {
$posRows .= '<tr>
<td class="pos-nr">' . $h($line['position']) . '</td>
<td class="pos-bez">' . $h($line['bezeichnung']) . '</td>
<td class="pos-menge">' . $fmtNum($line['menge']) . '</td>
<td class="pos-einheit">' . $h($line['einheit']) . '</td>
<td class="pos-ust">' . $h(number_format((float)$line['ust_satz'], 0)) . ' %</td>
<td class="pos-preis">' . $fmt($line['einzelpreis']) . '</td>
<td class="pos-gesamt">' . $fmt($line['betrag_brutto']) . '</td>
</tr>';
}
$payDays = $inv['payment_days'] ?? 14;
return <<<HTML
<div class="invoice-wrap">
<!-- Logo -->
<div class="logo-wrap">{$logoHtml}</div>
<!-- Adressbereich (DIN 5008) -->
<div class="address-block">
<div class="address-left">
<div class="return-address">{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</div>
<div class="recipient">
{$h($customerName)}<br>
{$h($inv['strasse'])}<br>
{$h($inv['plz'])} {$h($inv['ort'])}<br>
{$h($inv['land'] !== 'DE' ? $inv['land'] : '')}
</div>
</div>
<div class="address-right">
<table class="sender-table">
<tr><td>{$h($settings['firma'] ?? '')}</td></tr>
<tr><td>{$h($settings['inhaber'] ?? '')}</td></tr>
<tr><td>{$h($settings['strasse'] ?? '')}</td></tr>
<tr><td>{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</td></tr>
<tr><td>&nbsp;</td></tr>
<tr><td>{$h($settings['telefon'] ?? '')}</td></tr>
<tr><td>{$h($settings['email'] ?? '')}</td></tr>
<tr><td>USt-ID: {$h($settings['ust_id'] ?? '')}</td></tr>
</table>
</div>
</div>
<!-- Meta-Felder -->
<table class="meta-table">
<tr>
<th>Betreff</th>
<th>Rechnungsnummer</th>
<th>Kundennummer</th>
<th>Leistungszeitraum</th>
<th>Datum</th>
</tr>
<tr>
<td>{$h($inv['betreff'])}</td>
<td>{$h($inv['rechnungs_nr'] ?? 'Entwurf')}</td>
<td>{$h($inv['kundennummer'])}</td>
<td>{$h($lz)}</td>
<td>{$fmtDate($inv['rechnungs_datum'])}</td>
</tr>
</table>
<!-- Positionen -->
<table class="pos-table">
<thead>
<tr>
<th class="pos-nr">Pos.</th>
<th class="pos-bez">Bezeichnung</th>
<th class="pos-menge">Menge</th>
<th class="pos-einheit">Einheit</th>
<th class="pos-ust">USt.</th>
<th class="pos-preis">Einzelpreis</th>
<th class="pos-gesamt">Gesamt</th>
</tr>
</thead>
<tbody>
{$posRows}
</tbody>
</table>
<!-- Summen -->
<table class="sum-table">
<tr><td>Nettobetrag:</td><td class="sum-val">{$fmt($inv['betrag_netto'])}</td></tr>
<tr><td>zzgl. Umsatzsteuer:</td><td class="sum-val">{$fmt($inv['betrag_ust'])}</td></tr>
<tr class="sum-total"><td><strong>Rechnungsbetrag:</strong></td><td class="sum-val"><strong>{$fmt($inv['betrag_brutto'])}</strong></td></tr>
</table>
<!-- Zahlungshinweis -->
<p class="payment-note">
Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.
</p>
</div>
HTML;
}
private function buildFooter(array $settings): string
{
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
return <<<HTML
<table class="footer-table">
<tr>
<td>
<strong>{$h($settings['firma'] ?? '')}</strong><br>
{$h($settings['inhaber'] ?? '')}<br>
{$h($settings['strasse'] ?? '')}<br>
{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}<br>
USt-ID: {$h($settings['ust_id'] ?? '')}
</td>
<td>
Tel: {$h($settings['telefon'] ?? '')}<br>
E-Mail: {$h($settings['email'] ?? '')}<br>
Web: {$h($settings['website'] ?? '')}
</td>
<td>
{$h($settings['bank_name'] ?? '')}<br>
IBAN: {$h($settings['iban'] ?? '')}<br>
BIC: {$h($settings['bic'] ?? '')}
</td>
<td class="page-nr">
Seite {PAGENO} von {nbpg}
</td>
</tr>
</table>
HTML;
}
private function buildCss(): string
{
return <<<CSS
body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #000; }
.invoice-wrap { width: 100%; }
.logo-wrap { text-align: center; margin-bottom: 10mm; }
.logo { max-height: 25mm; max-width: 80mm; }
/* DIN 5008 Adressbereich */
.address-block { display: flex; justify-content: space-between; margin-bottom: 10mm; }
.address-left { width: 85mm; }
.return-address { font-size: 7pt; color: #555; border-bottom: 0.3pt solid #aaa; padding-bottom: 1mm; margin-bottom: 2mm; }
.recipient { font-size: 10pt; line-height: 1.4; min-height: 27.3mm; }
.address-right { width: 75mm; text-align: left; }
.sender-table td { font-size: 9pt; padding: 0.5mm 0; }
/* Meta-Felder */
.meta-table { width: 100%; border-collapse: collapse; margin-bottom: 8mm; font-size: 9pt; }
.meta-table th { background: #f0f0f0; padding: 2mm 2mm; text-align: left; font-size: 8pt; color: #555; border: 0.5pt solid #ccc; }
.meta-table td { padding: 2mm; border: 0.5pt solid #ccc; }
/* Positionen */
.pos-table { width: 100%; border-collapse: collapse; margin-bottom: 5mm; font-size: 9pt; }
.pos-table thead th { background: #1e293b; color: #fff; padding: 2mm; text-align: left; }
.pos-table tbody tr:nth-child(even) { background: #f8f8f8; }
.pos-table tbody td { padding: 1.5mm 2mm; border-bottom: 0.3pt solid #e0e0e0; }
.pos-nr { width: 10mm; }
.pos-bez { width: 55mm; }
.pos-menge { width: 18mm; text-align: right; }
.pos-einheit { width: 15mm; }
.pos-ust { width: 12mm; text-align: right; }
.pos-preis { width: 20mm; text-align: right; }
.pos-gesamt { width: 22mm; text-align: right; }
/* Summen */
.sum-table { float: right; width: 70mm; border-collapse: collapse; margin-bottom: 8mm; font-size: 9.5pt; }
.sum-table td { padding: 1.5mm 2mm; }
.sum-table .sum-val { text-align: right; border-top: 0.3pt solid #ccc; }
.sum-table .sum-total td { border-top: 1pt solid #000; font-size: 10.5pt; }
/* Zahlungshinweis */
.payment-note { clear: both; margin-top: 5mm; font-size: 9pt; padding: 3mm; background: #f8f8f8; border-left: 2pt solid #2563eb; }
/* Fußzeile */
.footer-table { width: 100%; border-collapse: collapse; font-size: 7.5pt; border-top: 0.5pt solid #ccc; padding-top: 2mm; }
.footer-table td { width: 25%; padding: 1mm 2mm; vertical-align: top; }
.page-nr { text-align: right; }
CSS;
}
}

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
/**
* Berechnet alle ELSTER-Kennzahlen (Kz) für die Umsatzsteuervoranmeldung.
*
* Maßgeblich sind ausschließlich ust_satz und betrag_ust der Buchung selbst –
* nicht der steuercode des gebuchten Kontos. Dadurch werden Buchungen mit
* ust_satz=0 (z.B. innergemeinschaftliche Erwerbe, steuerfreie Leistungen)
* korrekt mit 0 Vorsteuer/USt gewertet.
*
* Buchungslogik:
* Umsatzsteuer (Kz 81/86): ust_satz > 0 auf Ertrag-Haben-Konten
* Vorsteuer (Kz 61/62): ust_satz > 0 auf Aufwand/Aktiv-Soll-Konten
* Steuerfreie Umsätze (Kz 35): Ertrag-Konten mit ust_satz = 0
*/
class UstvaCalculator
{
public function calculate(int $year, int $month): array
{
$kz = [
'81' => ['name' => 'Steuerpfl. Umsätze 19 %', 'betrag' => 0.0, 'steuer' => 0.0],
'86' => ['name' => 'Steuerpfl. Umsätze 7 %', 'betrag' => 0.0, 'steuer' => 0.0],
'35' => ['name' => 'Steuerfreie Umsätze (ohne Vorsteuerabzug)', 'betrag' => 0.0, 'steuer' => null],
'41' => ['name' => 'Steuerfreie EU-Lieferungen (§ 4 Nr. 1b)', 'betrag' => 0.0, 'steuer' => null],
'83' => ['name' => 'USt auf steuerpfl. Umsätze 19 %', 'betrag' => null, 'steuer' => 0.0],
'84' => ['name' => 'USt auf steuerpfl. Umsätze 7 %', 'betrag' => null, 'steuer' => 0.0],
'61' => ['name' => 'Vorsteuer aus Eingangsrechnungen 19 %', 'betrag' => null, 'steuer' => 0.0],
'62' => ['name' => 'Vorsteuer aus Eingangsrechnungen 7 %', 'betrag' => null, 'steuer' => 0.0],
'66' => ['name' => 'Vorsteuerbeträge gesamt', 'betrag' => null, 'steuer' => 0.0],
'69' => ['name' => 'Verbleibende USt-Vorauszahlung/Erstattung', 'betrag' => null, 'steuer' => 0.0],
];
$bookings = $this->fetchBookings($year, $month);
foreach ($bookings as $b) {
$ustSatz = (float)$b['ust_satz'];
$betragUst = abs((float)$b['betrag_ust']);
$betragNetto= abs((float)$b['betrag_netto']);
$habenArt = $b['haben_kontenart'] ?? '';
$sollArt = $b['soll_kontenart'] ?? '';
// ── Umsatzsteuer (Ausgangsseite) ──────────────────────────────
// Kriterium: Haben-Konto = Ertrag UND ust_satz > 0
if ($habenArt === 'Ertrag' && $ustSatz > 0) {
if (abs($ustSatz - 19) < 0.01) {
$kz['81']['betrag'] += $betragNetto;
} elseif (abs($ustSatz - 7) < 0.01) {
$kz['86']['betrag'] += $betragNetto;
}
}
// Steuerfreie Umsätze: Haben-Konto = Ertrag UND ust_satz = 0
if ($habenArt === 'Ertrag' && $ustSatz == 0) {
$kz['35']['betrag'] += $betragNetto;
}
// ── Vorsteuer (Eingangsseite) ──────────────────────────────────
// Kriterium: Soll-Konto = Aufwand oder Aktiv (Vorsteuerkonto)
// UND ust_satz > 0 UND betrag_ust > 0
// Buchungen mit ust_satz=0 (z.B. innergemeinschaftlich) → kein Eintrag
if (in_array($sollArt, ['Aufwand', 'Aktiv']) && $ustSatz > 0 && $betragUst > 0) {
if (abs($ustSatz - 19) < 0.01) {
$kz['61']['steuer'] += $betragUst;
} elseif (abs($ustSatz - 7) < 0.01) {
$kz['62']['steuer'] += $betragUst;
}
$kz['66']['steuer'] += $betragUst;
}
}
// Abgeleitete Kennzahlen
$kz['83']['steuer'] = round($kz['81']['betrag'] * 0.19, 2);
$kz['84']['steuer'] = round($kz['86']['betrag'] * 0.07, 2);
$ustGesamt = $kz['83']['steuer'] + $kz['84']['steuer'];
$kz['69']['steuer'] = round($ustGesamt - $kz['66']['steuer'], 2);
foreach ($kz as &$field) {
if ($field['betrag'] !== null) $field['betrag'] = round($field['betrag'], 2);
if ($field['steuer'] !== null) $field['steuer'] = round($field['steuer'], 2);
}
return $kz;
}
private function fetchBookings(int $year, int $month): array
{
return Database::fetchAll(
'SELECT bk.ust_satz, bk.betrag_netto, bk.betrag_ust,
s.kontenart AS soll_kontenart,
h.kontenart AS haben_kontenart
FROM bookings bk
JOIN accounts s ON bk.soll_konto_id = s.id
JOIN accounts h ON bk.haben_konto_id = h.id
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND bk.is_reversal = 0',
[$year, $month]
);
}
}

63
src/View.php Normal file
View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Steuerkram;
class View
{
private string $viewDir;
private array $flash = [];
public function __construct()
{
$this->viewDir = dirname(__DIR__) . '/src/Views';
// Session-based flash messages
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (isset($_SESSION['flash'])) {
$this->flash = $_SESSION['flash'];
unset($_SESSION['flash']);
}
}
public function render(string $template, array $data = []): void
{
$data['flash'] = $this->flash;
$data['currentPath'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
extract($data, EXTR_SKIP);
include $this->viewDir . '/' . $template;
}
public static function redirect(string $path, string $message = '', string $type = 'success'): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if ($message !== '') {
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
}
header('Location: ' . $path);
exit;
}
public static function h(mixed $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public static function formatMoney(mixed $value): string
{
return number_format((float)$value, 2, ',', '.') . ' €';
}
public static function formatDate(?string $date): string
{
if (!$date) return '–';
$d = \DateTime::createFromFormat('Y-m-d', $date);
return $d ? $d->format('d.m.Y') : $date;
}
}

View file

@ -0,0 +1,91 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="max-width:700px">
<div class="card-body">
<form method="post" action="<?= $h($action) ?>">
<input type="hidden" name="_back" value="<?= $h($back ?? '/accounts') ?>">
<div class="form-grid form-grid-2">
<div class="form-group">
<label for="kontonummer">Kontonummer *</label>
<input type="text" id="kontonummer" name="kontonummer"
value="<?= $h($account['kontonummer'] ?? '') ?>"
class="<?= isset($errors['kontonummer']) ? 'input-error' : '' ?>"
required>
<?php if (isset($errors['kontonummer'])): ?>
<div class="error-text"><?= $h($errors['kontonummer']) ?></div>
<?php endif; ?>
</div>
<div class="form-group">
<label for="kontenart">Kontenart *</label>
<select id="kontenart" name="kontenart"
class="<?= isset($errors['kontenart']) ? 'input-error' : '' ?>" required>
<option value="">– wählen –</option>
<?php foreach (['Aktiv','Passiv','Aufwand','Ertrag','Neutral'] as $art): ?>
<option value="<?= $h($art) ?>"<?= ($account['kontenart'] ?? '') === $art ? ' selected' : '' ?>>
<?= $h($art) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['kontenart'])): ?>
<div class="error-text"><?= $h($errors['kontenart']) ?></div>
<?php endif; ?>
</div>
</div>
<div class="form-group">
<label for="bezeichnung">Bezeichnung *</label>
<input type="text" id="bezeichnung" name="bezeichnung"
value="<?= $h($account['bezeichnung'] ?? '') ?>"
class="<?= isset($errors['bezeichnung']) ? 'input-error' : '' ?>"
required>
<?php if (isset($errors['bezeichnung'])): ?>
<div class="error-text"><?= $h($errors['bezeichnung']) ?></div>
<?php endif; ?>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label for="steuercode">Steuercode</label>
<select id="steuercode" name="steuercode">
<option value="">– kein –</option>
<?php foreach (['USt19','USt7','VSt19','VSt7'] as $sc): ?>
<option value="<?= $sc ?>"<?= ($account['steuercode'] ?? '') === $sc ? ' selected' : '' ?>>
<?= $sc ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-hint">USt = Ausgangs-USt, VSt = Vorsteuer</div>
</div>
<div class="form-group">
<label for="ustva_kz">UStVA Kennzahl (ELSTER)</label>
<input type="text" id="ustva_kz" name="ustva_kz"
value="<?= $h($account['ustva_kz'] ?? '') ?>"
placeholder="z.B. 81, 86, 66">
<div class="form-hint">Kennzahl im ELSTER-Formular</div>
</div>
</div>
<div class="form-group">
<label for="beschreibung">Beschreibung</label>
<textarea id="beschreibung" name="beschreibung"><?= $h($account['beschreibung'] ?? '') ?></textarea>
</div>
<?php if (!empty($account['id'])): ?>
<div class="form-group">
<label>
<input type="checkbox" name="aktiv" value="1"<?= ($account['aktiv'] ?? 1) ? ' checked' : '' ?>>
Konto aktiv
</label>
</div>
<?php endif; ?>
<div style="display:flex;gap:.75rem;margin-top:1.5rem">
<button type="submit" class="btn btn-primary">Speichern</button>
<a href="<?= $h($back ?? '/accounts') ?>" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,76 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="toolbar">
<div class="toolbar-left">
<form method="get" style="display:flex;gap:.5rem;align-items:center">
<input type="search" name="search" placeholder="Suche Kto.-Nr. oder Bezeichnung…"
value="<?= $h($filter['search'] ?? '') ?>" style="width:260px">
<select name="kontenart">
<option value="">Alle Kontenarten</option>
<?php foreach (['Aktiv','Passiv','Aufwand','Ertrag','Neutral'] as $art): ?>
<option value="<?= $h($art) ?>"<?= ($filter['kontenart'] ?? '') === $art ? ' selected' : '' ?>><?= $h($art) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-secondary">Filtern</button>
<?php if (!empty($filter['search']) || !empty($filter['kontenart'])): ?>
<a href="/accounts" class="btn btn-secondary">✕ Reset</a>
<?php endif; ?>
</form>
</div>
<div class="toolbar-right">
<span class="text-muted text-sm"><?= count($accounts) ?> Konten</span>
</div>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Kontonummer</th>
<th>Bezeichnung</th>
<th>Kontenart</th>
<th>Steuercode</th>
<th>UStVA Kz</th>
<th>Beschreibung</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($accounts as $acc): ?>
<tr>
<td class="td-mono font-bold"><?= $h($acc['kontonummer']) ?></td>
<td><?= $h($acc['bezeichnung']) ?></td>
<td>
<?php
$badgeClass = match($acc['kontenart']) {
'Ertrag' => 'badge-green',
'Aufwand' => 'badge-red',
'Aktiv' => 'badge-blue',
'Passiv' => 'badge-yellow',
default => 'badge-gray',
};
?>
<span class="badge <?= $badgeClass ?>"><?= $h($acc['kontenart']) ?></span>
</td>
<td class="td-muted"><?= $h($acc['steuercode'] ?? '–') ?></td>
<td class="td-muted"><?= $h($acc['ustva_kz'] ?? '–') ?></td>
<td class="td-muted text-sm"><?= $h(mb_strimwidth($acc['beschreibung'] ?? '', 0, 60, '…')) ?></td>
<td>
<div style="display:flex;gap:.5rem">
<a href="/accounts/<?= $h($acc['id']) ?>/edit" class="btn btn-secondary btn-sm">Bearbeiten</a>
<form method="post" action="/accounts/<?= $h($acc['id']) ?>/delete"
data-confirm="Konto '<?= $h($acc['kontonummer']) ?>' wirklich löschen?">
<button type="submit" class="btn btn-danger btn-sm">Löschen</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($accounts)): ?>
<tr><td colspan="7" class="text-center text-muted" style="padding:2rem">Keine Konten gefunden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

338
src/Views/belege/detail.php Normal file
View file

@ -0,0 +1,338 @@
<?php
use Steuerkram\View;
use Steuerkram\Models\Setting;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
$testMode = Setting::isTestMode();
?>
<!-- Beleg-Info-Bar -->
<div class="card mb-2">
<div class="card-body" style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;padding:.75rem 1.25rem">
<div>
<span class="text-muted text-sm">Belegnr.</span><br>
<strong class="font-mono"><?= $h($beleg['beleg_nr']) ?></strong>
</div>
<div>
<span class="text-muted text-sm">Typ</span><br>
<span class="badge badge-gray"><?= $h($beleg['belegtyp']) ?></span>
</div>
<div>
<span class="text-muted text-sm">Belegdatum</span><br>
<?= View::formatDate($beleg['beleg_datum']) ?>
</div>
<div>
<span class="text-muted text-sm">Eingangsdatum</span><br>
<?= View::formatDate($beleg['eingangs_datum']) ?>
</div>
<div>
<span class="text-muted text-sm">Rechnungsnr.</span><br>
<?= $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '<span class="text-muted">–</span>' ?>
</div>
<div>
<span class="text-muted text-sm">Brutto lt. Beleg</span><br>
<?= $beleg['betrag_brutto'] ? '<strong>' . $fmt($beleg['betrag_brutto']) . '</strong>' : '<span class="text-muted">–</span>' ?>
</div>
<div>
<span class="text-muted text-sm">GoBD-Integrität</span><br>
<?php if ($integrity): ?>
<span class="badge badge-green">✓ OK</span>
<?php else: ?>
<span class="badge badge-red">⚠ Prüfsumme abweichend</span>
<?php endif; ?>
</div>
<div style="margin-left:auto;display:flex;gap:.5rem">
<a href="/belege/<?= $h($beleg['id']) ?>/file" target="_blank" class="btn btn-secondary btn-sm">Datei öffnen</a>
<?php if (!$beleg['locked']): ?>
<button type="button" class="btn btn-secondary btn-sm"
onclick="document.getElementById('beleg-meta-form').style.display = document.getElementById('beleg-meta-form').style.display === 'none' ? 'block' : 'none'">
Metadaten bearbeiten
</button>
<?php endif; ?>
<?php if (!$beleg['locked'] && empty($bookings)): ?>
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/delete"
data-confirm="Beleg wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.">
<button type="submit" class="btn btn-danger btn-sm">Löschen</button>
</form>
<?php endif; ?>
</div>
</div>
<?php if (!$beleg['locked']): ?>
<div id="beleg-meta-form" style="display:none;border-top:1px solid var(--color-border);padding:.75rem 1.25rem">
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/meta">
<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin:0;min-width:140px">
<label style="font-size:.8rem">Rechnungsnr.</label>
<input type="text" name="rechnungs_nr" value="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
placeholder="z. B. RE-2024-001">
</div>
<div class="form-group" style="margin:0">
<label style="font-size:.8rem">Belegtyp</label>
<select name="belegtyp">
<?php foreach (['Eingangsrechnung','Ausgangsrechnung','Quittung','Kontoauszug','Sonstiges'] as $t): ?>
<option value="<?= $t ?>"<?= $beleg['belegtyp'] === $t ? ' selected' : '' ?>><?= $t ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0">
<label style="font-size:.8rem">Belegdatum</label>
<input type="date" name="beleg_datum" value="<?= $h($beleg['beleg_datum'] ?? '') ?>">
</div>
<div class="form-group" style="margin:0;min-width:120px">
<label style="font-size:.8rem">Brutto lt. Beleg (€)</label>
<input type="number" name="betrag_brutto" step="0.01" min="0"
value="<?= $h($beleg['betrag_brutto'] ?? '') ?>" placeholder="0.00">
</div>
<button type="submit" class="btn btn-primary btn-sm">Speichern</button>
<button type="button" class="btn btn-secondary btn-sm"
onclick="document.getElementById('beleg-meta-form').style.display='none'">Abbrechen</button>
</div>
</form>
</div>
<?php endif; ?>
</div>
<!-- Split-Pane: Vorschau + Buchungsformular -->
<div class="beleg-layout">
<!-- Linke Seite: Dokumentvorschau -->
<div class="beleg-preview">
<?php if (in_array($beleg['mime_type'], ['application/pdf'])): ?>
<iframe src="/belege/<?= $h($beleg['id']) ?>/file" title="Belegvorschau"></iframe>
<?php elseif (str_starts_with($beleg['mime_type'], 'image/')): ?>
<img src="/belege/<?= $h($beleg['id']) ?>/file" alt="Belegvorschau" style="object-fit:contain;max-height:700px;width:100%">
<?php elseif (str_contains($beleg['mime_type'], 'xml')): ?>
<div style="padding:1.5rem">
<div class="badge badge-blue mb-2">XML / eRechnung</div>
<p class="text-muted text-sm">XML-Datei kann nicht direkt angezeigt werden.</p>
<a href="/belege/<?= $h($beleg['id']) ?>/file" class="btn btn-secondary mt-2" download>XML herunterladen</a>
</div>
<?php endif; ?>
</div>
<!-- Rechte Seite: Buchungen -->
<div>
<!-- Vorhandene Buchungen -->
<div class="card mb-2">
<div class="card-header">
<div class="card-title">Buchungszeilen (<?= count($bookings) ?>)</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Buchungsnr.</th>
<th>Datum</th>
<th>Soll</th>
<th>Haben</th>
<th class="td-right">Netto</th>
<th class="td-right">USt</th>
<th class="td-right">Brutto</th>
<th>Text</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $bk):
$isReversed = !empty($bk['reversed_by']);
$isReversal = (bool)$bk['is_reversal'];
?>
<tr style="<?= ($isReversed || $isReversal) ? 'opacity:.55;background:#fff5f5' : '' ?>">
<td class="td-mono text-sm"><?= $h($bk['buchungs_nr']) ?></td>
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
<td class="td-mono text-sm"><?= $h($bk['soll_nr']) ?><br><span class="td-muted"><?= $h($bk['soll_name']) ?></span></td>
<td class="td-mono text-sm"><?= $h($bk['haben_nr']) ?><br><span class="td-muted"><?= $h($bk['haben_name']) ?></span></td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td>
<td class="td-right td-mono"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
<td class="td-right td-mono"><?= $fmt($bk['betrag_brutto']) ?></td>
<td class="text-sm">
<?= $h($bk['buchungstext']) ?>
<?php if ($isReversal): ?>
<span class="badge badge-red">Storno</span>
<?php elseif ($isReversed): ?>
<span class="badge badge-red">Storniert</span>
<?php endif; ?>
</td>
<td>
<?php if (!$bk['locked'] && !$isReversal && !$isReversed): ?>
<div style="display:flex;gap:.35rem">
<button type="button" class="btn btn-danger btn-sm"
data-modal-open="modal-storno-<?= $h($bk['id']) ?>">
Storno
</button>
<?php if ($testMode): ?>
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/delete"
data-confirm="Buchung <?= $h($bk['buchungs_nr']) ?> wirklich löschen? (Nur im Testmodus – nicht GoBD-konform!)">
<input type="hidden" name="beleg_id" value="<?= $h($beleg['id']) ?>">
<button type="submit" class="btn btn-danger btn-sm" title="Löschen (Testmodus)">
Löschen
</button>
</form>
<?php endif; ?>
</div>
<!-- Storno-Modal -->
<div class="modal-overlay" id="modal-storno-<?= $h($bk['id']) ?>">
<div class="modal">
<div class="modal-title">Buchung stornieren</div>
<p class="text-sm text-muted mb-2">Die Buchung <?= $h($bk['buchungs_nr']) ?> wird durch eine Gegenbuchung storniert (GoBD-konform).</p>
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/reverse">
<div class="form-group">
<label>Stornogrund</label>
<input type="text" name="reason" required placeholder="Grund für die Stornierung">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button type="submit" class="btn btn-danger">Stornieren</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($bookings)): ?>
<tr><td colspan="9" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Neue Buchungszeile erfassen -->
<?php if (!$beleg['locked']): ?>
<?php
$formErrors = $formErrors ?? [];
$formData = $formData ?? [];
?>
<div class="card">
<div class="card-header">
<div class="card-title">Neue Buchungszeile</div>
</div>
<div class="card-body">
<?php if (!empty($formErrors)): ?>
<div class="flash flash-error" style="margin-bottom:1rem">
<?php if (isset($formErrors['_exception'])): ?>
Fehler: <?= $h($formErrors['_exception']) ?>
<?php else: ?>
<?= implode(' &nbsp;|&nbsp; ', array_map(fn($k,$v) => "<strong>$k:</strong> $v", array_keys($formErrors), $formErrors)) ?>
<?php endif; ?>
</div>
<?php endif; ?>
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings">
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Buchungsdatum *</label>
<input type="date" name="buchungs_datum"
value="<?= $h($formData['buchungs_datum'] ?? date('Y-m-d')) ?>" required
class="<?= isset($formErrors['buchungs_datum']) ? 'input-error' : '' ?>">
</div>
<div class="form-group">
<label>USt-Satz (%)</label>
<select name="ust_satz">
<?php foreach (['0','7','19'] as $s): ?>
<option value="<?= $s ?>"<?= ($formData['ust_satz'] ?? '19') === $s ? ' selected' : '' ?>><?= $s ?> %</option>
<?php endforeach; ?>
</select>
</div>
</div>
<?php
// Konten nach Art gruppieren für <optgroup>
$kontenGruppen = [];
foreach ($accounts as $acc) {
$kontenGruppen[$acc['kontenart']][] = $acc;
}
$gruppenReihenfolge = ['Aufwand', 'Ertrag', 'Aktiv', 'Passiv', 'Neutral'];
?>
<!-- Soll-Konto -->
<div class="form-group">
<label for="soll_konto_id">Soll-Konto *
<span id="soll_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
</label>
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
data-target="soll_konto_id" autocomplete="off" style="margin-bottom:.3rem">
<select id="soll_konto_id" name="soll_konto_id" required size="6"
onchange="showKontoArt('soll_konto_id','soll_konto_art',this)">
<option value="">– Konto wählen –</option>
<?php foreach ($gruppenReihenfolge as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?>
<optgroup label="── <?= $h($art) ?> ──">
<?php foreach ($kontenGruppen[$art] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"
data-art="<?= $h($acc['kontenart']) ?>"
data-search="<?= $h($acc['kontonummer'] . ' ' . $acc['bezeichnung'] . ' ' . $acc['kontenart']) ?>">
<?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
<div class="form-hint">Typisch: <strong>Aufwand</strong>-Konto (Eingangsrechnung) oder <strong>Aktiv</strong>-Konto (Forderung/Bank)</div>
</div>
<!-- Haben-Konto -->
<div class="form-group">
<label for="haben_konto_id">Haben-Konto *
<span id="haben_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
</label>
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
data-target="haben_konto_id" autocomplete="off" style="margin-bottom:.3rem">
<select id="haben_konto_id" name="haben_konto_id" required size="6"
onchange="showKontoArt('haben_konto_id','haben_konto_art',this)">
<option value="">– Konto wählen –</option>
<?php foreach ($gruppenReihenfolge as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?>
<optgroup label="── <?= $h($art) ?> ──">
<?php foreach ($kontenGruppen[$art] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"
data-art="<?= $h($acc['kontenart']) ?>"
data-search="<?= $h($acc['kontonummer'] . ' ' . $acc['bezeichnung'] . ' ' . $acc['kontenart']) ?>">
<?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
<div class="form-hint">Typisch: <strong>Passiv</strong>-Konto (Verbindlichkeit) oder <strong>Ertrag</strong>-Konto (Ausgangsrechnung)</div>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Nettobetrag (€) *</label>
<input type="number" name="betrag_netto" step="0.01" min="0.01" required
placeholder="0.00"
value="<?= $h($formData['betrag_netto'] ?? '') ?>"
class="<?= isset($formErrors['betrag_netto']) ? 'input-error' : '' ?>">
</div>
<div class="form-group">
<label>Buchungstext *</label>
<?php
$defaultText = $formData['buchungstext']
?? ($beleg['rechnungs_nr']
? 'Rechnung ' . $beleg['rechnungs_nr']
: $beleg['belegtyp'] . ' ' . $beleg['beleg_nr']);
?>
<input type="text" name="buchungstext" required
value="<?= $h($defaultText) ?>"
class="<?= isset($formErrors['buchungstext']) ? 'input-error' : '' ?>">
</div>
</div>
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
</form>
</div>
</div>
<?php else: ?>
<div class="flash flash-info">Dieser Beleg ist gesperrt (GoBD). Keine weiteren Buchungen möglich.</div>
<?php endif; ?>
</div>
</div>

94
src/Views/belege/list.php Normal file
View file

@ -0,0 +1,94 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="toolbar">
<div class="toolbar-left">
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
<input type="search" name="search" placeholder="Belegnr., Dateiname, Rechnungsnr.…"
value="<?= $h($filter['search'] ?? '') ?>" style="width:240px">
<select name="year">
<option value="">Alle Jahre</option>
<?php for ($y = (int)date('Y'); $y >= 2020; $y--): ?>
<option value="<?= $y ?>"<?= ($filter['year'] ?? '') == $y ? ' selected' : '' ?>><?= $y ?></option>
<?php endfor; ?>
</select>
<select name="month">
<option value="">Alle Monate</option>
<?php foreach (['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] as $i => $m): ?>
<option value="<?= $i+1 ?>"<?= ($filter['month'] ?? '') == $i+1 ? ' selected' : '' ?>><?= $m ?></option>
<?php endforeach; ?>
</select>
<select name="booked">
<option value="">Alle</option>
<option value="0"<?= ($filter['booked'] ?? '') === '0' ? ' selected' : '' ?>>Ungebucht</option>
<option value="1"<?= ($filter['booked'] ?? '') === '1' ? ' selected' : '' ?>>Gebucht</option>
</select>
<button type="submit" class="btn btn-secondary">Filtern</button>
<a href="/belege" class="btn btn-secondary">✕</a>
</form>
</div>
<div class="toolbar-right">
<span class="text-muted text-sm"><?= $total ?> Belege</span>
</div>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Belegnr.</th>
<th>Eingangsdatum</th>
<th>Belegdatum</th>
<th>Typ</th>
<th>Rechnungsnr.</th>
<th>Dateiname</th>
<th class="td-right">Brutto</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $b): ?>
<tr>
<td><a href="/belege/<?= $h($b['id']) ?>" class="font-mono"><?= $h($b['beleg_nr']) ?></a></td>
<td class="td-muted"><?= View::formatDate($b['eingangs_datum']) ?></td>
<td class="td-muted"><?= View::formatDate($b['beleg_datum']) ?></td>
<td><span class="badge badge-gray"><?= $h($b['belegtyp']) ?></span></td>
<td class="td-muted"><?= $h($b['rechnungs_nr'] ?? '–') ?></td>
<td class="text-sm text-muted"><?= $h(mb_strimwidth($b['filename'], 0, 35, '…')) ?></td>
<td class="td-right td-mono"><?= $b['betrag_brutto'] !== null ? View::formatMoney($b['betrag_brutto']) : '–' ?></td>
<td>
<?php if ($b['locked']): ?>
<span class="badge badge-purple">Gesperrt</span>
<?php elseif ($b['booked']): ?>
<span class="badge badge-green">Gebucht</span>
<?php else: ?>
<span class="badge badge-yellow">Offen</span>
<?php endif; ?>
</td>
<td>
<a href="/belege/<?= $h($b['id']) ?>" class="btn btn-secondary btn-sm">Öffnen</a>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="9" class="text-center text-muted" style="padding:2rem">Keine Belege gefunden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php if ($pages > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page-1])) ?>">‹</a><?php endif; ?>
<?php for ($p = max(1,$page-3); $p <= min($pages,$page+3); $p++): ?>
<?php if ($p === $page): ?>
<span class="active"><?= $p ?></span>
<?php else: ?>
<a href="?<?= http_build_query(array_merge($filter, ['page' => $p])) ?>"><?= $p ?></a>
<?php endif; ?>
<?php endfor; ?>
<?php if ($page < $pages): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page+1])) ?>">›</a><?php endif; ?>
</div>
<?php endif; ?>

View file

@ -0,0 +1,67 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="max-width:700px">
<div class="card-body">
<form method="post" action="/belege/upload" enctype="multipart/form-data">
<div class="form-group">
<label for="beleg_file">Datei auswählen *</label>
<input type="file" id="beleg_file" name="beleg_file" required
accept=".pdf,.png,.jpg,.jpeg,.tif,.tiff,.xml"
class="<?= isset($errors['file']) ? 'input-error' : '' ?>">
<div class="form-hint">Erlaubte Formate: PDF, PNG, JPEG, TIFF, XML (eRechnung)</div>
<?php if (isset($errors['file'])): ?>
<div class="error-text"><?= $h($errors['file']) ?></div>
<?php endif; ?>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label for="belegtyp">Belegtyp</label>
<select id="belegtyp" name="belegtyp">
<?php foreach (['Eingangsrechnung','Ausgangsrechnung','Quittung','Kontoauszug','Sonstiges'] as $t): ?>
<option value="<?= $t ?>"<?= ($old['belegtyp'] ?? 'Eingangsrechnung') === $t ? ' selected' : '' ?>><?= $t ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="eingangs_datum">Eingangsdatum *</label>
<input type="date" id="eingangs_datum" name="eingangs_datum"
value="<?= $h($old['eingangs_datum'] ?? date('Y-m-d')) ?>" required>
</div>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label for="beleg_datum">Belegdatum (Rechnungsdatum)</label>
<input type="date" id="beleg_datum" name="beleg_datum"
value="<?= $h($old['beleg_datum'] ?? '') ?>">
</div>
<div class="form-group">
<label for="rechnungs_nr">Rechnungsnummer des Lieferanten</label>
<input type="text" id="rechnungs_nr" name="rechnungs_nr"
value="<?= $h($old['rechnungs_nr'] ?? '') ?>" placeholder="z.B. RE-2024-001">
</div>
</div>
<div class="form-group">
<label for="betrag_brutto">Bruttobetrag (€)</label>
<input type="number" id="betrag_brutto" name="betrag_brutto"
value="<?= $h($old['betrag_brutto'] ?? '') ?>"
step="0.01" min="0" placeholder="0.00">
</div>
<div class="form-group">
<label for="notes">Notizen</label>
<textarea id="notes" name="notes" rows="3"><?= $h($old['notes'] ?? '') ?></textarea>
</div>
<div style="display:flex;gap:.75rem;margin-top:1rem">
<button type="submit" class="btn btn-primary">Hochladen</button>
<a href="/belege" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,112 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="toolbar">
<div class="toolbar-left">
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
<input type="search" name="search" placeholder="Buchungstext suchen…"
value="<?= $h($filter['search'] ?? '') ?>" style="width:220px">
<select name="year">
<option value="">Alle Jahre</option>
<?php foreach ($years as $y): ?>
<option value="<?= $y ?>"<?= ($filter['year'] ?? '') == $y ? ' selected' : '' ?>><?= $y ?></option>
<?php endforeach; ?>
</select>
<select name="month">
<option value="">Alle Monate</option>
<?php foreach (['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] as $i => $m): ?>
<option value="<?= $i+1 ?>"<?= ($filter['month'] ?? '') == $i+1 ? ' selected' : '' ?>><?= $m ?></option>
<?php endforeach; ?>
</select>
<select name="konto" style="width:160px">
<option value="">Alle Konten</option>
<?php foreach ($accounts as $a): ?>
<option value="<?= $a['id'] ?>"<?= ($filter['konto'] ?? '') == $a['id'] ? ' selected' : '' ?>>
<?= $h($a['kontonummer']) ?> – <?= $h($a['bezeichnung']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-secondary">Filtern</button>
<a href="/journal" class="btn btn-secondary">✕</a>
</form>
</div>
<div class="toolbar-right">
<span class="text-muted text-sm"><?= $total ?> Buchungen</span>
</div>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Buchungsnr.</th>
<th>Datum</th>
<th>Belegnr.</th>
<th>Soll-Konto</th>
<th>Haben-Konto</th>
<th class="td-right">Netto</th>
<th class="td-right">USt (%)</th>
<th class="td-right">Brutto</th>
<th>Buchungstext</th>
<?php if ($testMode): ?><th></th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $bk):
$isReversed = !empty($bk['reversed_by']);
$isReversal = (bool)$bk['is_reversal'];
$isCancelled = $isReversed || $isReversal;
?>
<tr style="<?= $isCancelled ? 'opacity:.55;background:#fff5f5' : '' ?>">
<td class="td-mono text-sm" style="<?= $isReversed ? 'text-decoration:line-through' : '' ?>">
<?= $h($bk['buchungs_nr']) ?>
</td>
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
<td><a href="/belege/<?= $h($bk['beleg_id']) ?>" class="td-mono text-sm"><?= $h($bk['beleg_nr']) ?></a></td>
<td class="td-mono text-sm">
<?= $h($bk['soll_nr']) ?><br>
<span class="td-muted"><?= $h(mb_strimwidth($bk['soll_name'], 0, 25, '…')) ?></span>
</td>
<td class="td-mono text-sm">
<?= $h($bk['haben_nr']) ?><br>
<span class="td-muted"><?= $h(mb_strimwidth($bk['haben_name'], 0, 25, '…')) ?></span>
</td>
<td class="td-right td-mono"><?= View::formatMoney($bk['betrag_netto']) ?></td>
<td class="td-right td-muted"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
<td class="td-right td-mono"><?= View::formatMoney($bk['betrag_brutto']) ?></td>
<td>
<?= $h($bk['buchungstext']) ?>
<?php if ($isReversal): ?>
<span class="badge badge-red">Storno</span>
<?php elseif ($isReversed): ?>
<span class="badge badge-red">Storniert</span>
<?php endif; ?>
</td>
<?php if ($testMode): ?>
<td>
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/delete"
data-confirm="Buchung <?= $h($bk['buchungs_nr']) ?> wirklich löschen? (Nur im Testmodus – nicht GoBD-konform!)">
<button type="submit" class="btn btn-danger btn-sm" title="Löschen (Testmodus)">✕</button>
</form>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="<?= $testMode ? 10 : 9 ?>" class="text-center text-muted" style="padding:2rem">Keine Buchungen gefunden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php if ($pages > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page-1])) ?>">‹</a><?php endif; ?>
<?php for ($p = max(1,$page-3); $p <= min($pages,$page+3); $p++): ?>
<?php if ($p === $page): ?><span class="active"><?= $p ?></span>
<?php else: ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $p])) ?>"><?= $p ?></a><?php endif; ?>
<?php endfor; ?>
<?php if ($page < $pages): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page+1])) ?>">›</a><?php endif; ?>
</div>
<?php endif; ?>

View file

@ -0,0 +1,97 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="max-width:800px">
<div class="card-body">
<form method="post" action="<?= $h($action) ?>">
<div class="form-grid form-grid-3">
<div class="form-group">
<label for="anrede">Anrede</label>
<select id="anrede" name="anrede">
<option value="">–</option>
<?php foreach (['Herr','Frau','Divers','Dr.','Prof.'] as $a): ?>
<option value="<?= $a ?>"<?= ($customer['anrede'] ?? '') === $a ? ' selected' : '' ?>><?= $a ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="vorname">Vorname</label>
<input type="text" id="vorname" name="vorname" value="<?= $h($customer['vorname'] ?? '') ?>">
</div>
<div class="form-group">
<label for="nachname">Nachname *</label>
<input type="text" id="nachname" name="nachname" value="<?= $h($customer['nachname'] ?? '') ?>"
class="<?= isset($errors['nachname']) ? 'input-error' : '' ?>" required>
<?php if (isset($errors['nachname'])): ?><div class="error-text"><?= $h($errors['nachname']) ?></div><?php endif; ?>
</div>
</div>
<div class="form-group">
<label for="firma">Firma / Organisation</label>
<input type="text" id="firma" name="firma" value="<?= $h($customer['firma'] ?? '') ?>">
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label for="strasse">Straße + Hausnummer *</label>
<input type="text" id="strasse" name="strasse" value="<?= $h($customer['strasse'] ?? '') ?>"
class="<?= isset($errors['strasse']) ? 'input-error' : '' ?>" required>
</div>
<div class="form-group" style="display:grid;grid-template-columns:100px 1fr;gap:.5rem">
<div>
<label for="plz">PLZ *</label>
<input type="text" id="plz" name="plz" value="<?= $h($customer['plz'] ?? '') ?>" required>
</div>
<div>
<label for="ort">Ort *</label>
<input type="text" id="ort" name="ort" value="<?= $h($customer['ort'] ?? '') ?>" required>
</div>
</div>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" value="<?= $h($customer['email'] ?? '') ?>">
</div>
<div class="form-group">
<label for="telefon">Telefon</label>
<input type="tel" id="telefon" name="telefon" value="<?= $h($customer['telefon'] ?? '') ?>">
</div>
</div>
<div class="form-grid form-grid-3">
<div class="form-group">
<label for="ust_id">Umsatzsteuer-ID</label>
<input type="text" id="ust_id" name="ust_id" value="<?= $h($customer['ust_id'] ?? '') ?>"
placeholder="DE123456789">
</div>
<div class="form-group">
<label for="steuernummer">Steuernummer</label>
<input type="text" id="steuernummer" name="steuernummer" value="<?= $h($customer['steuernummer'] ?? '') ?>">
</div>
<div class="form-group">
<label for="payment_days">Zahlungsziel (Tage)</label>
<input type="number" id="payment_days" name="payment_days"
value="<?= $h($customer['payment_days'] ?? 14) ?>" min="0" max="365">
</div>
</div>
<div class="form-group">
<label for="notes">Notizen</label>
<textarea id="notes" name="notes"><?= $h($customer['notes'] ?? '') ?></textarea>
</div>
<div style="display:flex;gap:.75rem;margin-top:1rem">
<button type="submit" class="btn btn-primary">Speichern</button>
<a href="/customers" class="btn btn-secondary">Abbrechen</a>
<?php if (!empty($customer['id']) && !$customer['archived']): ?>
<form method="post" action="/customers/<?= $h($customer['id']) ?>/archive" style="margin:0"
data-confirm="Kunden archivieren?">
<button type="submit" class="btn btn-secondary">Archivieren</button>
</form>
<?php endif; ?>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,60 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="toolbar">
<div class="toolbar-left">
<form method="get" style="display:flex;gap:.5rem;align-items:center">
<input type="search" name="search" placeholder="Name, Kundennummer, E-Mail…"
value="<?= $h($filter['search'] ?? '') ?>" style="width:260px">
<label style="display:flex;align-items:center;gap:.4rem;font-size:.875rem;text-transform:none;letter-spacing:0">
<input type="checkbox" name="archived" value="1"<?= !empty($filter['archived']) ? ' checked' : '' ?>>
Archivierte anzeigen
</label>
<button type="submit" class="btn btn-secondary">Suchen</button>
</form>
</div>
<div class="toolbar-right">
<span class="text-muted text-sm"><?= count($customers) ?> Kunden</span>
</div>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Kundennr.</th>
<th>Name / Firma</th>
<th>Adresse</th>
<th>E-Mail</th>
<th>USt-ID</th>
<th>Zahlungsziel</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($customers as $c): ?>
<tr>
<td class="td-mono"><?= $h($c['kundennummer']) ?></td>
<td>
<a href="/customers/<?= $h($c['id']) ?>"><?= $h(\Steuerkram\Models\Customer::displayName($c)) ?></a>
<?php if ($c['archived']): ?><span class="badge badge-gray">Archiviert</span><?php endif; ?>
</td>
<td class="td-muted text-sm"><?= $h($c['plz']) ?> <?= $h($c['ort']) ?></td>
<td class="td-muted text-sm"><?= $h($c['email'] ?? '–') ?></td>
<td class="td-muted text-sm"><?= $h($c['ust_id'] ?? '–') ?></td>
<td class="td-muted"><?= $h($c['payment_days']) ?> Tage</td>
<td>
<div style="display:flex;gap:.5rem">
<a href="/customers/<?= $h($c['id']) ?>/edit" class="btn btn-secondary btn-sm">Bearbeiten</a>
<a href="/invoices/new?customer_id=<?= $h($c['id']) ?>" class="btn btn-primary btn-sm">Rechnung</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($customers)): ?>
<tr><td colspan="7" class="text-center text-muted" style="padding:2rem">Keine Kunden gefunden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,58 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div style="display:grid;grid-template-columns:340px 1fr;gap:1.5rem;align-items:start">
<div class="card">
<div class="card-header">
<div class="card-title">Kundendaten</div>
<a href="/customers/<?= $h($customer['id']) ?>/edit" class="btn btn-secondary btn-sm">Bearbeiten</a>
</div>
<div class="card-body">
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<?php $row = fn($label, $val) => "<tr><td style='color:var(--color-muted);padding:.35rem 0;width:120px'>{$label}</td><td>{$val}</td></tr>"; ?>
<?= $row('Kundennr.', $h($customer['kundennummer'])) ?>
<?= $row('Name', $h(\Steuerkram\Models\Customer::displayName($customer))) ?>
<?php if ($customer['firma']): ?><?= $row('Firma', $h($customer['firma'])) ?><?php endif; ?>
<?= $row('Adresse', $h($customer['strasse']) . '<br>' . $h($customer['plz'] . ' ' . $customer['ort'])) ?>
<?= $row('E-Mail', $customer['email'] ? '<a href="mailto:'.$h($customer['email']).'">'.$h($customer['email']).'</a>' : '–') ?>
<?= $row('Telefon', $h($customer['telefon'] ?? '–')) ?>
<?= $row('USt-ID', $h($customer['ust_id'] ?? '–')) ?>
<?= $row('Zahlungsziel', $h($customer['payment_days']) . ' Tage') ?>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">Rechnungen</div>
<a href="/invoices/new?customer_id=<?= $h($customer['id']) ?>" class="btn btn-primary btn-sm">+ Neue Rechnung</a>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Rechnungsnr.</th><th>Datum</th><th>Betreff</th><th class="td-right">Brutto</th><th>Status</th></tr>
</thead>
<tbody>
<?php foreach ($invoices as $inv): ?>
<tr>
<td><a href="/invoices/<?= $h($inv['id']) ?>"><?= $h($inv['rechnungs_nr'] ?? 'Entwurf') ?></a></td>
<td class="td-muted"><?= View::formatDate($inv['rechnungs_datum']) ?></td>
<td><?= $h(mb_strimwidth($inv['betreff'], 0, 40, '…')) ?></td>
<td class="td-right td-mono"><?= View::formatMoney($inv['betrag_brutto']) ?></td>
<td><?php
$bc = match($inv['status']) {
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
default => 'badge-gray',
};
?><span class="badge <?= $bc ?>"><?= $h($inv['status']) ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($invoices)): ?>
<tr><td colspan="5" class="text-center text-muted" style="padding:1.5rem">Noch keine Rechnungen.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>

177
src/Views/dashboard.php Normal file
View file

@ -0,0 +1,177 @@
<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
?>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Umsatz <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:var(--color-success)"><?= $fmt($stats['umsatz']) ?></div>
<div class="stat-meta">Betriebseinnahmen (netto)</div>
</div>
<div class="stat-card">
<div class="stat-label">Ausgaben <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:var(--color-danger)"><?= $fmt($stats['ausgaben']) ?></div>
<div class="stat-meta">Betriebsausgaben (netto)</div>
</div>
<div class="stat-card">
<div class="stat-label">Gewinn / Verlust <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:<?= $stats['gewinn'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
<?= $fmt($stats['gewinn']) ?>
</div>
<div class="stat-meta">EÜR-Ergebnis</div>
</div>
<div class="stat-card">
<div class="stat-label">Offene Rechnungen</div>
<div class="stat-value"><?= $h($stats['offene_rechnungen']) ?></div>
<div class="stat-meta"><?= $fmt($stats['offene_rechnungen_sum']) ?> ausstehend</div>
</div>
<div class="stat-card">
<div class="stat-label">Ungebuchte Belege</div>
<div class="stat-value" style="color:<?= $stats['ungebuchte_belege'] > 0 ? 'var(--color-warning)' : 'inherit' ?>">
<?= $h($stats['ungebuchte_belege']) ?>
</div>
<div class="stat-meta">
<?php if ($stats['ungebuchte_belege'] > 0): ?>
<a href="/belege?booked=0">Belege buchen →</a>
<?php else: ?>
Alle Belege gebucht
<?php endif; ?>
</div>
</div>
</div>
<!-- Drag & Drop Upload -->
<div id="dnd-zone" style="
border:2px dashed var(--color-border);
border-radius:var(--radius);
background:var(--color-surface);
padding:2rem;
text-align:center;
margin-bottom:1.5rem;
cursor:pointer;
transition:border-color .15s, background .15s;
position:relative;
">
<input type="file" id="dnd-file-input" accept=".pdf,.png,.jpg,.jpeg,.tif,.tiff,.xml"
style="position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%">
<div id="dnd-icon" style="font-size:2rem;line-height:1;margin-bottom:.5rem;pointer-events:none">📎</div>
<div style="font-weight:600;pointer-events:none">Beleg hierher ziehen oder klicken</div>
<div class="text-muted text-sm" style="pointer-events:none">PDF, PNG, JPEG, TIFF, XML · max. 20 MB</div>
<div id="dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
</div>
<!-- Schnellzugriff -->
<div style="display:flex;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap">
<a href="/belege/upload" class="btn btn-primary">+ Beleg hochladen</a>
<a href="/invoices/new" class="btn btn-secondary">+ Rechnung erstellen</a>
<a href="/tax/ustva" class="btn btn-secondary">UStVA erstellen</a>
<a href="/tax/euer/<?= date('Y') ?>" class="btn btn-secondary">EÜR <?= date('Y') ?></a>
</div>
<script>
(function () {
const zone = document.getElementById('dnd-zone');
const input = document.getElementById('dnd-file-input');
const status = document.getElementById('dnd-status');
const icon = document.getElementById('dnd-icon');
function setActive(on) {
zone.style.borderColor = on ? 'var(--color-primary)' : 'var(--color-border)';
zone.style.background = on ? '#eff6ff' : 'var(--color-surface)';
}
function setStatus(msg, color) {
status.innerHTML = `<span style="color:${color || 'var(--color-muted)'}">${msg}</span>`;
}
zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); });
zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) setActive(false); });
zone.addEventListener('drop', (e) => {
e.preventDefault();
setActive(false);
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
});
input.addEventListener('change', () => {
if (input.files[0]) uploadFile(input.files[0]);
input.value = '';
});
function uploadFile(file) {
const allowed = ['application/pdf','image/png','image/jpeg','image/tiff',
'application/xml','text/xml'];
if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|png|jpe?g|tif{1,2}|xml)$/i)) {
setStatus('Nicht unterstütztes Dateiformat.', 'var(--color-danger)');
return;
}
icon.textContent = '⏳';
setStatus('Wird hochgeladen…');
zone.style.pointerEvents = 'none';
const fd = new FormData();
fd.append('beleg_file', file);
fd.append('belegtyp', 'Eingangsrechnung');
fd.append('eingangs_datum', new Date().toISOString().slice(0,10));
fd.append('beleg_datum', new Date().toISOString().slice(0,10));
fetch('/belege/upload', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd,
})
.then(async (res) => {
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Unbekannter Fehler');
icon.textContent = '✅';
setStatus(`${data.beleg_nr} hochgeladen – weiterleiten…`, 'var(--color-success)');
setTimeout(() => { window.location.href = '/belege/' + data.id; }, 600);
})
.catch((err) => {
icon.textContent = '📎';
zone.style.pointerEvents = '';
setStatus('Fehler: ' + err.message, 'var(--color-danger)');
});
}
})();
</script>
<!-- Letzte Buchungen -->
<div class="card">
<div class="card-header">
<div class="card-title">Letzte Buchungen</div>
<a href="/journal" class="btn btn-secondary btn-sm">Alle anzeigen →</a>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Datum</th>
<th>Belegnr.</th>
<th>Soll</th>
<th>Haben</th>
<th>Buchungstext</th>
<th class="td-right">Brutto</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentBookings as $b): ?>
<tr>
<td class="td-muted"><?= View::formatDate($b['buchungs_datum']) ?></td>
<td><a href="/belege/<?= $h($b['beleg_id']) ?>"><?= $h($b['beleg_nr']) ?></a></td>
<td class="td-mono"><?= $h($b['soll_nr']) ?> <span class="text-muted">– <?= $h($b['soll_name']) ?></span></td>
<td class="td-mono"><?= $h($b['haben_nr']) ?> <span class="text-muted">– <?= $h($b['haben_name']) ?></span></td>
<td><?= $h($b['buchungstext']) ?><?php if ($b['is_reversal']): ?> <span class="badge badge-red">Storno</span><?php endif; ?></td>
<td class="td-right td-mono"><?= $fmt($b['betrag_brutto']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recentBookings)): ?>
<tr><td colspan="6" class="text-center text-muted" style="padding:2rem">Noch keine Buchungen vorhanden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

152
src/Views/invoices/form.php Normal file
View file

@ -0,0 +1,152 @@
<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$isEdit = !empty($invoice['id']);
// Pre-select customer if passed via GET
$preCustomer = (int)($_GET['customer_id'] ?? 0);
?>
<div class="card">
<div class="card-body">
<form method="post" action="<?= $h($action) ?>">
<div class="form-grid form-grid-2 mb-2">
<div class="form-group">
<label for="customer_id">Kunde *</label>
<select id="customer_id" name="customer_id"
class="<?= isset($errors['customer_id']) ? 'input-error' : '' ?>" required
onchange="updatePaymentDays(this)">
<option value="">– Kunde wählen –</option>
<?php foreach ($customers as $c): ?>
<?php $selected = ($invoice['customer_id'] ?? $preCustomer) == $c['id'] ? ' selected' : ''; ?>
<option value="<?= $c['id'] ?>"<?= $selected ?>
data-payment="<?= $c['payment_days'] ?>">
<?= $h($c['kundennummer']) ?> – <?= $h(\Steuerkram\Models\Customer::displayName($c)) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['customer_id'])): ?><div class="error-text"><?= $h($errors['customer_id']) ?></div><?php endif; ?>
</div>
<div class="form-group">
<label for="betreff">Betreff *</label>
<input type="text" id="betreff" name="betreff"
value="<?= $h($invoice['betreff'] ?? '') ?>"
class="<?= isset($errors['betreff']) ? 'input-error' : '' ?>" required>
<?php if (isset($errors['betreff'])): ?><div class="error-text"><?= $h($errors['betreff']) ?></div><?php endif; ?>
</div>
</div>
<div class="form-grid form-grid-4 mb-2">
<div class="form-group">
<label for="rechnungs_datum">Rechnungsdatum *</label>
<input type="date" id="rechnungs_datum" name="rechnungs_datum"
value="<?= $h($invoice['rechnungs_datum'] ?? date('Y-m-d')) ?>" required>
</div>
<div class="form-group">
<label for="leistungszeitraum_von">Leistungszeitraum von</label>
<input type="date" id="leistungszeitraum_von" name="leistungszeitraum_von"
value="<?= $h($invoice['leistungszeitraum_von'] ?? '') ?>">
</div>
<div class="form-group">
<label for="leistungszeitraum_bis">Leistungszeitraum bis</label>
<input type="date" id="leistungszeitraum_bis" name="leistungszeitraum_bis"
value="<?= $h($invoice['leistungszeitraum_bis'] ?? '') ?>">
</div>
<div class="form-group">
<label for="payment_days">Zahlungsziel (Tage)</label>
<input type="number" id="payment_days" name="payment_days"
value="<?= $h($invoice['payment_days'] ?? $settings['payment_days_default'] ?? 14) ?>"
min="0">
</div>
</div>
<!-- Positionen-Tabelle -->
<div class="mb-2">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem">
<strong>Rechnungspositionen</strong>
<button type="button" class="btn btn-secondary btn-sm" onclick="addInvoiceLine()">+ Position hinzufügen</button>
</div>
<?php if (isset($errors['lines'])): ?>
<div class="flash flash-error mb-2"><?= $h($errors['lines']) ?></div>
<?php endif; ?>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:40px">Pos.</th>
<th>Bezeichnung</th>
<th style="width:80px">Menge</th>
<th style="width:80px">Einheit</th>
<th style="width:80px">USt.</th>
<th style="width:110px">Einzelpreis</th>
<th style="width:100px" class="td-right">Netto</th>
<th style="width:110px" class="td-right">Brutto</th>
<th style="width:40px"></th>
</tr>
</thead>
<tbody id="invoice-lines-body">
<?php foreach ($lines as $i => $line): ?>
<tr class="invoice-line-row">
<td><span class="line-pos text-muted"><?= $i+1 ?></span></td>
<td><input type="text" name="lines[<?= $i ?>][bezeichnung]" value="<?= $h($line['bezeichnung']) ?>" required style="min-width:200px"></td>
<td><input type="number" name="lines[<?= $i ?>][menge]" value="<?= $h($line['menge']) ?>" min="0.001" step="0.001" style="width:75px" required></td>
<td>
<select name="lines[<?= $i ?>][einheit]" style="width:75px">
<?php foreach (['Stk.','h','Tage','Monat','Pauschale','km'] as $eu): ?>
<option value="<?= $eu ?>"<?= $line['einheit'] === $eu ? ' selected' : '' ?>><?= $eu ?></option>
<?php endforeach; ?>
</select>
</td>
<td>
<select name="lines[<?= $i ?>][ust_satz]" style="width:75px">
<?php foreach ([19, 7, 0] as $r): ?>
<option value="<?= $r ?>"<?= (float)$line['ust_satz'] === (float)$r ? ' selected' : '' ?>><?= $r ?> %</option>
<?php endforeach; ?>
</select>
</td>
<td><input type="number" name="lines[<?= $i ?>][einzelpreis]" value="<?= $h($line['einzelpreis']) ?>" step="0.01" min="0" style="width:105px" required></td>
<td class="td-right line-netto text-muted"><?= View::formatMoney($line['betrag_netto']) ?></td>
<td class="td-right line-brutto font-bold"><?= View::formatMoney($line['betrag_brutto']) ?></td>
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Summen -->
<div style="display:flex;justify-content:flex-end;margin-top:.75rem">
<table style="width:280px;font-size:.9rem">
<tr><td>Nettobetrag:</td><td class="td-right" id="total-netto">–</td></tr>
<tr><td>zzgl. Umsatzsteuer:</td><td class="td-right" id="total-ust">–</td></tr>
<tr style="font-size:1rem;font-weight:700">
<td>Rechnungsbetrag:</td>
<td class="td-right" id="total-brutto">–</td>
</tr>
</table>
</div>
</div>
<div class="form-group">
<label for="notes">Interne Notizen</label>
<textarea id="notes" name="notes" rows="2"><?= $h($invoice['notes'] ?? '') ?></textarea>
</div>
<div style="display:flex;gap:.75rem;margin-top:1rem">
<button type="submit" class="btn btn-primary">Entwurf speichern</button>
<a href="/invoices" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
<script>
function updatePaymentDays(sel) {
const opt = sel.options[sel.selectedIndex];
const days = opt.dataset.payment;
if (days) document.getElementById('payment_days').value = days;
}
</script>

View file

@ -0,0 +1,88 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="toolbar">
<div class="toolbar-left">
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
<input type="search" name="search" placeholder="Rechnungsnr., Betreff, Kunde…"
value="<?= $h($filter['search'] ?? '') ?>" style="width:240px">
<select name="status">
<option value="">Alle Status</option>
<?php foreach (['Entwurf','Gesendet','Bezahlt','Storniert'] as $s): ?>
<option value="<?= $s ?>"<?= ($filter['status'] ?? '') === $s ? ' selected' : '' ?>><?= $s ?></option>
<?php endforeach; ?>
</select>
<select name="year">
<option value="">Alle Jahre</option>
<?php for ($y = (int)date('Y'); $y >= 2020; $y--): ?>
<option value="<?= $y ?>"<?= ($filter['year'] ?? '') == $y ? ' selected' : '' ?>><?= $y ?></option>
<?php endfor; ?>
</select>
<button type="submit" class="btn btn-secondary">Filtern</button>
<a href="/invoices" class="btn btn-secondary">✕</a>
</form>
</div>
<div class="toolbar-right"><span class="text-muted text-sm"><?= $total ?> Rechnungen</span></div>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Rechnungsnr.</th>
<th>Datum</th>
<th>Kunde</th>
<th>Betreff</th>
<th class="td-right">Netto</th>
<th class="td-right">Brutto</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $inv): ?>
<?php
$badgeClass = match($inv['status']) {
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
default => 'badge-gray',
};
$customerName = !empty($inv['firma']) ? $inv['firma'] : trim(($inv['vorname'] ?? '') . ' ' . $inv['nachname']);
?>
<tr>
<td><a href="/invoices/<?= $h($inv['id']) ?>" class="td-mono"><?= $h($inv['rechnungs_nr'] ?? 'Entwurf') ?></a></td>
<td class="td-muted"><?= View::formatDate($inv['rechnungs_datum']) ?></td>
<td><a href="/customers/<?= $h($inv['customer_id']) ?>"><?= $h($customerName) ?></a></td>
<td><?= $h(mb_strimwidth($inv['betreff'], 0, 40, '…')) ?></td>
<td class="td-right td-mono"><?= View::formatMoney($inv['betrag_netto']) ?></td>
<td class="td-right td-mono"><?= View::formatMoney($inv['betrag_brutto']) ?></td>
<td><span class="badge <?= $badgeClass ?>"><?= $h($inv['status']) ?></span></td>
<td>
<div style="display:flex;gap:.4rem">
<a href="/invoices/<?= $h($inv['id']) ?>" class="btn btn-secondary btn-sm">Öffnen</a>
<?php if ($inv['status'] !== 'Entwurf'): ?>
<a href="/invoices/<?= $h($inv['id']) ?>/pdf" class="btn btn-secondary btn-sm">PDF</a>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="8" class="text-center text-muted" style="padding:2rem">Keine Rechnungen gefunden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php if ($pages > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page-1])) ?>">‹</a><?php endif; ?>
<?php for ($p = max(1,$page-3); $p <= min($pages,$page+3); $p++): ?>
<?php if ($p === $page): ?><span class="active"><?= $p ?></span>
<?php else: ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $p])) ?>"><?= $p ?></a><?php endif; ?>
<?php endfor; ?>
<?php if ($page < $pages): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page+1])) ?>">›</a><?php endif; ?>
</div>
<?php endif; ?>

186
src/Views/invoices/show.php Normal file
View file

@ -0,0 +1,186 @@
<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
$customerName = !empty($invoice['firma'])
? $invoice['firma']
: trim(($invoice['anrede'] ?? '') . ' ' . ($invoice['vorname'] ?? '') . ' ' . $invoice['nachname']);
$lz = '';
if (!empty($invoice['leistungszeitraum_von'])) {
$lz = View::formatDate($invoice['leistungszeitraum_von']);
if (!empty($invoice['leistungszeitraum_bis'])) {
$lz .= ' – ' . View::formatDate($invoice['leistungszeitraum_bis']);
}
}
$badgeClass = match($invoice['status']) {
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
default => 'badge-gray',
};
?>
<!-- Aktionsleiste -->
<div class="toolbar mb-2">
<div class="toolbar-left">
<span class="badge <?= $badgeClass ?>" style="font-size:.9rem;padding:.3rem .8rem"><?= $h($invoice['status']) ?></span>
</div>
<div class="toolbar-right" style="gap:.5rem">
<?php if ($invoice['status'] === 'Entwurf'): ?>
<a href="/invoices/<?= $h($invoice['id']) ?>/edit" class="btn btn-secondary">Bearbeiten</a>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/finalize"
data-confirm="Rechnung finalisieren? Danach kann sie nicht mehr bearbeitet werden.">
<button type="submit" class="btn btn-primary">Finalisieren &amp; Nummerieren</button>
</form>
<?php endif; ?>
<?php if ($invoice['status'] !== 'Entwurf'): ?>
<a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
<?php endif; ?>
<?php if ($invoice['status'] === 'Gesendet' && empty($invoice['beleg_id'])): ?>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/book"
data-confirm="Rechnung jetzt buchen?">
<button type="submit" class="btn btn-success">Buchen</button>
</form>
<?php endif; ?>
<?php if ($invoice['status'] === 'Gesendet'): ?>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/mark-paid">
<button type="submit" class="btn btn-success">Als bezahlt markieren</button>
</form>
<?php endif; ?>
<?php if (in_array($invoice['status'], ['Entwurf','Gesendet'])): ?>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
data-confirm="Rechnung stornieren?">
<button type="submit" class="btn btn-danger">Stornieren</button>
</form>
<?php endif; ?>
</div>
</div>
<!-- Rechnungsvorschau (DIN 5008 ähnlich) -->
<div class="invoice-preview">
<!-- Logo -->
<?php if (!empty($settings['logo_path'])): ?>
<div style="text-align:center;margin-bottom:1.5rem">
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo" style="max-height:60px;max-width:250px">
</div>
<?php endif; ?>
<!-- Adressbereich -->
<div style="display:grid;grid-template-columns:100mm 1fr;gap:1rem;margin-bottom:1.5rem">
<div>
<div style="font-size:8pt;color:#666;border-bottom:1px solid #ccc;padding-bottom:2px;margin-bottom:4px">
<?= $h($settings['firma'] ?? '') ?> · <?= $h($settings['strasse'] ?? '') ?> · <?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?>
</div>
<div style="line-height:1.5">
<?= $h($customerName) ?><br>
<?= $h($invoice['strasse']) ?><br>
<?= $h($invoice['plz']) ?> <?= $h($invoice['ort']) ?>
<?php if ($invoice['land'] !== 'DE'): ?><br><?= $h($invoice['land']) ?><?php endif; ?>
</div>
</div>
<div style="text-align:left;font-size:.85rem;line-height:1.7">
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
<?= $h($settings['inhaber'] ?? '') ?><br>
<?= $h($settings['strasse'] ?? '') ?><br>
<?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?><br>
<?= $h($settings['telefon'] ?? '') ?><br>
<?= $h($settings['email'] ?? '') ?><br>
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
</div>
</div>
<!-- Meta-Tabelle -->
<table style="width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
<thead>
<tr style="background:#f0f0f0">
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Betreff</th>
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Rechnungsnummer</th>
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Kundennummer</th>
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Leistungszeitraum</th>
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Datum</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $h($invoice['betreff']) ?></td>
<td style="padding:.4rem .5rem;border:1px solid #ddd;font-weight:700"><?= $h($invoice['rechnungs_nr'] ?? 'Entwurf') ?></td>
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $h($invoice['kundennummer']) ?></td>
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $h($lz ?: '–') ?></td>
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= View::formatDate($invoice['rechnungs_datum']) ?></td>
</tr>
</tbody>
</table>
<!-- Positionen -->
<table style="width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
<thead>
<tr style="background:#1e293b;color:#fff">
<th style="padding:.5rem;text-align:left;width:40px">Pos.</th>
<th style="padding:.5rem;text-align:left">Bezeichnung</th>
<th style="padding:.5rem;text-align:right;width:70px">Menge</th>
<th style="padding:.5rem;text-align:left;width:70px">Einheit</th>
<th style="padding:.5rem;text-align:right;width:60px">USt.</th>
<th style="padding:.5rem;text-align:right;width:100px">Einzelpreis</th>
<th style="padding:.5rem;text-align:right;width:110px">Gesamt</th>
</tr>
</thead>
<tbody>
<?php foreach ($lines as $line): ?>
<tr style="border-bottom:1px solid #eee">
<td style="padding:.4rem .5rem"><?= $h($line['position']) ?></td>
<td style="padding:.4rem .5rem"><?= $h($line['bezeichnung']) ?></td>
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['menge'], 3, ',', '.') ?></td>
<td style="padding:.4rem .5rem"><?= $h($line['einheit']) ?></td>
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['ust_satz'], 0) ?> %</td>
<td style="padding:.4rem .5rem;text-align:right"><?= $fmt($line['einzelpreis']) ?></td>
<td style="padding:.4rem .5rem;text-align:right;font-weight:600"><?= $fmt($line['betrag_brutto']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<!-- Summen -->
<div style="display:flex;justify-content:flex-end;margin-bottom:1.5rem">
<table style="width:260px">
<tr><td style="padding:.3rem 0">Nettobetrag:</td><td style="text-align:right"><?= $fmt($invoice['betrag_netto']) ?></td></tr>
<tr><td style="padding:.3rem 0">zzgl. Umsatzsteuer:</td><td style="text-align:right"><?= $fmt($invoice['betrag_ust']) ?></td></tr>
<tr style="font-weight:700;font-size:1.05rem;border-top:2px solid #000">
<td style="padding:.5rem 0">Rechnungsbetrag:</td>
<td style="text-align:right"><?= $fmt($invoice['betrag_brutto']) ?></td>
</tr>
</table>
</div>
<!-- Zahlungshinweis -->
<div style="background:#f0f4ff;border-left:3px solid #2563eb;padding:.75rem 1rem;margin-bottom:2rem;font-size:.9rem">
Bitte überweisen Sie den Rechnungsbetrag innerhalb <?= $h($invoice['payment_days'] ?? 14) ?> Tagen auf das unten genannte Konto.
<strong>Fällig am: <?= View::formatDate($invoice['faellig_datum']) ?></strong>
</div>
<!-- Fußzeile -->
<div style="border-top:1px solid #ccc;padding-top:.75rem;display:grid;grid-template-columns:1fr 1fr 1fr 80px;gap:1rem;font-size:.75rem;color:#555">
<div>
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
<?= $h($settings['inhaber'] ?? '') ?><br>
<?= $h($settings['strasse'] ?? '') ?><br>
<?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?><br>
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
</div>
<div>
Tel: <?= $h($settings['telefon'] ?? '') ?><br>
E-Mail: <?= $h($settings['email'] ?? '') ?><br>
Web: <?= $h($settings['website'] ?? '') ?>
</div>
<div>
<?= $h($settings['bank_name'] ?? '') ?><br>
IBAN: <?= $h($settings['iban'] ?? '') ?><br>
BIC: <?= $h($settings['bic'] ?? '') ?>
</div>
<div style="text-align:right">Seite 1 von 1</div>
</div>
</div>

8
src/Views/layout/404.php Normal file
View file

@ -0,0 +1,8 @@
<?php
$pageTitle = '404 – Seite nicht gefunden';
$content = '<div class="card card-body text-center mt-3">
<h2 style="font-size:3rem;color:var(--color-muted);margin-bottom:.5rem">404</h2>
<p class="text-muted mb-2">Die Seite <strong>' . \Steuerkram\View::h($uri ?? '') . '</strong> wurde nicht gefunden.</p>
<a href="/dashboard" class="btn btn-primary">Zum Dashboard</a>
</div>';
include __DIR__ . '/base.php';

41
src/Views/layout/base.php Normal file
View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= \Steuerkram\View::h($pageTitle ?? 'Steuerkram') ?> – Steuerkram</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div class="app-layout">
<?php include __DIR__ . '/nav.php'; ?>
<div class="main-content">
<div class="topbar">
<div class="topbar-title"><?= \Steuerkram\View::h($pageTitle ?? '') ?></div>
<div style="display:flex;gap:.5rem;align-items:center;">
<?php if (!empty($topbarActions)) echo $topbarActions; ?>
</div>
</div>
<div class="page-body">
<?php if (!empty($flash)): ?>
<div class="flash-list">
<?php foreach ($flash as $f): ?>
<div class="flash flash-<?= \Steuerkram\View::h($f['type']) ?>">
<?= \Steuerkram\View::h($f['message']) ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?= $content ?? '' ?>
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>

72
src/Views/layout/nav.php Normal file
View file

@ -0,0 +1,72 @@
<?php
$path = $currentPath ?? '/';
function navActive(string $prefix, string $current): string {
return str_starts_with($current, $prefix) ? ' active' : '';
}
?>
<aside class="sidebar">
<div class="sidebar-brand">Steuer<span>kram</span></div>
<div class="sidebar-section">
<div class="sidebar-label">Übersicht</div>
<nav>
<a href="/dashboard" class="<?= navActive('/dashboard', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Dashboard
</a>
</nav>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Belege &amp; Buchungen</div>
<nav>
<a href="/belege" class="<?= navActive('/belege', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
Belege
</a>
<a href="/journal" class="<?= navActive('/journal', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
Buchungsjournal
</a>
</nav>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Ausgangsrechnungen</div>
<nav>
<a href="/invoices" class="<?= navActive('/invoices', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>
Rechnungen
</a>
<a href="/customers" class="<?= navActive('/customers', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Kunden
</a>
</nav>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Steuerauswertungen</div>
<nav>
<a href="/tax/ustva" class="<?= navActive('/tax/ustva', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 14l6-6"/><circle cx="9.5" cy="9.5" r=".5" fill="currentColor"/><circle cx="14.5" cy="14.5" r=".5" fill="currentColor"/><path d="M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9 9 4.03 9 9z"/></svg>
UStVA
</a>
<a href="/tax/euer" class="<?= navActive('/tax/euer', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
EÜR
</a>
</nav>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Verwaltung</div>
<nav>
<a href="/settings" class="<?= navActive('/settings', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Einstellungen
</a>
</nav>
</div>
</aside>

299
src/Views/settings/form.php Normal file
View file

@ -0,0 +1,299 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<form method="post" action="/settings">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
<!-- Firmendaten -->
<div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Firmendaten</div></div>
<div class="card-body">
<div class="form-group">
<label>Firma / Name *</label>
<input type="text" name="firma" value="<?= $h($settings['firma'] ?? '') ?>"
class="<?= isset($errors['firma']) ? 'input-error' : '' ?>" required>
<?php if (isset($errors['firma'])): ?><div class="error-text"><?= $h($errors['firma']) ?></div><?php endif; ?>
</div>
<div class="form-group">
<label>Inhaber</label>
<input type="text" name="inhaber" value="<?= $h($settings['inhaber'] ?? '') ?>">
</div>
<div class="form-group">
<label>Straße *</label>
<input type="text" name="strasse" value="<?= $h($settings['strasse'] ?? '') ?>" required>
</div>
<div style="display:grid;grid-template-columns:100px 1fr;gap:.5rem">
<div class="form-group">
<label>PLZ *</label>
<input type="text" name="plz" value="<?= $h($settings['plz'] ?? '') ?>" required>
</div>
<div class="form-group">
<label>Ort *</label>
<input type="text" name="ort" value="<?= $h($settings['ort'] ?? '') ?>" required>
</div>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Telefon</label>
<input type="tel" name="telefon" value="<?= $h($settings['telefon'] ?? '') ?>">
</div>
<div class="form-group">
<label>E-Mail</label>
<input type="email" name="email" value="<?= $h($settings['email'] ?? '') ?>">
</div>
</div>
<div class="form-group">
<label>Website</label>
<input type="url" name="website" value="<?= $h($settings['website'] ?? '') ?>">
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Steuerdaten</div></div>
<div class="card-body">
<div class="form-group">
<label>Umsatzsteuer-ID</label>
<input type="text" name="ust_id" value="<?= $h($settings['ust_id'] ?? '') ?>" placeholder="DE123456789">
</div>
<div class="form-group">
<label>Steuernummer (Finanzamt)</label>
<input type="text" name="steuernummer" value="<?= $h($settings['steuernummer'] ?? '') ?>">
</div>
<div class="form-group">
<label>Finanzamt</label>
<input type="text" name="finanzamt" value="<?= $h($settings['finanzamt'] ?? '') ?>">
</div>
<div class="form-group">
<label>ELSTER-Steuernummer (13-stellig, Format: FFFBBBBBBUUUUP)</label>
<input type="text" name="ustva_steuernummer" value="<?= $h($settings['ustva_steuernummer'] ?? '') ?>"
placeholder="z.B. 2893012345678">
<div class="form-hint">Wird für die ELSTER XML-Datei benötigt</div>
</div>
<div class="form-group">
<label>Bundesland (für ELSTER)</label>
<select name="ustva_bundesland">
<option value="">– wählen –</option>
<?php foreach (['BW'=>'Baden-Württemberg','BY'=>'Bayern','BE'=>'Berlin','BB'=>'Brandenburg','HB'=>'Bremen','HH'=>'Hamburg','HE'=>'Hessen','MV'=>'Mecklenburg-Vorpommern','NI'=>'Niedersachsen','NW'=>'Nordrhein-Westfalen','RP'=>'Rheinland-Pfalz','SL'=>'Saarland','SN'=>'Sachsen','ST'=>'Sachsen-Anhalt','SH'=>'Schleswig-Holstein','TH'=>'Thüringen'] as $code => $name): ?>
<option value="<?= $code ?>"<?= ($settings['ustva_bundesland'] ?? '') === $code ? ' selected' : '' ?>><?= $name ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
</div>
<!-- Bankdaten + Logo + Nummernkreise -->
<div>
<button type="submit" class="btn btn-primary btn-lg w-full" style="margin-bottom:1rem">Einstellungen speichern</button>
<!-- Betriebsmodus -->
<div class="card mb-2" style="border:2px solid <?= ($settings['test_mode'] ?? 1) ? 'var(--color-warning,#f59e0b)' : 'var(--color-success,#22c55e)' ?>">
<div class="card-header">
<div class="card-title">Betriebsmodus</div>
<?php if ($settings['test_mode'] ?? 1): ?>
<span class="badge badge-yellow">Testinstallation</span>
<?php else: ?>
<span class="badge badge-green">Produktiv</span>
<?php endif; ?>
</div>
<div class="card-body">
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;user-select:none">
<div style="position:relative;width:48px;height:26px;flex-shrink:0">
<input type="checkbox" name="test_mode" id="test_mode_toggle"
<?= ($settings['test_mode'] ?? 1) ? 'checked' : '' ?>
style="opacity:0;width:0;height:0;position:absolute"
onchange="updateModeLabel(this)">
<span id="toggle_track" style="
position:absolute;inset:0;border-radius:13px;transition:background .2s;
background:<?= ($settings['test_mode'] ?? 1) ? '#f59e0b' : '#22c55e' ?>;
"></span>
<span id="toggle_thumb" style="
position:absolute;top:3px;width:20px;height:20px;border-radius:50%;
background:#fff;transition:left .2s;box-shadow:0 1px 3px rgba(0,0,0,.3);
left:<?= ($settings['test_mode'] ?? 1) ? '3px' : '25px' ?>;
"></span>
</div>
<span id="mode_label" style="font-weight:600">
<?= ($settings['test_mode'] ?? 1) ? 'Testinstallation (Löschen von Buchungen erlaubt)' : 'Produktivbetrieb (GoBD-strikt)' ?>
</span>
</label>
<div class="form-hint mt-1" id="mode_hint" style="color:<?= ($settings['test_mode'] ?? 1) ? '#92400e' : '#166534' ?>">
<?php if ($settings['test_mode'] ?? 1): ?>
Im Testmodus können Buchungen und Belege direkt gelöscht werden. Nicht GoBD-konform.
<?php else: ?>
Im Produktivmodus sind Buchungen unveränderlich. Korrekturen nur per Stornobuchung moeglich.
<?php endif; ?>
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Bankdaten</div></div>
<div class="card-body">
<div class="form-group">
<label>Bankname</label>
<input type="text" name="bank_name" value="<?= $h($settings['bank_name'] ?? '') ?>">
</div>
<div class="form-group">
<label>IBAN</label>
<input type="text" name="iban" value="<?= $h($settings['iban'] ?? '') ?>" placeholder="DE89 3704 0044 0532 0130 00">
</div>
<div class="form-group">
<label>BIC</label>
<input type="text" name="bic" value="<?= $h($settings['bic'] ?? '') ?>">
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Logo</div></div>
<div class="card-body">
<?php if (!empty($settings['logo_path'])): ?>
<div style="text-align:center;margin-bottom:1rem">
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo" style="max-height:80px;max-width:200px">
</div>
<form method="post" action="/settings/logo/delete" style="margin-bottom:1rem">
<button type="submit" class="btn btn-danger btn-sm w-full">Logo löschen</button>
</form>
<?php endif; ?>
<form method="post" action="/settings/logo" enctype="multipart/form-data">
<div class="form-group">
<label>Logo hochladen (PNG, JPG, SVG)</label>
<input type="file" name="logo" accept=".png,.jpg,.jpeg,.svg">
<div class="form-hint">Empfohlen: transparentes PNG, mind. 300px breit</div>
</div>
<button type="submit" class="btn btn-secondary">Logo hochladen</button>
</form>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Nummernkreise</div></div>
<div class="card-body">
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Rechnungs-Präfix</label>
<input type="text" name="invoice_nr_prefix" value="<?= $h($settings['invoice_nr_prefix'] ?? 'AR') ?>" maxlength="10">
</div>
<div class="form-group">
<label>Beleg-Präfix</label>
<input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10">
</div>
</div>
<div class="form-group">
<label>Standard-Zahlungsziel (Tage)</label>
<input type="number" name="payment_days_default" value="<?= $h($settings['payment_days_default'] ?? 14) ?>" min="0">
</div>
</div>
</div>
</div>
</div>
</form>
<!-- ── Kontenrahmen ──────────────────────────────────────────── -->
<div class="card mt-2" style="margin-top:1.5rem">
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
<div class="card-title">Kontenrahmen (SKR03)</div>
<a href="/accounts/new?from=settings" class="btn btn-primary btn-sm">+ Konto anlegen</a>
</div>
<!-- Filter -->
<div style="padding:.75rem 1.25rem;border-bottom:1px solid var(--color-border);display:flex;gap:.5rem;flex-wrap:wrap">
<form method="get" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="search" name="search" placeholder="Kto.-Nr. oder Bezeichnung…"
value="<?= $h($filter['search'] ?? '') ?>" style="width:220px">
<select name="kontenart">
<option value="">Alle Kontenarten</option>
<?php foreach (['Aktiv','Passiv','Aufwand','Ertrag','Neutral'] as $art): ?>
<option value="<?= $h($art) ?>"<?= ($filter['kontenart'] ?? '') === $art ? ' selected' : '' ?>><?= $h($art) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button>
<?php if (!empty($filter['search']) || !empty($filter['kontenart'])): ?>
<a href="/settings" class="btn btn-secondary btn-sm">✕</a>
<?php endif; ?>
<span class="text-muted text-sm"><?= count($accounts) ?> Konten</span>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Kto.-Nr.</th>
<th>Bezeichnung</th>
<th>Kontenart</th>
<th>Steuercode</th>
<th>UStVA Kz</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($accounts as $acc):
$badgeClass = match($acc['kontenart']) {
'Ertrag' => 'badge-green',
'Aufwand' => 'badge-red',
'Aktiv' => 'badge-blue',
'Passiv' => 'badge-yellow',
default => 'badge-gray',
};
?>
<tr style="<?= $acc['aktiv'] ? '' : 'opacity:.45' ?>">
<td class="td-mono font-bold"><?= $h($acc['kontonummer']) ?></td>
<td><?= $h($acc['bezeichnung']) ?><?= $acc['aktiv'] ? '' : ' <span class="badge badge-gray" style="font-size:.7rem">inaktiv</span>' ?></td>
<td><span class="badge <?= $badgeClass ?>"><?= $h($acc['kontenart']) ?></span></td>
<td class="td-muted"><?= $h($acc['steuercode'] ?? '–') ?></td>
<td class="td-muted"><?= $h($acc['ustva_kz'] ?? '–') ?></td>
<td>
<?php if ($settings['test_mode'] ?? 1): ?>
<div style="display:flex;gap:.35rem">
<a href="/accounts/<?= $h($acc['id']) ?>/edit?from=settings"
class="btn btn-secondary btn-sm">Bearbeiten</a>
<form method="post" action="/accounts/<?= $h($acc['id']) ?>/delete"
data-confirm="Konto '<?= $h($acc['kontonummer']) ?>' wirklich löschen?">
<input type="hidden" name="_back" value="/settings">
<button type="submit" class="btn btn-danger btn-sm">Löschen</button>
</form>
</div>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($accounts)): ?>
<tr><td colspan="6" class="text-center text-muted" style="padding:2rem">Keine Konten gefunden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>
function updateModeLabel(cb) {
const track = document.getElementById('toggle_track');
const thumb = document.getElementById('toggle_thumb');
const label = document.getElementById('mode_label');
const hint = document.getElementById('mode_hint');
if (cb.checked) {
track.style.background = '#f59e0b';
thumb.style.left = '3px';
label.textContent = 'Testinstallation (Löschen von Buchungen erlaubt)';
hint.textContent = 'Im Testmodus können Buchungen und Belege direkt gelöscht werden. Nicht GoBD-konform.';
hint.style.color = '#92400e';
cb.closest('.card').style.borderColor = '#f59e0b';
cb.closest('.card').querySelector('.badge').className = 'badge badge-yellow';
cb.closest('.card').querySelector('.badge').textContent = 'Testinstallation';
} else {
track.style.background = '#22c55e';
thumb.style.left = '25px';
label.textContent = 'Produktivbetrieb (GoBD-strikt)';
hint.textContent = 'Im Produktivmodus sind Buchungen unveränderlich. Korrekturen nur per Stornobuchung möglich.';
hint.style.color = '#166534';
cb.closest('.card').style.borderColor = '#22c55e';
cb.closest('.card').querySelector('.badge').className = 'badge badge-green';
cb.closest('.card').querySelector('.badge').textContent = 'Produktiv';
}
}
</script>

View file

@ -0,0 +1,12 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="max-width:400px">
<div class="card-header"><div class="card-title">Geschäftsjahr wählen</div></div>
<div class="card-body">
<div style="display:flex;flex-direction:column;gap:.75rem">
<?php foreach ($years as $y): ?>
<a href="/tax/euer/<?= $h($y) ?>" class="btn btn-secondary"><?= $h($y) ?></a>
<?php endforeach; ?>
</div>
</div>
</div>

View file

@ -0,0 +1,82 @@
<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
?>
<div class="card">
<div class="card-header">
<div class="card-title">EÜR Geschäftsjahr <?= $h($year) ?></div>
<div style="display:flex;gap:.5rem">
<a href="/tax/euer/<?= $h($year) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th colspan="3" style="background:#16a34a;color:#fff;text-align:center">Betriebseinnahmen</th>
<th style="width:20px;background:#f0f0f0"></th>
<th colspan="3" style="background:#dc2626;color:#fff;text-align:center">Betriebsausgaben</th>
</tr>
<tr>
<th style="width:80px">Kto.</th>
<th>Bezeichnung</th>
<th class="td-right" style="width:120px">Betrag</th>
<th style="background:#f0f0f0"></th>
<th style="width:80px">Kto.</th>
<th>Bezeichnung</th>
<th class="td-right" style="width:120px">Betrag</th>
</tr>
</thead>
<tbody>
<?php
$maxRows = max(count($euer['einnahmen']), count($euer['ausgaben']));
for ($i = 0; $i < $maxRows; $i++):
$e = $euer['einnahmen'][$i] ?? null;
$a = $euer['ausgaben'][$i] ?? null;
?>
<tr>
<?php if ($e): ?>
<td class="td-mono text-sm" style="color:var(--color-muted)"><?= $h($e['kontonummer']) ?></td>
<td><?= $h($e['bezeichnung']) ?></td>
<td class="td-right td-mono" style="color:var(--color-success)"><?= $fmt($e['netto']) ?></td>
<?php else: ?>
<td colspan="3"></td>
<?php endif; ?>
<td style="background:#f8f8f8"></td>
<?php if ($a): ?>
<td class="td-mono text-sm" style="color:var(--color-muted)"><?= $h($a['kontonummer']) ?></td>
<td><?= $h($a['bezeichnung']) ?></td>
<td class="td-right td-mono" style="color:var(--color-danger)"><?= $fmt($a['netto']) ?></td>
<?php else: ?>
<td colspan="3"></td>
<?php endif; ?>
</tr>
<?php endfor; ?>
</tbody>
<tfoot>
<tr style="background:#e8ecf0;font-weight:700;font-size:.95rem">
<td colspan="2">Summe Einnahmen</td>
<td class="td-right td-mono" style="color:var(--color-success)"><?= $fmt($euer['total_einnahmen']) ?></td>
<td style="background:#d0d7e0"></td>
<td colspan="2">Summe Ausgaben</td>
<td class="td-right td-mono" style="color:var(--color-danger)"><?= $fmt($euer['total_ausgaben']) ?></td>
</tr>
<tr style="font-weight:700;font-size:1.05rem;background:<?= $euer['gewinn'] >= 0 ? '#dcfce7' : '#fee2e2' ?>">
<td colspan="7" style="text-align:right;padding:1rem">
<?= $euer['gewinn'] >= 0 ? 'Überschuss (Gewinn)' : 'Fehlbetrag (Verlust)' ?>:
<span style="color:<?= $euer['gewinn'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>;font-size:1.2rem;margin-left:1rem">
<?= $fmt($euer['gewinn']) ?>
</span>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php if (empty($euer['einnahmen']) && empty($euer['ausgaben'])): ?>
<div class="flash flash-info mt-2">Für <?= $h($year) ?> wurden noch keine Buchungen erfasst.</div>
<?php endif; ?>

View file

@ -0,0 +1,81 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div style="display:grid;grid-template-columns:300px 1fr;gap:1.5rem;align-items:start">
<!-- Formular: Monat wählen -->
<div class="card">
<div class="card-header"><div class="card-title">UStVA erstellen</div></div>
<div class="card-body">
<form method="get" action="" id="ustva-nav-form">
<div class="form-group">
<label>Jahr</label>
<select name="year" id="ustva-year">
<?php foreach ($years as $y): ?>
<option value="<?= $y ?>"<?= $y == date('Y') ? ' selected' : '' ?>><?= $y ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Monat</label>
<select name="month" id="ustva-month">
<?php
$mnames = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
foreach ($mnames as $i => $m):
?>
<option value="<?= $i+1 ?>"<?= ($i+1) == (int)date('m') ? ' selected' : '' ?>><?= $m ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="button" class="btn btn-primary w-full" onclick="
var y=document.getElementById('ustva-year').value;
var m=document.getElementById('ustva-month').value;
window.location='/tax/ustva/'+y+'/'+m;
">UStVA anzeigen</button>
</form>
</div>
</div>
<!-- Liste vergangener Einreichungen -->
<div class="card">
<div class="card-header"><div class="card-title">Bisherige Voranmeldungen</div></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Zeitraum</th>
<th>Status</th>
<th>Erstellt</th>
<th>Eingereicht</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($submissions as $s): ?>
<?php $mnames = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; ?>
<tr>
<td><?= $mnames[$s['month']-1] ?> <?= $h($s['year']) ?></td>
<td>
<?php if ($s['status'] === 'Eingereicht'): ?>
<span class="badge badge-green">Eingereicht</span>
<?php else: ?>
<span class="badge badge-yellow">Erstellt</span>
<?php endif; ?>
</td>
<td class="td-muted text-sm"><?= $h($s['created_at']) ?></td>
<td class="td-muted text-sm"><?= $h($s['submitted_at'] ?? '–') ?></td>
<td>
<div style="display:flex;gap:.4rem">
<a href="/tax/ustva/<?= $h($s['year']) ?>/<?= $h($s['month']) ?>" class="btn btn-secondary btn-sm">Ansehen</a>
<a href="/tax/ustva/<?= $h($s['year']) ?>/<?= $h($s['month']) ?>/xml" class="btn btn-secondary btn-sm">XML</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($submissions)): ?>
<tr><td colspan="5" class="text-center text-muted" style="padding:2rem">Noch keine Voranmeldungen erstellt.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>

View file

@ -0,0 +1,137 @@
<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($v);
$fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' : '–';
?>
<div style="display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start">
<!-- Linke Seite: Kennzahlen-Tabelle -->
<div class="card">
<div class="card-header">
<div class="card-title">Kennzahlen – <?= $h($months[$month-1]) ?> <?= $h($year) ?></div>
<?php if (!empty($settings['ustva_steuernummer'])): ?>
<span class="text-muted text-sm">StNr: <?= $h($settings['ustva_steuernummer']) ?></span>
<?php else: ?>
<a href="/settings" class="badge badge-red">⚠ Steuernummer fehlt in Einstellungen</a>
<?php endif; ?>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Kz</th>
<th>Bezeichnung</th>
<th class="td-right">Bemessungsgrundlage</th>
<th class="td-right">Steuer</th>
</tr>
</thead>
<tbody>
<?php
$sections = [
'Steuerpflichtige Umsätze' => ['81', '86', '35', '41', '44'],
'Umsatzsteuer auf Umsätze' => ['83', '84'],
'Vorsteuerbeträge' => ['66', '61', '62'],
'Verbleibende Zahllast' => ['69'],
];
foreach ($sections as $sectionTitle => $kzNrs):
?>
<tr style="background:#f8fafc">
<td colspan="4" style="font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--color-muted);padding:.4rem 1rem">
<?= $h($sectionTitle) ?>
</td>
</tr>
<?php foreach ($kzNrs as $nr):
if (!isset($kz[$nr])) continue;
$field = $kz[$nr];
$isResult = in_array($nr, ['69']);
$isNegative = $isResult && ($field['steuer'] ?? 0) < 0;
?>
<tr style="<?= $isResult ? 'font-weight:700;background:#f0f4ff' : '' ?>">
<td class="td-mono" style="color:var(--color-muted);width:50px"><?= $h($nr) ?></td>
<td><?= $h($field['name']) ?></td>
<td class="td-right td-mono">
<?= $field['betrag'] !== null ? $fmtKz($field['betrag']) : '–' ?>
</td>
<td class="td-right td-mono" style="<?= $isNegative ? 'color:var(--color-success)' : ($isResult ? 'color:var(--color-danger)' : '') ?>">
<?= $field['steuer'] !== null ? $fmtKz($field['steuer']) : '–' ?>
</td>
</tr>
<?php endforeach; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Rechte Seite: Aktionen -->
<div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Aktionen</div></div>
<div class="card-body">
<div style="display:flex;flex-direction:column;gap:.75rem">
<a href="/tax/ustva/<?= $h($year) ?>/<?= $h($month) ?>/xml" class="btn btn-primary">
⬇ ELSTER XML herunterladen
</a>
<form method="post" action="/tax/ustva/<?= $h($year) ?>/<?= $h($month) ?>/save">
<button type="submit" class="btn btn-secondary w-full">Voranmeldung speichern</button>
</form>
<?php if (!empty($saved)): ?>
<div class="flash flash-info" style="margin:0">
Gespeichert am <?= $h($saved['created_at']) ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Zusammenfassung</div></div>
<div class="card-body">
<table style="width:100%;font-size:.9rem">
<tr>
<td style="padding:.35rem 0;color:var(--color-muted)">Umsatz 19 %</td>
<td class="td-right td-mono"><?= $fmtKz($kz['81']['betrag']) ?></td>
</tr>
<tr>
<td style="padding:.35rem 0;color:var(--color-muted)">Umsatz 7 %</td>
<td class="td-right td-mono"><?= $fmtKz($kz['86']['betrag']) ?></td>
</tr>
<tr>
<td style="padding:.35rem 0;color:var(--color-muted)">USt gesamt</td>
<td class="td-right td-mono"><?= $fmtKz(($kz['83']['steuer'] ?? 0) + ($kz['84']['steuer'] ?? 0)) ?></td>
</tr>
<tr>
<td style="padding:.35rem 0;color:var(--color-muted)">Vorsteuer</td>
<td class="td-right td-mono" style="color:var(--color-success)">– <?= $fmtKz($kz['66']['steuer'] ?? 0) ?></td>
</tr>
<tr style="border-top:2px solid #000;font-weight:700">
<td style="padding:.5rem 0"><?= ($kz['69']['steuer'] ?? 0) >= 0 ? 'Vorauszahlung' : 'Erstattung' ?></td>
<td class="td-right td-mono" style="color:<?= ($kz['69']['steuer'] ?? 0) >= 0 ? 'var(--color-danger)' : 'var(--color-success)' ?>">
<?= $fmtKz($kz['69']['steuer'] ?? 0) ?>
</td>
</tr>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><div class="card-title">Navigation</div></div>
<div class="card-body">
<?php
$prevMonth = $month - 1;
$prevYear = $year;
if ($prevMonth < 1) { $prevMonth = 12; $prevYear--; }
$nextMonth = $month + 1;
$nextYear = $year;
if ($nextMonth > 12) { $nextMonth = 1; $nextYear++; }
?>
<div style="display:flex;justify-content:space-between">
<a href="/tax/ustva/<?= $prevYear ?>/<?= $prevMonth ?>" class="btn btn-secondary btn-sm">‹ <?= $h($months[$prevMonth-1]) ?></a>
<a href="/tax/ustva/<?= $nextYear ?>/<?= $nextMonth ?>" class="btn btn-secondary btn-sm"><?= $h($months[$nextMonth-1]) ?> ›</a>
</div>
</div>
</div>
</div>
</div>