Solario PV platform
PV platform for Solario's classic sales line — Builder, Configurator and Planner for the personally guided process from first inquiry to finished system
Solario's classic, personally guided sales line: three independent Angular apps on a shared Supabase backend. Builder for the customer's first online inquiry, Configurator for the worked-out offer — which the consultant walks through together with the customer at the kitchen-table appointment — and an internal Planner dashboard where Solario's consultants steer the full journey from first contact through home visit and quote to handover of the installed system. Type-safe GraphQL queries and tenant isolation via row-level security.
01 Problem
Requests came in through forms, were copied into Excel, quotes were drafted in Word — several hours of manual transcription per project. The handoff from the online inquiry to the in-home appointment was a break in the data flow: the consultant drove out with a printed form and took notes on paper at the customer's place. Three pain points at once: no data quality, no data continuity between inquiry, home visit and quote, and an online first contact that was friction for the prospect rather than help. On top of that, Solario works with multiple sales partners — tenant separation had to be solid from day one, because a consultant in someone's living room must never see data from another partner.
02 Solution
Three Angular apps with a shared Supabase backend, mapped onto the classic sales process. Builder as the customer-facing inquiry app before the first appointment, Configurator as the worked-out offer that the consultant adjusts together with the customer in the living room, Planner as the internal dashboard for appointments, quotes and ongoing customer contact. GraphQL codegen for type-safe queries, automatic yield estimation per roof segment including orientation and shading. Quote data is generated from the Builder answers plus the technical details added during the home visit and just reviewed and released in the Planner — no retyping. PV system as a versioned JSON schema, tenant separation as an RLS policy, quotes as HTML rendered to PDF via the browser's print API.
03 Outcome
Lead-time from inquiry to sent quote significantly reduced — and the in-home appointment now starts from clean, complete data rather than a paper form and handwritten notes. The platform runs in parallel with the DIY line "Machs Dir Selbst Solar" and is continuously extended — new modules, new inverters and legal changes (feed-in tariff, tax rules) land in the tool without code changes. After three years, still the backbone of Solario's classic, personally guided sales line.
Three apps, one backend
The platform consists of three independent Angular apps sharing a Supabase data layer — mapped onto the classic Solario sales process: online inquiry, home visit, worked-out offer, installation, handover.
Builder — the customer-facing inquiry app, public, no login, conversion-critical. A prospective customer enters their address, sketches their roof segments roughly and describes what they have in mind — no component-level material choices, no detailed technical decisions. Out comes a first, internally consistent inquiry that the Solario consultant can review before the on-site appointment. The app reserves a configuration_id on the first click and uses it as anonymous pseudo-auth — the inquiry is shareable as a link, the data exists in the database from second one.
Configurator — the worked-out offer the customer lands in after the home visit. Here the consultant has filled in the technical details from the on-site appointment (roof access, wiring, shading, meter cabinet); the customer sees concrete module series, concrete inverter, optional components with surcharge, and detailed economic statistics. Materials can be swapped live (premium vs base module), impact on price, yield and payback shows immediately — often the consultant sits with the customer in front of the screen and adjusts it interactively.
Planner — the internal app with Supabase Auth and row-level security per tenant. Consultants see all inquiries from their tenant, schedule the on-site appointments, fill in technical details after the home visit (module series, wiring, which inverter fits which string layout), generate quotes and accompany the customer through installation and commissioning to handover of the finished system. Master-data editing (manufacturers, modules, inverters, subsidies, electricity-price assumptions) happens here too.
All three apps pull their TypeScript types directly from the Supabase schema via GraphQL codegen — a schema change lands in all three frontends, type-safe, within minutes. This was especially valuable in the early phase when the data model and UI requirements were still co-evolving: every few days something changed in the model, and a hand-maintained types layer would have ground us down.
Approach
The central design decision was to not shred the PV system into ten normalised tables, but to keep it as a versioned JSON in a single column. A PV system has irreducible complexity — modules, inverters, batteries, roof segments with shading sections, optional emergency backup, every component with properties that vary by manufacturer. Forcing every relationship into a table would mean every new component type is a migration. Instead:
- The schema lives as a Zod definition in TypeScript and is validated on the app side.
- New fields are added additively —
schema_versionincrements, older versions are migrated on read. - Postgres indexes on JSON paths (
->>andjsonb_path_ops) cover the few queries we actually run across systems (e.g. “all systems with manufacturer X”).
That buys us refactoring freedom without losing data integrity: the schema is versioned, every read goes through validation, and only structurally correct objects are written.
import { z } from 'zod';
export const ModuleEntry = z.object({
model: z.string(), // e.g. "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),
});
// On read: older versions are migrated here.
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);
} Highlights
Type-safe queries end-to-end
A .graphql file describes the query, codegen generates TypeScript types and a type-safe Apollo hook. When the schema changes, the build breaks — before the code reaches production. We run codegen both as a pre-commit hook and a CI check: a PR with stale generated files is flagged as red in review immediately.
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 system as a versioned JSON schema
The system itself lives as a JSON column in Postgres. The schema lives as TypeScript and is validated in the app (Zod). New fields are added additively; older systems are migrated on read. Three years and six schema versions later, we haven’t needed a single actual DB migration on the system column — all shape changes live in the app layer.
// V1 had `roof_area_m2` as a scalar. V2 has an array of RoofSegments with
// per-segment orientation — roofs with a south + west section need that for
// an honest yield estimate.
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,
}],
});
} Tenant separation as a database policy
Every request belongs to a tenant_id. A single RLS policy governs visibility — the apps know nothing about it. Maintenance scripts go through the same layer too. This rules out a whole class of bugs: anyone who forgets the WHERE tenant_id = ? filter in a SQL query still only gets back their own rows.
alter table public.offers enable row level security;
-- Planners can only read offers from their tenant
create policy "offers_select_own_tenant"
on public.offers for select
using (tenant_id = auth.jwt()->>'tenant_id');
-- Configurator (anon) can only write its own offer id
create policy "offers_insert_own_session"
on public.offers for insert
with check (session_id = current_setting('request.session_id', true));
-- The configurator can also read back its own offer id — as long as it
-- knows the session id. This is what makes "share configuration by link" work.
create policy "offers_select_own_session"
on public.offers for select
using (session_id = current_setting('request.session_id', true)); Yield estimation per roof segment
An honest yield estimate doesn’t come from “system size × default factor” — it comes from a separate calculation per roof segment: orientation, tilt, shading, module type. The libs/economics library handles the rest: electricity-price escalation over 25 years, self-consumption vs feed-in, annuity for financed purchases, payback period. It’s written as pure functions so it’s testable and works in both the configurator (live updates while dragging sliders) and the planner (final quote calculation).
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;
}
} Quotes as HTML with browser print
Instead of maintaining a PDF generator (PDFKit, Puppeteer, a custom font pipeline, manual page-break handling), the quote is rendered as an HTML component — with its own print stylesheet using @page, break-inside: avoid on component groups, and full-bleed diagrams. The browser does the rest. For layout changes the cycle time is: change one value in the SCSS, hit “Print” in the app, done.
@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; }
} What three years of operation have shown
Three points that turned out to matter more than we thought at the start.
Master data belongs in the database, editorially editable. Every new module type, every change to the feed-in tariff, every new inverter is a row in the back office — no deploy. Over three years of operation there have been two or three legal changes (VAT on PV systems, raised feed-in tariff) that would otherwise have meant code changes each time.
JSON schema versioning carries further than expected. We’re at schema_version: 5 for the system JSON by now. Each version is migrated cleanly on read, so data looks uniform in the frontend without us having to run a Postgres migration over 50,000 systems. If something really doesn’t fit anymore (very old schemas), a nightly cron can rewrite the column — it hasn’t been needed.
RLS catches every refactor. We’ve done three larger frontend refactors (RxJS subjects → signals; lazy-loaded modules → standalone components; Tailwind v3 → v4). On each one, we accidentally dropped a tenant filter somewhere — and nothing happened, because RLS in the database enforced it again. Exactly the defence-in-depth tenant separation deserves.
05 Key decisions
Architecture choices that paid off
Configurator without login
Anonymous sessions with a server-side reserved configuration ID. Customers can share their configuration, Solario sees it live in the planner. Contact data is only asked for at the point of sending the quote — that drops abandonment significantly compared to a "please register first" wall.
PV systems as typed JSON, not 12 normalised tables
A system consists of modules, inverters, batteries, roof segments, wiring — relationships that change often. Instead of forcing them into a rigid schema, they live as versioned JSON in Postgres with validation in the app layer. Refactors don't need migrations, new component kinds can be added additively.
GraphQL codegen, not hand-maintained types
TypeScript types for all queries and mutations are generated from the Supabase GraphQL schema. A schema change in the backend becomes a compile error in the frontend — no silent drift between DB and UI. The pipeline runs as a pre-commit hook and as a CI check, so nobody reviews code against stale types.
Quotes as HTML templates with a print stylesheet
Instead of maintaining a PDF generator (custom fonts, page breaks, table rendering) the quote is rendered as HTML and printed to PDF via the browser's print API. Layout changes need no specialised toolchain — anyone who knows HTML/CSS can adjust the quote.
Row-level security in Postgres, not in the app
Tenant separation (which Solario planner sees which request) is implemented as an RLS policy in Postgres. A forgotten `WHERE` in the frontend can no longer leak data — the database knows who can see what, and the anonymous configurator gets its own per-session policies.
Master data in the database, not in code
Modules, inverters, batteries, subsidies, electricity price assumptions, feed-in tariff — all editorially editable in the planner back office. A legal change to the tariff no longer needs a deploy, just an update to one row of master data.
07 Lessons learned
- 01 JSON columns for complex domain objects are a good middle ground between flexibility and type safety — provided the app layer validates rigorously and the schema itself is versioned.
- 02 GraphQL codegen is worth the setup cost from day one. The one-time 90 minutes pay off within a week, and in reviews the compiler catches bugs that would otherwise show up in production.
- 03 Row-level security in the database is more robust than "we remember to filter in the frontend". A forgotten `WHERE` clause no longer leads to a leak — the database simply returns nothing.
- 04 Anonymous configurator sessions convert significantly better than a login wall up front — as long as the data is bound server-side to a later email cleanly.
- 05 Browser-print PDFs are enough for 95% of quote layouts. The 5% with exotic requirements (custom watermarks, multi-page component plans) get a second tool — don't rebuild the entire PDF stack for them.