CREATE TABLE IF NOT EXISTS finance_tax_code ( id BIGSERIAL PRIMARY KEY, code VARCHAR(160) NOT NULL UNIQUE, label VARCHAR(220) NOT NULL, tax_rate_percent NUMERIC(6,3) NOT NULL DEFAULT 0 CHECK (tax_rate_percent >= 0 AND tax_rate_percent <= 100), is_active BOOLEAN NOT NULL DEFAULT TRUE, sort_order INTEGER NOT NULL DEFAULT 1000, created_by BIGINT REFERENCES app_user(id), updated_by BIGINT REFERENCES app_user(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS finance_expense_line ( id BIGSERIAL PRIMARY KEY, expense_id BIGINT NOT NULL REFERENCES finance_expense(id) ON DELETE CASCADE, line_no INTEGER NOT NULL DEFAULT 1, description VARCHAR(255) NOT NULL, tax_code_id BIGINT REFERENCES finance_tax_code(id) ON DELETE SET NULL, tax_code_key VARCHAR(160), net_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (net_amount >= 0), tax_rate_percent NUMERIC(6,3) NOT NULL DEFAULT 0 CHECK (tax_rate_percent >= 0 AND tax_rate_percent <= 100), tax_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), gross_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (gross_amount >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT finance_expense_line_line_no_unique UNIQUE (expense_id, line_no) ); CREATE INDEX IF NOT EXISTS idx_finance_expense_line_expense ON finance_expense_line(expense_id); CREATE INDEX IF NOT EXISTS idx_finance_expense_line_tax_code ON finance_expense_line(tax_code_id); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_tax_code_updated_at') THEN CREATE TRIGGER trg_finance_tax_code_updated_at BEFORE UPDATE ON finance_tax_code FOR EACH ROW EXECUTE FUNCTION set_updated_at(); END IF; IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_expense_line_updated_at') THEN CREATE TRIGGER trg_finance_expense_line_updated_at BEFORE UPDATE ON finance_expense_line FOR EACH ROW EXECUTE FUNCTION set_updated_at(); END IF; END $$; INSERT INTO finance_tax_code(code, label, tax_rate_percent, is_active, sort_order) VALUES ('Kfz_Betriebskosten_19%', 'Kfz Betriebskosten 19%', 19.000, TRUE, 10), ('Mietwagen_Betanken_19%', 'Mietwagen Betanken 19%', 19.000, TRUE, 11), ('Mietwagen_Ausland', 'Mietwagen Ausland', 0.000, TRUE, 12), ('Bahn_ueber_50_km_19%', 'Bahn ueber 50 km 19%', 19.000, TRUE, 13), ('Bahn_ueber_50_km', 'Bahn ueber 50 km', 0.000, TRUE, 14), ('Bahn_unter_50_km_7%', 'Bahn unter 50 km 7%', 7.000, TRUE, 15), ('Bahn_unter_50_km', 'Bahn unter 50 km', 0.000, TRUE, 16), ('Bewirtungskosten_19%', 'Bewirtungskosten 19%', 19.000, TRUE, 17), ('Bewirtungskosten_7%', 'Bewirtungskosten 7%', 7.000, TRUE, 18), ('Bewirtungskosten', 'Bewirtungskosten', 0.000, TRUE, 19), ('Bewirtungskosten_30_19%', 'Bewirtungskosten 30 19%', 19.000, TRUE, 20), ('Bewirtungskosten_30_7%', 'Bewirtungskosten 30 7%', 7.000, TRUE, 21), ('Bewirtungskosten_30', 'Bewirtungskosten 30', 0.000, TRUE, 22), ('Buerobedarf_19%', 'Buerobedarf 19%', 19.000, TRUE, 23), ('Buerobedarf', 'Buerobedarf', 0.000, TRUE, 24), ('Bus_ueber_50_km_19%', 'Bus ueber 50 km 19%', 19.000, TRUE, 25), ('Bus_ueber_50_km', 'Bus ueber 50 km', 0.000, TRUE, 26), ('Bus_unter_50_km_7%', 'Bus unter 50 km 7%', 7.000, TRUE, 27), ('Bus_unter_50_km', 'Bus unter 50 km', 0.000, TRUE, 28), ('Flug_19%', 'Flug 19%', 19.000, TRUE, 29), ('Flug', 'Flug', 0.000, TRUE, 30), ('Fruehstueck_(Hotel_Pension)_19%', 'Fruehstueck (Hotel Pension) 19%', 19.000, TRUE, 31), ('Fruehstueck_(Hotel_Pension)', 'Fruehstueck (Hotel Pension)', 0.000, TRUE, 32), ('Mittagessen_(Hotel_Pension)_19%', 'Mittagessen (Hotel Pension) 19%', 19.000, TRUE, 33), ('Mittagessen_(Hotel_Pension)', 'Mittagessen (Hotel Pension)', 0.000, TRUE, 34), ('Abendessen_(Hotel_Pension)_19%', 'Abendessen (Hotel Pension) 19%', 19.000, TRUE, 35), ('Abendessen_(Hotel_Pension)', 'Abendessen (Hotel Pension)', 0.000, TRUE, 36), ('Handy_19%', 'Handy 19%', 19.000, TRUE, 37), ('Handy', 'Handy', 0.000, TRUE, 38), ('Hotel_Pension_7%', 'Hotel Pension 7%', 7.000, TRUE, 39), ('Hotel_Pension', 'Hotel Pension', 0.000, TRUE, 40), ('Internet_19%', 'Internet 19%', 19.000, TRUE, 41), ('Internet', 'Internet', 0.000, TRUE, 42), ('Lebensmittel_19%%', 'Lebensmittel 19%%', 19.000, TRUE, 43), ('Lebensmittel_7%', 'Lebensmittel 7%', 7.000, TRUE, 44), ('Lebensmittel', 'Lebensmittel', 0.000, TRUE, 45), ('Mietwagen_19%', 'Mietwagen 19%', 19.000, TRUE, 46), ('Mietwagen', 'Mietwagen', 0.000, TRUE, 47), ('Mietwohnung_Appartement_19%', 'Mietwohnung Appartement 19%', 19.000, TRUE, 48), ('Mietwohnung_Appartement', 'Mietwohnung Appartement', 0.000, TRUE, 49), ('Parkgebuehren_19%', 'Parkgebuehren 19%', 19.000, TRUE, 50), ('Parkgebuehren', 'Parkgebuehren', 0.000, TRUE, 51), ('Porto_19%', 'Porto 19%', 19.000, TRUE, 52), ('Porto', 'Porto', 0.000, TRUE, 53), ('Reservierung_19%', 'Reservierung 19%', 19.000, TRUE, 54), ('Reservierung', 'Reservierung', 0.000, TRUE, 55), ('Sonstiges_(19_%_MwSt.)', 'Sonstiges (19 % MwSt.)', 19.000, TRUE, 56), ('Sonstiges_(7_%_MwSt.)', 'Sonstiges (7 % MwSt.)', 7.000, TRUE, 57), ('Sonstiges', 'Sonstiges', 0.000, TRUE, 58), ('Tankbelege_19%', 'Tankbelege 19%', 19.000, TRUE, 59), ('Tankbelege', 'Tankbelege', 0.000, TRUE, 60), ('Taxi_ueber_50_km_19%', 'Taxi ueber 50 km 19%', 19.000, TRUE, 61), ('Taxi_ueber_50_km', 'Taxi ueber 50 km', 0.000, TRUE, 62), ('Taxi_unter_50_km_7%', 'Taxi unter 50 km 7%', 7.000, TRUE, 63), ('Taxi_unter_50_km', 'Taxi unter 50 km', 0.000, TRUE, 64), ('Telefonkosten_19%', 'Telefonkosten 19%', 19.000, TRUE, 65), ('Telefonkosten', 'Telefonkosten', 0.000, TRUE, 66), ('Abendessen_(Hotel/Pension)_19%', 'Abendessen (Hotel/Pension) 19%', 19.000, TRUE, 67) ON CONFLICT (code) DO UPDATE SET label = EXCLUDED.label, tax_rate_percent = EXCLUDED.tax_rate_percent, sort_order = EXCLUDED.sort_order, updated_at = NOW(); INSERT INTO finance_expense_line( expense_id, line_no, description, tax_code_key, net_amount, tax_rate_percent, tax_amount, gross_amount ) SELECT e.id, 1, COALESCE(NULLIF(e.cost_category, ''), e.title, 'Rechnungsposition'), e.cost_category, e.net_amount, e.tax_rate_percent, e.tax_amount, e.gross_amount FROM finance_expense e WHERE NOT EXISTS ( SELECT 1 FROM finance_expense_line l WHERE l.expense_id = e.id ); INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) VALUES ('finance-tax-codes', '/finance-tax-codes', 'MwSt.-Sätze Finanzen', 'admin', 55, 'Kostenarten und MwSt.-Sätze für Rechnungspositionen pflegen') ON CONFLICT (page_key) DO UPDATE SET path = EXCLUDED.path, label = EXCLUDED.label, nav_group = EXCLUDED.nav_group, sort_order = EXCLUDED.sort_order, description = EXCLUDED.description; INSERT INTO app_permission(permission_key, label, category, description) VALUES ('finance.tax_codes', 'MwSt.-Sätze verwalten', 'Finanzen', 'Kostenarten und MwSt.-Sätze für Finanz-Rechnungspositionen pflegen') ON CONFLICT (permission_key) DO UPDATE SET label = EXCLUDED.label, category = EXCLUDED.category, description = EXCLUDED.description; INSERT INTO app_role_page(role_profile_id, page_key, can_view) SELECT rp.id, 'finance-tax-codes', TRUE FROM app_role_profile rp WHERE rp.code IN ('admin','chair') ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; INSERT INTO app_role_permission(role_profile_id, permission_key, granted) SELECT rp.id, 'finance.tax_codes', TRUE FROM app_role_profile rp WHERE rp.code IN ('admin','chair') ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; ALTER TABLE finance_tax_code ENABLE ROW LEVEL SECURITY; ALTER TABLE finance_expense_line ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS finance_tax_code_select ON finance_tax_code; DROP POLICY IF EXISTS finance_tax_code_modify ON finance_tax_code; CREATE POLICY finance_tax_code_select ON finance_tax_code FOR SELECT USING ( app_has_permission('finance.read') OR app_has_permission('finance.tax_codes') ); CREATE POLICY finance_tax_code_modify ON finance_tax_code FOR ALL USING ( app_has_permission('finance.tax_codes') OR app_has_permission('finance.budget') ) WITH CHECK (app_has_permission('finance.tax_codes') OR app_has_permission('finance.budget')); DROP POLICY IF EXISTS finance_expense_line_select ON finance_expense_line; DROP POLICY IF EXISTS finance_expense_line_modify ON finance_expense_line; CREATE POLICY finance_expense_line_select ON finance_expense_line FOR SELECT USING ( app_has_permission('finance.read') ); CREATE POLICY finance_expense_line_modify ON finance_expense_line FOR ALL USING ( app_has_permission('finance.write') ) WITH CHECK (app_has_permission('finance.write')); GRANT SELECT, INSERT, UPDATE, DELETE ON finance_tax_code, finance_expense_line TO br_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO br_app;