Vermietungs-Tool

A local web app for a small room-rental business in Austria: guest register,
room ledger, occupancy plan, invoices, the monthly tourist-tax report and the
quarterly VAT list. Application code only; it ships no data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BlackMarkandClaude Opus 5.5 committed 2026-11-02 16:52:27 +01:00
commit 45424e6fd6
47 files changed
+6701

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
-- 001 schema.
-- * text is COLLATE "C" (byte order), dates are ISO text, money is double precision
-- * every foreign key is DEFERRABLE (INITIALLY IMMEDIATE): company and client point at each other
CREATE TABLE unit (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
code text COLLATE "C" NOT NULL UNIQUE, -- short code shown in the lists
name text COLLATE "C" NOT NULL,
subtitle text COLLATE "C",
address text COLLATE "C", -- multi-line issuer address block
place text COLLATE "C", -- where invoices are issued ('<place>, am <date>')
contact text COLLATE "C", -- multi-line contact block of the invoice footer
bank text COLLATE "C",
iban text COLLATE "C",
uid text COLLATE "C",
tvb_number text COLLATE "C", -- Betriebsnummer for the tourist-tax report
invoice_prefix text COLLATE "C", -- '2026-' | '26-': the year is kept current
logo text COLLATE "C", -- file in app/static/invoice/, nullable
photo text COLLATE "C",
theme text COLLATE "C", -- 'green' | 'blue'
is_current integer NOT NULL DEFAULT 1,
succeeded_by_id bigint REFERENCES unit(id) DEFERRABLE -- a former operator reports under its successor
);
CREATE TABLE room (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
number text COLLATE "C" NOT NULL UNIQUE, -- floor-coded, e.g. '21'
category text COLLATE "C", -- EZ | DZ | 3BZ | 4BZ | Garconniere
capacity integer,
unit_id bigint REFERENCES unit(id) DEFERRABLE, -- current owner
long_term integer NOT NULL DEFAULT 0, -- let long-term, left out of the free-bed count
floor text COLLATE "C"
);
CREATE TABLE company (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text COLLATE "C" NOT NULL, -- canonical display name
kind text COLLATE "C" NOT NULL DEFAULT 'company', -- company | private
uid text COLLATE "C",
email text COLLATE "C",
phone text COLLATE "C",
notes text COLLATE "C",
current_client_id bigint -- the address version used for NEW invoices
);
CREATE TABLE client (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
kind text COLLATE "C" NOT NULL DEFAULT 'company', -- company | private
name text COLLATE "C" NOT NULL,
address text COLLATE "C",
postcode text COLLATE "C",
city text COLLATE "C",
country text COLLATE "C",
company_id bigint REFERENCES company(id) DEFERRABLE
);
ALTER TABLE company ADD FOREIGN KEY (current_client_id) REFERENCES client(id) DEFERRABLE;
CREATE TABLE company_key (
key text COLLATE "C" PRIMARY KEY, -- normalize.company_key(spelling)
company_id bigint NOT NULL REFERENCES company(id) DEFERRABLE
);
CREATE TABLE dismissed_finding (
kind text COLLATE "C" NOT NULL,
key text COLLATE "C" NOT NULL,
note text COLLATE "C",
PRIMARY KEY (kind, key)
);
CREATE TABLE guest (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
last_name text COLLATE "C",
first_name text COLLATE "C",
birth_date text COLLATE "C", -- ISO yyyy-mm-dd
nationality text COLLATE "C",
doc_type text COLLATE "C",
doc_number text COLLATE "C",
profession text COLLATE "C",
home_street text COLLATE "C",
home_postcode text COLLATE "C",
home_city text COLLATE "C",
home_country text COLLATE "C",
phone text COLLATE "C"
);
CREATE TABLE invoice (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
unit_id bigint REFERENCES unit(id) DEFERRABLE,
client_id bigint REFERENCES client(id) DEFERRABLE,
number text COLLATE "C", -- '2026-01'
date text COLLATE "C",
paid_date text COLLATE "C",
payment_method text COLLATE "C",
notes text COLLATE "C",
lines_stale integer NOT NULL DEFAULT 0
);
-- One table for three kinds of rows, told apart by source_sheet: 'guestsheet' (a Gästeblatt in the guest
-- register), 'ledger' (a Zimmerbelegung row; its dates live on the charge) and 'invoice' (an invoice line).
CREATE TABLE stay (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
guest_id bigint REFERENCES guest(id) DEFERRABLE, -- null for ledger and invoice rows
room_id bigint REFERENCES room(id) DEFERRABLE,
unit_id bigint REFERENCES unit(id) DEFERRABLE, -- the operator at the time
employer_client_id bigint REFERENCES client(id) DEFERRABLE,
arrival text COLLATE "C",
departure text COLLATE "C",
sheet_number text COLLATE "C",
label text COLLATE "C", -- occupant text when guest_id is null
notes text COLLATE "C",
source_sheet text COLLATE "C", -- guestsheet | ledger | invoice
hauptwohnsitz integer,
tourism_liable integer
);
CREATE TABLE charge (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
stay_id bigint REFERENCES stay(id) DEFERRABLE,
invoice_id bigint REFERENCES invoice(id) DEFERRABLE,
period_from text COLLATE "C",
period_to text COLLATE "C",
quantity double precision,
unit_type text COLLATE "C", -- week | day | night | month
gross_per_unit double precision,
line_net_override double precision, -- null => computed
source_sheet text COLLATE "C",
persons numeric,
paid_date text COLLATE "C",
paid_status text COLLATE "C",
tourism_tax double precision,
art text COLLATE "C",
gb_ref text COLLATE "C",
vat_rate double precision NOT NULL DEFAULT 0.10,
kind text COLLATE "C" NOT NULL DEFAULT 'stay',
description text COLLATE "C",
rnr text COLLATE "C",
payer text COLLATE "C",
notes text COLLATE "C"
);
CREATE TABLE payment (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
charge_id bigint NOT NULL REFERENCES charge(id) ON DELETE CASCADE DEFERRABLE,
paid_date text COLLATE "C" NOT NULL,
amount double precision NOT NULL
);
CREATE INDEX idx_charge_invoice ON charge(invoice_id);
CREATE INDEX idx_charge_stay ON charge(stay_id);
CREATE INDEX idx_client_company ON client(company_id);
CREATE INDEX idx_stay_guest ON stay(guest_id);
CREATE INDEX idx_stay_room ON stay(room_id);
-- the company pages count stays and invoices per company in correlated subqueries
CREATE INDEX idx_stay_employer ON stay(employer_client_id);
CREATE INDEX idx_invoice_client ON invoice(client_id);
CREATE INDEX payment_charge ON payment(charge_id);
-- "Has anything changed?" for the page cache and the browser's frozen column widths: every statement that
-- writes a data table counts it up.
CREATE TABLE data_version (
id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1),
version bigint NOT NULL DEFAULT 0
);
INSERT INTO data_version (id, version) VALUES (1, 0);
CREATE FUNCTION bump_data_version() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
EXECUTE format('UPDATE %I.data_version SET version = version + 1', TG_TABLE_SCHEMA);
RETURN NULL;
END $$;
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['unit', 'room', 'company', 'client', 'company_key', 'dismissed_finding', 'guest',
'invoice', 'stay', 'charge', 'payment'] LOOP
EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON %I '
'FOR EACH STATEMENT EXECUTE FUNCTION bump_data_version()', t || '_data_version', t);
END LOOP;
END $$;
+49
View File
@@ -0,0 +1,49 @@
-- 002 audit log: every row a data table gains, loses or changes — before, after, and who wrote it.
-- Backups protect against losing the database, not against it being wrong; this shows which rows changed,
-- when and by whom, and `python -m app.audit revert` undoes just those. It is also the registration history
-- the Meldegesetz asks for: corrections through history, not by overwriting.
-- The trigger function runs as the owner (SECURITY DEFINER): the app's role may read the log but neither
-- write nor change it (db._grant), and only a table's owner can switch its triggers off.
CREATE TABLE audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(), -- when the writing transaction began
tx bigint NOT NULL DEFAULT pg_current_xact_id()::text::bigint, -- revert one transaction whole
actor text COLLATE "C" NOT NULL, -- the connection's app.actor: web | cli | ...
db_user text COLLATE "C" NOT NULL DEFAULT session_user, -- the login role, unspoofable
table_name text COLLATE "C" NOT NULL,
op text COLLATE "C" NOT NULL, -- INSERT | UPDATE | DELETE | TRUNCATE
row_id bigint, -- null for company_key / dismissed_finding: their key is in the JSON
old jsonb,
new jsonb
);
CREATE INDEX audit_log_row ON audit_log (table_name, row_id);
CREATE INDEX audit_log_tx ON audit_log (tx);
CREATE FUNCTION audit_row() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$
DECLARE
o jsonb := CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) END;
n jsonb := CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) END;
BEGIN
IF TG_OP = 'UPDATE' AND o = n THEN
RETURN NULL; -- a save that changed nothing is no history
END IF;
EXECUTE format('INSERT INTO %I.audit_log (actor, table_name, op, row_id, old, new) '
'VALUES ($1, $2, $3, $4, $5, $6)', TG_TABLE_SCHEMA)
USING COALESCE(NULLIF(current_setting('app.actor', true), ''), 'unknown'), TG_TABLE_NAME, TG_OP,
(COALESCE(n, o) ->> 'id')::bigint, o, n;
RETURN NULL;
END $$;
REVOKE EXECUTE ON FUNCTION audit_row() FROM PUBLIC;
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['unit', 'room', 'company', 'client', 'company_key', 'dismissed_finding', 'guest',
'invoice', 'stay', 'charge', 'payment'] LOOP
EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON %I '
'FOR EACH ROW EXECUTE FUNCTION audit_row()', t || '_audit', t);
EXECUTE format('CREATE TRIGGER %I AFTER TRUNCATE ON %I '
'FOR EACH STATEMENT EXECUTE FUNCTION audit_row()', t || '_audit_truncate', t);
END LOOP;
END $$;