Solario PV-Plattform
PV-Plattform für den klassischen Solario-Vertrieb — Builder, Configurator und Planner für den persönlich begleiteten Prozess vom Erstkontakt bis zur fertigen Anlage
Solarios klassische, persönlich begleitete Vertriebslinie: drei eigenständige Angular-Apps auf gemeinsamem Supabase-Backend. Builder für die erste Online- Anfrage des Kunden, Configurator für das ausgearbeitete Angebot — das der Berater im Wohnzimmertermin gemeinsam mit dem Kunden anpasst — und ein internes Planner-Dashboard, in dem die Solario-Berater den gesamten Weg vom Erstkontakt über Hausbesuch und Angebot bis zur Übergabe der installierten Anlage steuern. Typsichere GraphQL-Abfragen und Mandantentrennung über Row-Level Security.
01 Problem
Anfragen kamen über Formulare, wurden in Excel überführt, Angebote per Word erstellt — pro Auftrag mehrere Stunden manuelle Übertragung. Der Übergang von der Online-Anfrage zum Wohnzimmertermin war ein Bruch im Datenfluss: der Berater fuhr mit einem ausgedruckten Formular raus und schrieb beim Kunden handschriftlich mit. Drei Schmerzpunkte gleichzeitig: keine Datenqualität, keine Datenkontinuität zwischen Anfrage, Hausbesuch und Angebot, und der Online-Erstkontakt war für den potenziellen Kunden Reibung statt Hilfe. Dazu arbeitet Solario mit mehreren Vertriebspartnern — Mandantentrennung musste sauber ab Tag 1 stehen, weil ein Berater im Wohnzimmer keine Daten anderer Partner sehen darf.
02 Lösung
Drei Angular-Apps mit gemeinsamem Supabase-Backend, entlang des klassischen Vertriebsprozesses aufgebaut. Builder als kundenseitige Anfrage-App vor dem ersten Termin, Configurator als ausgearbeitetes Angebot, das der Berater im Wohnzimmer gemeinsam mit dem Kunden anpasst, Planner als internes Dashboard für Termine, Angebote und Kundenkontakt. GraphQL-Codegen für typsichere Abfragen, automatische Ertragsabschätzung pro Dachfläche samt Ausrichtung und Verschattung. Angebotsdaten werden aus den Builder-Antworten und den im Hausbesuch ergänzten Details generiert und im Planner nur noch geprüft und freigegeben — kein erneutes Abtippen. PV-Anlage als versioniertes JSON-Schema, Mandantentrennung als RLS-Policy, Angebote als HTML mit Browser-Print-API zu PDF.
03 Ergebnis
Durchlaufzeit von der Kundenanfrage bis zum versendeten Angebot deutlich verkürzt — und der Wohnzimmertermin startet mit sauberen, vollständigen Daten statt Papierformularen und handschriftlichen Notizen. Plattform läuft parallel zur DIY-Linie „Machs Dir Selbst Solar" weiter und wird kontinuierlich erweitert — neue Module, neue Wechselrichter-Modelle und gesetzliche Anpassungen (Einspeisevergütung, Steuerregeln) landen ohne Code-Änderungen direkt im Tool. Nach drei Jahren noch das Rückgrat von Solarios klassischer, persönlich begleiteter Vertriebslinie.
Drei Apps, ein Backend
Die Plattform besteht aus drei eigenständigen Angular-Apps mit gemeinsamer Supabase-Datenebene — entlang des klassischen Solario-Vertriebsprozesses aufgebaut: Online-Anfrage, Hausbesuch, ausgearbeitetes Angebot, Installation, Übergabe.
Builder — die kundenseitige Erstanfrage-App, öffentlich erreichbar, ohne Login, konversionskritisch. Ein potenzieller Kunde gibt seine Adresse ein, skizziert seine Dachflächen grob und beschreibt, was er sich vorstellt — keine Materialwahl auf Komponentenebene, keine technischen Detail-Entscheidungen. Heraus kommt eine erste, in sich konsistente Anfrage, die der Solario-Berater vor dem Vor-Ort-Termin sichten kann. Die App reserviert beim ersten Klick eine configuration_id und nutzt sie als anonymes Pseudo-Auth — die Anfrage ist über einen Link teilbar, die Daten existieren in der DB von Sekunde 1 an.
Configurator — das ausgearbeitete Angebot, in dem der Kunde nach dem Hausbesuch landet. Hier hat der Berater die technischen Details aus dem Vor-Ort-Termin (Dachzugang, Verkabelung, Verschattung, Zählerschrank) eingepflegt; der Kunde sieht konkrete Modulreihen, konkreten Wechselrichter, optionale Komponenten mit Aufpreis und eine detaillierte Wirtschaftlichkeitsrechnung. Material lässt sich live tauschen (z. B. Premium-Modul statt Basis), Auswirkungen auf Preis, Ertrag und Payback sind sofort sichtbar — oft sitzt der Berater gemeinsam mit dem Kunden vor dem Bildschirm und passt es interaktiv an.
Planner — die interne App mit Supabase Auth und Row-Level Security pro Mandant. Berater sehen alle Anfragen ihres Mandanten, planen die Vor-Ort-Termine, ergänzen technische Details nach dem Hausbesuch (welche Modulreihe, welche Verkabelung, welcher Wechselrichter zum String-Layout), erstellen Angebote und begleiten den Kunden über Installation und Inbetriebnahme bis zur Übergabe der fertigen Anlage. Stammdaten (Hersteller, Module, Wechselrichter, Zuschüsse, Strompreis-Annahmen) werden hier redaktionell gepflegt.
Alle drei ziehen ihre TypeScript-Typen direkt aus dem Supabase-Schema über GraphQL Codegen — eine Schemaänderung landet binnen Minuten typsicher in allen drei Frontends. Das hat sich besonders in der frühen Phase bewährt, in der Datenmodell und UI-Anforderungen sich noch wechselseitig beeinflussten: alle paar Tage änderte sich etwas im Datenmodell, und in einem von Hand gepflegten Typen-Layer hätten wir uns daran zermürbt.
Vorgehen
Die zentrale Designentscheidung war, das PV-System nicht in zehn normalisierte Tabellen zu zerschlagen, sondern als ein versioniertes JSON in einer einzigen Spalte zu halten. Eine Anlage hat irreduzible Komplexität — Module, Wechselrichter, Speicher, Dachflächen mit Verschattungssegmenten, optionale Notstromversorgung, jede Komponente mit Eigenschaften, die sich pro Hersteller unterscheiden. Würde man jede Beziehung in eine Tabelle gießen, wäre jeder neue Komponententyp eine Migration. So aber:
- Das Schema lebt als Zod-Definition in TypeScript und wird auf der App-Seite validiert.
- Neue Felder werden additiv eingeführt —
schema_versionzählt hoch, alte Versionen werden beim Lesen migriert. - Postgres-Indizes auf JSON-Pfaden (
->>undjsonb_path_ops) decken die wenigen Abfragen ab, die wir tatsächlich quer über Anlagen fahren (z. B. „alle Anlagen mit Hersteller X”).
Das gibt uns Refactoring-Freiheit, ohne Datenintegrität zu verlieren: das Schema ist versioniert, jede Lesung läuft durch Validierung, und beim Schreiben gehen nur strukturell korrekte Objekte rein.
import { z } from 'zod';
export const ModuleEntry = z.object({
model: z.string(), // z.B. "JA Solar JAM72S30-545"
count: z.number().int().positive(),
power_wp: z.number().positive(),
});
export const SystemV2 = z.object({
schema_version: z.literal(2),
modules: z.array(ModuleEntry).min(1),
inverter: Inverter,
storage: Storage.optional(),
roofs: z.array(RoofSegment).min(1),
});
// Beim Lesen: alte Versionen werden hier migriert.
export function readSystem(raw: unknown): z.infer<typeof SystemV2> {
const v = (raw as any)?.schema_version ?? 1;
return v === 2 ? SystemV2.parse(raw) : migrateV1toV2(raw);
} Tiefe Einblicke
Typsichere Abfragen end-to-end
Eine .graphql-Datei beschreibt die Query, der Codegen generiert TypeScript-Types und einen typsicheren Apollo-Hook. Wenn das Schema sich ändert, schlägt der Build fehl — bevor der Code in Produktion landet. Wir lassen den Codegen sowohl als Pre-Commit-Hook als auch als CI-Check laufen: ein PR mit veralteten Generated-Files wird im Review sofort als rot markiert.
query GetOffer($id: UUID!) {
offer(id: $id) {
id
status
customer { name email }
system {
modules { model count }
inverter { model power_kw }
storage { model capacity_kwh }
roofs { area_m2 azimuth tilt shading }
}
pricing { total vat installation_eur }
}
}
# → generates GetOfferQuery, GetOfferQueryVariables,
# useGetOfferQuery(), watchGetOffer() automatically. PV-Anlage als versioniertes JSON-Schema
Die Anlage selbst lebt als JSON-Spalte in Postgres. Das Schema wird in TypeScript geführt und in der App validiert (Zod). Neue Felder werden additiv eingeführt; ältere Anlagen werden beim Lesen migriert. Drei Jahre und sechs Schema-Versionen später haben wir keine einzige tatsächliche DB-Migration auf der system-Spalte gebraucht — alle Form-Änderungen leben in der App-Schicht.
// V1 hatte `roof_area_m2` als Skalar. V2 hat ein Array von RoofSegments
// mit eigener Ausrichtung pro Segment — Dächer mit Süd- + Westanteil
// brauchen das für eine ehrliche Ertragsabschätzung.
export function migrateV1toV2(raw: unknown): z.infer<typeof SystemV2> {
const v1 = SystemV1.parse(raw);
return SystemV2.parse({
schema_version: 2,
modules: v1.modules,
inverter: v1.inverter,
storage: v1.storage,
roofs: [{
area_m2: v1.roof_area_m2,
azimuth: v1.roof_azimuth ?? 180,
tilt: v1.roof_tilt ?? 35,
shading: 0,
}],
});
} Mandantentrennung als Datenbank-Policy
Jede Anfrage gehört zu einem tenant_id. Eine einzige RLS-Policy regelt, wer was sehen darf — die Apps müssen darüber nichts wissen. Auch Wartungs-Skripte greifen über die gleiche Schicht zu. Das schließt eine ganze Klasse von Bugs aus: wer in einer SQL-Abfrage versehentlich den WHERE tenant_id = ?-Filter vergisst, kriegt trotzdem nur die eigenen Rows zurück.
alter table public.offers enable row level security;
-- Planner dürfen nur die Angebote ihres Mandanten lesen
create policy "offers_select_own_tenant"
on public.offers for select
using (tenant_id = auth.jwt()->>'tenant_id');
-- Konfigurator (anonym) darf nur seine eigene Offer-ID schreiben
create policy "offers_insert_own_session"
on public.offers for insert
with check (session_id = current_setting('request.session_id', true));
-- Konfigurator darf seine eigene Offer-ID auch wieder lesen — solange er
-- die Session-ID kennt. Damit funktioniert "Konfiguration per Link teilen".
create policy "offers_select_own_session"
on public.offers for select
using (session_id = current_setting('request.session_id', true)); Ertragsabschätzung pro Dachsegment
Eine ehrliche Ertragsabschätzung kommt nicht von „PV-Größe × Standard-Faktor”, sondern von einer separaten Rechnung pro Dachsegment: Ausrichtung, Neigung, Verschattung, Modulgröße. Die libs/economics-Bibliothek macht den Rest — Strompreis-Eskalation über 25 Jahre, Eigenverbrauch versus Einspeisung, Annuität bei Finanzierung, Payback-Periode. Sie ist als reine Funktionen geschrieben, damit sie testbar ist und sowohl im Configurator (Live-Update beim Schieber-Drag) als auch im Planner (finale Angebots-Berechnung) läuft.
export class EconomicsUtils {
static DEFAULT_YEARS_FOR_CALCULATION = 25;
static ELECTRICITY_PRICE_YEARLY_INCREASE_PERCENTAGE = 0.04;
static calculate_electricity_price_list(
starting_price_cent: number,
yearly_factor = 1 + EconomicsUtils.ELECTRICITY_PRICE_YEARLY_INCREASE_PERCENTAGE,
max_years = EconomicsUtils.DEFAULT_YEARS_FOR_CALCULATION,
): number[] {
const list = [starting_price_cent];
for (let y = 1; y < max_years; y++) list.push(list[y - 1] * yearly_factor);
return list;
}
static calculate_payback_period(profit_list: number[], total_cost: number): number {
let years = 0, cumulative = 0;
while (total_cost > cumulative && years < profit_list.length) {
cumulative += profit_list[years++];
}
const overshoot = cumulative - total_cost;
return overshoot > 0 && years > 0 ? years - overshoot / profit_list[years - 1] : years;
}
} Angebote als HTML mit Browser-Print
Statt einen PDF-Generator zu pflegen (PDFKit, Puppeteer, eigene Schriftarten-Pipeline, manuelles Seitenumbruch-Management) wird das Angebot als HTML-Komponente gerendert — mit eigenem Print-Stylesheet, das @page-Regeln, break-inside: avoid auf Komponentengruppen und seitenfüllende Diagramme nutzt. Der Browser macht den Rest. Bei Layout-Änderungen ist das Cycletime: einen Wert in der SCSS-Datei ändern, in der App auf „Drucken” klicken, fertig.
@page {
size: A4;
margin: 1.5cm 2cm;
}
@media print {
.offer-page { break-after: page; }
.offer-component { break-inside: avoid; }
.offer-cover { background: var(--brand-bg) !important;
-webkit-print-color-adjust: exact; }
.no-print { display: none !important; }
} Was sich in drei Jahren bewährt hat
Drei Punkte, die sich nach längerem Betrieb als wichtiger erwiesen haben als wir am Anfang dachten:
Stammdaten gehören redaktionell pflegbar in die DB. Jeder neue Modultyp, jede Anpassung der Einspeisevergütung, jeder neue Wechselrichter ist eine Zeile im Backoffice — kein Deploy. In drei Jahren Betrieb gab es zwei oder drei gesetzliche Änderungen (Mehrwertsteuer auf PV-Anlagen, Anhebung der Einspeisevergütung), die sonst jedes Mal eine Code-Änderung gebraucht hätten.
JSON-Schema-Versionierung trägt weiter als gedacht. Wir sind inzwischen bei schema_version: 5 für das System-JSON. Jede Version wird beim Lesen sauber migriert, sodass die Daten im Frontend einheitlich aussehen, ohne dass wir eine Postgres-Migration auf 50.000 Anlagen jagen mussten. Wenn etwas wirklich nicht mehr passt (extrem alte Schemata), kann ein nächtlicher Cron die DB-Spalte rewriten — bisher nicht nötig gewesen.
RLS rettet bei jedem Refactor. Wir haben drei größere Frontend-Refactors hinter uns (RxJS-Subjects → Signals; lazy-loaded Module → Standalone-Components; Tailwind v3 → v4). Bei jedem haben wir versehentlich irgendwo einen Tenant-Filter rausgelöscht — und nichts ist passiert, weil RLS in der DB ihn nochmal durchsetzt. Das ist genau die Defense-in-Depth, die Mandantentrennung verdient.
05 Entscheidungen
Schlüssel-Entscheidungen
Configurator ohne Login
Anonyme Sessions mit serverseitiger Reservierung der Konfigurations-ID. Kunden können die Konfiguration teilen, Solario sieht sie in Echtzeit im Planner. Erst beim Angebotsversand wird Kontaktdaten verlangt — das senkt die Abbruchquote erheblich gegenüber einem Wall mit „bitte registrieren Sie sich erst".
PV-Systeme als typisiertes JSON, nicht als 12 normalisierte Tabellen
Eine Anlage besteht aus Modulen, Wechselrichtern, Speichern, Dachflächen, Verkabelung — Beziehungen, die sich oft ändern. Statt sie in ein starres Schema zu pressen, leben sie als versioniertes JSON in Postgres mit Validierung in der App-Schicht. Refactors brauchen keine Migrationen, neue Komponentenarten lassen sich additiv einführen.
GraphQL Codegen statt manuell gepflegter Types
Die TypeScript-Typen aller Queries und Mutations werden aus dem Supabase-GraphQL-Schema generiert. Eine Schemaänderung im Backend wird zum Compile-Fehler im Frontend — kein „silent drift" zwischen DB und UI. Die Pipeline läuft als Pre-Commit-Hook und als CI-Check, damit niemand mit veralteten Typen Code reviewt.
Angebote als HTML-Template mit Print-Stylesheet
Statt einen PDF-Generator zu pflegen (eigene Schriftarten, Seitenumbrüche, Tabellenrendering) wird das Angebot als HTML gerendert und per Browser-Print-API zu PDF gemacht. Anpassungen am Layout brauchen keine Spezial-Toolchain — wer HTML/CSS kann, kann das Angebot anpassen.
Row-Level Security in Postgres, nicht in der App
Mandantentrennung (welche Solario-Planer sehen welche Anfragen) ist als RLS-Policy in Postgres umgesetzt. Eine vergessene WHERE-Klausel im Frontend kann keinen Datenleak mehr erzeugen — die Datenbank weiß, wer was sehen darf, und der anonyme Configurator hat eigene Policies pro Session-ID.
Stammdaten in der Datenbank, nicht im Code
Module, Wechselrichter, Speicher, Zuschüsse, Strompreis-Annahmen, Einspeisevergütung — alles redaktionell pflegbar im Planner-Backoffice. Eine gesetzliche Änderung am Strompreis braucht keinen Deploy mehr, nur einen Update auf einer Stammdatenzeile.
07 Was hängenbleibt
- 01 JSON-Felder für komplexe Domänen-Objekte sind ein guter Mittelweg zwischen Flexibilität und Typsicherheit — vorausgesetzt, die App-Schicht validiert konsequent und das Schema selbst ist versioniert.
- 02 GraphQL Codegen ist den Setup-Aufwand vom ersten Tag wert. Die einmaligen 90 Minuten zahlen sich nach einer Woche aus, und in Reviews fängt der Compiler Bugs, die sonst erst in der Live-Umgebung auftauchen würden.
- 03 Row-Level Security in der Datenbank ist robuster als „wir denken im Frontend dran". Ein vergessener WHERE-Filter führt nicht mehr zum Datenleak, weil die Datenbank schon nichts mehr ausliefert.
- 04 Anonyme Konfigurator-Sessions konvertieren deutlich besser als ein Login-Wall am Anfang — solange die Daten serverseitig sauber an eine spätere E-Mail gebunden werden können.
- 05 Browser-Print-PDFs reichen für 95 % aller Angebotslayouts. Den 5 % mit exotischen Wünschen (eigene Wasserzeichen, mehrseitige Komponenten- Pläne) gibt man ein zweites Tool — nicht den ganzen PDF-Stack umbauen.