MatchPoint — Entwickler-Arbeitsanweisung
Version: 1.0 | Datum: 25.02.2026 | Phase: 2
Ziel: Rebuild des MatchPoint Pricing-Tools als Teil der STARQ Platform (Rails 8.1)
Quellcode-Referenz: github.com/Processly/matchpoint-starqstrom (React/TypeScript/Lovable)
1. Übersicht
Was ist MatchPoint?
MatchPoint ist das RLM-Preiskalkulationstool von STARQstrom für Großkunden (B2B). Es berechnet, wie viel der Kundenlast durch eigene PV- und Windanlagen abgedeckt werden kann und kalkuliert daraus individuelle Strompreise.
Aktueller Zustand (Lovable/React)
| Eigenschaft | Aktuell |
|---|---|
| Tech-Stack | React 18 + TypeScript + Vite + Tailwind + Recharts |
| Hosting | Lovable.app (Frontend-only, keine API, keine DB) |
| Datenfluss | Manuell — Vertriebler öffnet Tool, tippt Werte ein, liest Ergebnis ab |
| HubSpot | Kein automatischer Datenfluss |
| Repository | github.com/Processly/matchpoint-starqstrom |
Zielzustand (STARQ Platform / Rails 8.1)
| Eigenschaft | Ziel |
|---|---|
| Tech-Stack | Ruby 4.0 / Rails 8.1 / PostgreSQL / Sidekiq / Tailwind / Stimulus / Turbo |
| Hosting | Hetzner (als Teil der STARQ Platform) |
| Datenfluss | Automatisch — HubSpot Action Button → STARQ Platform → Berechnung → Ergebnis zurück in HubSpot |
| HubSpot | Bidirektional — Kundendaten rein, Preisszenarien raus |
| Multi-Szenario | Vertriebler sieht mehrere Angebotsszenarien, kann anpassen und auswählen |
Workflow (3 Schritte)
SCHRITT 1: Daten empfangen
HubSpot Deal "Angebot angefragt" → Webhook → STARQ Platform
→ Kundendaten + Lastprofil aus HubSpot lesen
SCHRITT 2: Energy Matching
Lastprofil (15-Min-Intervalle) vs. PV-Profil vs. Wind-Profil
→ pv_faktor, wind_faktor, spot_faktor (Summe = 1,00)
→ 24h-Matching-Chart generieren
SCHRITT 3: Preiskalkulation + Multi-Szenario
Matching-Faktoren × Preise + Risiken + Overhead
→ Mehrere Preisszenarien automatisch generieren
→ Vertriebler wählt/passt an → Gewähltes Szenario → HubSpot Deal
2. Pricing-Formeln
2.1 RLM-Tarife (Großkunden)
Es gibt zwei Tarife für RLM-Kunden:
| Tarif | Beschreibung | Preisstruktur |
|---|---|---|
| STARQ&pro | Premium-Tarif mit Dual-Preis | Fix-Preis (Erneuerbar) + Flex-Preis (Spot) |
| STARQ&fix | Einfacher Festpreis | Ein einziger Blended-Preis |
2.1.1 STARQ&pro — Dual-Preis (Fix + Flex)
Konzept: Der Kunde zahlt zwei separate Preise:
- Fixpreis für den erneuerbaren Anteil (PV + Wind) — stabil, langfristig fixiert
- Flexpreis für den Spotmarkt-Anteil — variabel, an Börsenpreis gekoppelt
STARQ&pro FIXPREIS (ct/kWh) — für den erneuerbaren Anteil
───────────────────────────────────────────────────────────
fix_anteil = pv_faktor + wind_faktor
Gewichteter Erneuerbaren-Preis:
renewable_avg = (pv_faktor × solar_preis + wind_faktor × wind_preis) / fix_anteil
Risiken (HALBIERT für pro!):
risiken_pro = (ausgleichsenergie + mengen + marktpreis + kredit) / 2
Overhead nach Laufzeitrabatt:
overhead_netto = max(0, overhead - laufzeitrabatt)
Kosten gesamt:
kosten = overhead_netto + vertriebsprovision
—————————————————————————————
FIXPREIS = renewable_avg + risiken_pro + kosten
—————————————————————————————
Beispiel: (0.45×7.50 + 0.44×8.50) / 0.89 + 0.70 + 1.65 = 10.34 ct/kWh
STARQ&pro FLEXPREIS (ct/kWh) — für den Spotmarkt-Anteil
───────────────────────────────────────────────────────────
flex_anteil = spot_faktor
—————————————————————————————
FLEXPREIS = spot_preis + beschaffungsgebuehren + hkn
—————————————————————————————
Beispiel: 9.50 + 0.40 + 0.01 = 9.91 ct/kWh
STARQ&pro JAHRESKOSTEN (EUR)
───────────────────────────────────────────────────────────
erneuerbar_verbrauch = jahresverbrauch × (fix_anteil / (fix_anteil + flex_anteil))
spot_verbrauch = jahresverbrauch × (flex_anteil / (fix_anteil + flex_anteil))
fix_kosten = fixpreis × erneuerbar_verbrauch / 100
flex_kosten = flexpreis × spot_verbrauch / 100
jahreskosten = fix_kosten + flex_kosten
gesamtkosten = jahreskosten × (vertragslaufzeit / 12)
2.1.2 STARQ&fix — Blended Single Price
Konzept: Ein einziger Festpreis für den gesamten Verbrauch.
STARQ&fix PREIS (ct/kWh)
───────────────────────────────────────────────────────────
Beschaffung (gewichtet):
beschaffung = pv_faktor × solar_preis
+ wind_faktor × wind_preis
+ spot_faktor × spot_preis
+ beschaffungsgebuehren
Risiken (VOLL — nicht halbiert!):
risiken_voll = ausgleichsenergie + mengen + marktpreis + kredit
Overhead nach Laufzeitrabatt:
overhead_netto = max(0, overhead - laufzeitrabatt)
Kosten gesamt:
kosten = overhead_netto + vertriebsprovision
—————————————————————————————
BASISPREIS = beschaffung + risiken_voll + kosten
—————————————————————————————
STARQ&fix JAHRESKOSTEN (EUR)
───────────────────────────────────────────────────────────
WICHTIG: HKN wird nur auf den Spot-Anteil berechnet!
spot_verbrauch = jahresverbrauch × (spot_faktor / (pv_faktor + wind_faktor + spot_faktor))
basis_kosten = basispreis × jahresverbrauch / 100
hkn_kosten = hkn × spot_verbrauch / 100
jahreskosten = basis_kosten + hkn_kosten
gesamtkosten = jahreskosten × (vertragslaufzeit / 12)
effektiver_preis = jahreskosten × 100 / jahresverbrauch
2.1.3 Wichtige Unterschiede STARQ&pro vs. STARQ&fix
| Merkmal | STARQ&pro | STARQ&fix |
|---|---|---|
| Preisstruktur | Dual (Fix + Flex) | Single (Blended) |
| Risiken | Halbiert (÷ 2) | Voll (× 1) |
| HKN | Im Flexpreis enthalten | Nur auf Spot-Anteil |
| Vorteil Kunde | Günstiger bei hohem Spot-Anteil | Einfacher, ein Preis |
| Vorteil STARQstrom | Marktpreisrisiko beim Kunden | Voller Risikoaufschlag |
2.2 SLP-Tarife (Kleingewerbe)
Tarif: STARQ&unternehmerisch — vereinfachter Festpreis ohne Dual-Preis-Option.
SLP FESTPREIS (ct/kWh)
───────────────────────────────────────────────────────────
Gleiche Formel wie STARQ&fix, aber mit REDUZIERTEN Risiken:
beschaffung = pv_faktor × solar_preis
+ wind_faktor × wind_preis
+ spot_faktor × spot_preis
+ beschaffungsgebuehren
risiken_slp = ausgleichsenergie_slp + mengen + marktpreis_slp + kredit
overhead_netto = max(0, overhead - laufzeitrabatt)
kosten = overhead_netto + vertriebsprovision
—————————————————————————————
SLP_PREIS = beschaffung + risiken_slp + kosten
—————————————————————————————
2.3 B2C-Tarife (Privatkunden)
Zwei Tarife: STARQ&fair und STARQ&unternehmerisch
B2C MONATLICHE KOSTEN (EUR/Monat)
───────────────────────────────────────────────────────────
Arbeitspreis (nach Rabatt):
arbeitspreis_netto = arbeitspreis - laufzeitrabatt_b2c
Arbeitspreiskosten/Monat:
= arbeitspreis_netto × jahresverbrauch / 100 / 12
Netzentgelte & Steuern/Monat:
= netzentgelte_gesamt × jahresverbrauch / 100 / 12
Grundpreis/Monat:
= grundpreis (tarifabhängig)
—————————————————————————————
MONATLICH = Arbeitspreiskosten + Netzentgelte + Grundpreis
GESAMT = MONATLICH × Vertragslaufzeit (Monate)
—————————————————————————————
3. Alle Parameter & Defaultwerte
3.1 Energiepreise
| Parameter | Variable | RLM Default | SLP Default | B2C Default | Editierbar | Quelle |
|---|---|---|---|---|---|---|
| Preis Solar PARQ | solar_preis | 7,50 ct/kWh | 7,50 ct/kWh | — | Ja (Dropdown 7,00-8,50 in 0,25er Schritten) | STARQ-intern |
| Gebietsrabatt Solar | gebietsrabatt | 0,00 ct/kWh | 0,00 ct/kWh | — | Ja | STARQ-intern |
| Preis Wind PPA | wind_preis | 8,50 ct/kWh | 8,50 ct/kWh | — | Nein (fixiert) | PPA-Vertrag |
| Preis Spot | spot_preis | 9,50 ct/kWh | 9,50 ct/kWh | — | Nein (fixiert) | EEX/EPEX |
| Beschaffungsgebühren | beschaffungsgebuehren | 0,40 ct/kWh | 0,40 ct/kWh | — | Nein (fixiert) | STARQ-intern |
| HKN (Herkunftsnachweise) | hkn | 0,01 ct/kWh | — | — | Nein (fixiert) | Markt |
| Arbeitspreis B2C | arbeitspreis | — | — | 11,19 ct/kWh | Nein (fixiert) | STARQ-intern |
3.2 Risikofaktoren
| Parameter | Variable | RLM Default | SLP Default | Editierbar |
|---|---|---|---|---|
| Ausgleichsenergie-Risiko | ausgleichsenergie | 0,60 ct/kWh | 0,30 ct/kWh | Ja |
| Mengen-Risiko | mengen | 0,10 ct/kWh | 0,10 ct/kWh | Ja |
| Marktpreis-Risiko | marktpreis | 0,20 ct/kWh | 0,10 ct/kWh | Ja |
| Kredit-Risiko | kredit | 0,50 ct/kWh | 0,50 ct/kWh | Ja |
| Summe Risiken | 1,40 ct/kWh | 1,00 ct/kWh | ||
| Summe pro (halbiert) | 0,70 ct/kWh | — |
3.3 Betriebskosten
| Parameter | Variable | Default | Editierbar |
|---|---|---|---|
| Overhead-Kosten | overhead | 1,50 ct/kWh | Ja (0,50-1,50) |
| Vertriebsprovision | vertriebsprovision | 0,15 ct/kWh | Ja |
3.4 Laufzeitrabatte
RLM + SLP (wird vom Overhead abgezogen)
| Vertragslaufzeit | Rabatt (ct/kWh) | Overhead nach Rabatt |
|---|---|---|
| 12 Monate | 0,00 | 1,50 |
| 24 Monate | 0,15 | 1,35 |
| 36 Monate | 0,30 | 1,20 |
| 48 Monate | 0,45 | 1,05 |
B2C (wird vom Arbeitspreis abgezogen)
| Vertragslaufzeit | Rabatt (ct/kWh) | Arbeitspreis nach Rabatt |
|---|---|---|
| 12 Monate | 0,00 | 11,19 |
| 24 Monate | 0,50 | 10,69 |
| 36 Monate | 1,00 | 10,19 |
3.5 Matching-Faktoren (berechnet, nicht manuell)
| Parameter | Variable | Beispielwert | Constraint |
|---|---|---|---|
| PV-Faktor | pv_faktor | 0,45 (45%) | 0,00–1,00 |
| Wind-Faktor | wind_faktor | 0,44 (44%) | 0,00–1,00 |
| Spot-Faktor | spot_faktor | 0,11 (11%) | 0,00–1,00 |
| Summe | 1,00 | Muss immer 1,00 ergeben |
3.6 Vertriebsprovision (Berechnung EUR)
Die Vertriebsprovision wird degressiv über die Vertragslaufzeit berechnet:
provision_jahr1 = vertriebsprovision × (jahresverbrauch / 100) → 100%
provision_jahr2 = vertriebsprovision × 0.8 × (jahresverbrauch / 100) → 80%
provision_jahr3 = vertriebsprovision × 0.6 × (jahresverbrauch / 100) → 60%
provision_jahr4 = vertriebsprovision × 0.4 × (jahresverbrauch / 100) → 40%
gesamt_provision = Summe der relevanten Jahre (abhängig von Vertragslaufzeit)
Beispiel: 0,15 ct/kWh × 121.352 kWh Jahresverbrauch:
- 12 Monate: 182,03 EUR
- 24 Monate: 182,03 + 145,62 = 327,65 EUR
- 36 Monate: 327,65 + 109,22 = 436,87 EUR
- 48 Monate: 436,87 + 72,81 = 509,68 EUR
3.7 B2C Netzentgelte & Steuern (Default-Werte)
| Komponente | Variable | Default (ct/kWh) |
|---|---|---|
| Netznutzungsentgelt | netznutzungsentgelt | 9,700 |
| §19 StromNEV-Umlage | nev_umlage | 1,559 |
| Offshore-Netzumlage | offshore_umlage | 0,941 |
| KWKG-Umlage | kwkg_umlage | 0,446 |
| Konzessionsabgabe | konzessionsabgabe | 2,390 |
| Stromsteuer | stromsteuer | 2,050 |
| Summe | 17,086 |
3.8 B2C Grundpreise
| Tarif | Grundpreis (EUR/Monat) |
|---|---|
| STARQ&fair (Privat) | 13,61 |
| STARQ&unternehmerisch (Gewerbe) | 11,43 |
4. Energy Matching Algorithmus
4.1 Übersicht
Der Matching-Algorithmus bestimmt, wie viel der Kundenlast durch eigene Erneuerbare-Energie-Anlagen abgedeckt werden kann.
INPUT:
Lastprofil des Kunden (CSV, 15-Min-Intervalle, ~35.040 Datenpunkte/Jahr)
PV-Generierungsprofil (aus Referenz-CSV oder echten Anlagendaten)
Wind-Generierungsprofil (aktuell synthetisch, perspektivisch echte Daten)
PROCESSING:
Für jede Stunde (0-23):
1. Kundenlast ermitteln (Durchschnitt der 15-Min-Werte)
2. PV-Erzeugung berechnen (skaliert auf Lastspitze)
3. Wind-Erzeugung berechnen
4. Erneuerbare Deckung = min(PV + Wind, Kundenlast)
5. Spot-Zukauf = Kundenlast - Erneuerbare Deckung
OUTPUT:
pv_faktor = Summe(PV genutzt) / Summe(Kundenlast)
wind_faktor = Summe(Wind genutzt) / Summe(Kundenlast)
spot_faktor = Summe(Spot-Zukauf) / Summe(Kundenlast)
24h-Chart-Daten (für Visualisierung)
4.2 PV-Profil
Aktuell: Statisches CSV-Profil (pv-generation-profile.csv) mit stündlichen Durchschnittswerten.
Skalierung: Das PV-Profil wird auf die Lastspitze des Kunden skaliert:
total_raw_pv = Summe(pv_profil[0..23])
pv_scale = (lastspitze × 1.0 × 24) / total_raw_pv
pv_skaliert[h] = pv_profil[h] × pv_scale
4.3 Wind-Profil
Aktuell synthetisch (muss perspektivisch durch echte Anlagendaten ersetzt werden):
wind[h] = (30 + sin((h + 2) / 24 × PI × 4) × 5) × 19.025 × 0.75
Dieses Profil erzeugt eine relativ gleichmäßige Windkurve mit leichten Schwankungen über den Tag.
4.4 Default-Lastprofil (wenn kein Upload)
Falls kein Kundenlastprofil hochgeladen wurde:
last[h] = 80 + sin((h - 6) / 24 × PI × 2) × 40
Typisches Gewerbe-Tagesprofil: niedrig nachts (~40 kW), hoch tagsüber (~120 kW).
4.5 SLP-Nutzungszeitprofile
Für SLP-Kunden (ohne Lastprofil) gibt es 4 vordefinierte Nutzungsprofile:
| Profil | Aktive Stunden | Beschreibung |
|---|---|---|
day | 08:00–18:00 (10h) | Typischer Gewerbebetrieb (Büro, Produktion) |
afternoon | 14:00–23:00 (9h) | Gastronomie, Einzelhandel |
night | 20:00–06:00 (10h) | Nachtbetrieb, Bäckereien |
continuous | 00:00–24:00 (24h) | Dauerbetrieb, Hotels, Rechenzentren |
Berechnung:
tagesverbrauch = jahresverbrauch / 365
stundenverbrauch = tagesverbrauch / anzahl_aktive_stunden
profil[h] = stundenverbrauch (wenn h in aktive Stunden) oder 0
4.6 Statistik-Berechnung
Für jede Stunde h (0-23):
genutzte_erneuerbare[h] = min(pv[h] + wind[h], last[h])
pv_anteil[h] = genutzte_erneuerbare[h] × (pv[h] / (pv[h] + wind[h]))
wind_anteil[h] = genutzte_erneuerbare[h] × (wind[h] / (pv[h] + wind[h]))
spot[h] = last[h] - genutzte_erneuerbare[h]
Aggregation:
pv_faktor = Summe(pv_anteil) / Summe(last)
wind_faktor = Summe(wind_anteil) / Summe(last)
spot_faktor = Summe(spot) / Summe(last)
erneuerbare_prozent = (pv_faktor + wind_faktor) × 100
Wichtig: Überproduktion wird NICHT gezählt. Wenn PV+Wind > Kundenlast, wird nur der genutzte Anteil berücksichtigt.
5. CSV-Parser (Lastprofil)
5.1 Unterstützte Formate
Primärformat (RLM-Lastprofil von Netzbetreiber):
# Zählpunkt-ID;;SWF_SST_1163255;;
# Zählpunkbezeichnung;;DE0001815463400000201700000064974;;
# Marktlokation;;50522423206;;
# OBIS-Kennzeichen;;1-1:1.29.0;;
# Einheit (Originaleinheit);;kW;;
# Periode;;15 Minuten;;
01.01.2025;00:15:00;4,528000;W;
01.01.2025;00:30:00;4,344000;W;
...
31.12.2025;23:45:00;3,320000;W;
5.2 Parsing-Regeln
1. Header-Zeilen: Beginnen mit '#', Format: "# Key;;Value;;"
→ Extrahiere: Marktlokation, Einheit, Periode, Zählpunktbezeichnung
2. Daten-Zeilen: "Datum;Uhrzeit;Leistung;Status;"
→ Trennzeichen: Semikolon
→ Dezimalzeichen: KOMMA (deutsch!) → in Punkt umwandeln
→ Status "W" = gemessener Wert (valide)
→ Leistung in kW
3. Umrechnung kW → kWh:
→ kWh = kW × 0,25 (15-Min-Intervall = 0,25 Stunden)
4. Aggregation:
→ Stundenprofil: 4 Werte pro Stunde → Durchschnitt
→ Tagesverbrauch: Summe aller 96 Intervalle × 0,25
→ Jahresverbrauch: Summe aller Tagesverbräuche
5.3 Validierung
✓ Mindestens 365 Tage Daten (> 33.000 Datenpunkte)
✓ Konsistentes 15-Minuten-Intervall
✓ Alle Werte positiv (kW ≥ 0)
✓ Lastspitze plausibel (< 10.000 kW für typische RLM-Kunden)
✓ Keine Lücken > 1 Tag
⚠ Warnung bei < 95% Datenvollständigkeit
5.4 Berechnete Kennzahlen
| KPI | Formel | Beispielwert |
|---|---|---|
| Jahresverbrauch | Summe aller (kW × 0,25) | 121.352 kWh |
| Durchschnittsleistung | Jahresverbrauch / 8.760 | 13,85 kW |
| Lastspitze | max(alle kW-Werte) | 87,5 kW |
| Benutzungsstunden | Jahresverbrauch / Lastspitze | 1.387 h/a |
| Tagesprofil (24h) | Durchschnitt pro Stunde | Array[24] |
5.5 PV-Generierungs-CSV
Separates Format (für PV-Referenzprofil):
timestamp;generation
2025-01-01T00:00:00;0.0
2025-01-01T01:00:00;0.0
...
2025-01-01T12:00:00;4523.7
...
Parsing: timestamp;generation mit Punkt als Dezimalzeichen, aggregiert auf 24h-Durchschnitt.
6. HubSpot-Integration
6.1 Trigger: HubSpot Action Button
Setup in HubSpot:
- Custom Action Button auf dem Deal-Record erstellen (HubSpot CRM Extensions API)
- Button-Label: „MatchPoint Analyse starten“
- Sichtbar ab B2B Pipeline Stage „Angebot angefragt“
- Button sendet Webhook an:
POST https://platform.starqstrom.de/api/matchpoint/trigger
Webhook Payload:
{
"objectId": 12345678,
"objectType": "DEAL",
"portalId": 146570641,
"userId": 98765
}
6.2 Daten aus HubSpot lesen (Input)
Die STARQ Platform liest folgende Daten aus dem HubSpot Deal:
| HubSpot Property | Beschreibung | Verwendet für |
|---|---|---|
jahresverbrauch__kwh_ | Jahresverbrauch (kWh) | Lastberechnung |
kundentyp | „rlm“ oder „slp“ | Tarifauswahl |
tarif | Gewählter Tarif | Preisformel |
mindestvertragslaufzeit_in_monaten | Laufzeit | Laufzeitrabatt |
dealname | Dealname | Anzeige |
| Associated Contact | ||
firstname, lastname | Kundenname | Angebots-PDF |
email | Benachrichtigung | |
| Associated Company | ||
name | Firmenname | Angebots-PDF |
netzgebiet | Netzgebiet | Netzentgelt-Zuordnung |
| Lastprofil | ||
| Deal Notes → File Attachment | CSV/Excel-Datei | Energy Matching |
Lastprofil abrufen:
1. GET /crm/v3/objects/deals/{dealId}/associations/notes → Note-IDs
2. GET /crm/v3/objects/notes/{noteId} → hs_attachment_ids
3. GET /files/v3/files/{fileId}/signed-url → Download-URL
4. Download CSV/Excel-Datei
6.3 Ergebnisse in HubSpot schreiben (Output)
Nach der Berechnung werden folgende Properties auf den Deal geschrieben:
| HubSpot Property | Typ | Wert | Quelle |
|---|---|---|---|
pv_faktor | Decimal | 0,00–1,00 | MatchPoint (Matching) |
wind_faktor | Decimal | 0,00–1,00 | MatchPoint (Matching) |
spot_faktor | Decimal | 0,00–1,00 | MatchPoint (Matching) |
anteil_erneuerbare | Percentage | 0–100% | Berechnet |
anteil_boersenzukauf | Percentage | 0–100% | Berechnet |
preis_solar_in_ctkwh | Decimal | z.B. 7,50 | Parameter |
preis_windanteil_in_ctkwh | Decimal | z.B. 8,50 | Parameter |
preis_spot_in_ctkwh | Decimal | z.B. 9,50 | Parameter |
preis_herkunftsnachweise_in_ctkwh | Decimal | z.B. 0,01 | Parameter |
preis_risiken_in_ctkwh | Decimal | z.B. 1,40 | Berechnet |
preis_fur_overhead_in_ctkwh | Decimal | z.B. 1,50 | Parameter |
provision_in_ctkwh | Decimal | z.B. 0,15 | Parameter |
netto_arbeitspreis | Decimal | z.B. 12,18 | Berechnet |
summe_provision_in_eur | Currency | z.B. 327,65 | Berechnet |
fixierte_liefermenge | Integer | kWh (Fix-Anteil) | Berechnet |
liefermenge_flex_in | Integer | kWh (Flex-Anteil) | Berechnet |
energiepreis_flex_in_ctkwh | Decimal | z.B. 9,91 | Berechnet |
Neue Properties (müssen in HubSpot angelegt werden):
pv_faktor(Decimal)wind_faktor(Decimal)spot_faktor(Decimal)matchpoint_status(Enum: pending, calculated, error)matchpoint_calculated_at(DateTime)matchpoint_scenario_url(URL — Link zur Szenario-Seite)
6.4 Deal-Stage Update
Nach erfolgreicher Berechnung:
PATCH /crm/v3/objects/deals/{dealId}
{
"properties": {
"dealstage": "angebot_erstellt",
"matchpoint_status": "calculated",
"matchpoint_calculated_at": "2026-03-15T14:30:00Z",
"matchpoint_scenario_url": "https://platform.starqstrom.de/matchpoint/scenarios/{scenario_id}"
}
}
7. Multi-Szenario Preisseite
7.1 Konzept
Nach der MatchPoint-Berechnung werden automatisch 3-4 Szenarien generiert. Der Vertriebler öffnet die Szenario-Seite in der STARQ Platform und kann:
- Szenarien vergleichen
- Parameter anpassen (Provision, Rabatt, Laufzeit)
- Ein Szenario als Angebot auswählen → wird in HubSpot geschrieben
7.2 Automatisch generierte Szenarien
| Szenario | Tarif | Laufzeit | Besonderheit |
|---|---|---|---|
| A — Standard | STARQ&pro | 24 Monate | Standardparameter |
| B — Langfristig | STARQ&pro | 48 Monate | Maximaler Laufzeitrabatt |
| C — Festpreis | STARQ&fix | 24 Monate | Ein Preis, kein Risiko für Kunden |
| D — Individuell | STARQ&pro | 36 Monate | Angepasste Provision/Overhead |
7.3 Anpassbare Parameter pro Szenario
Der Vertriebler kann folgende Werte pro Szenario ändern:
| Parameter | Änderbar | Bereich |
|---|---|---|
| Tarif (pro/fix) | Ja | Dropdown |
| Vertragslaufzeit | Ja | 12/24/36/48 Monate |
| Vertriebsprovision | Ja | 0,00–0,50 ct/kWh |
| Overhead | Ja | 0,50–1,50 ct/kWh |
| Solar-Preis | Ja | 7,00–8,50 ct/kWh |
| Gebietsrabatt | Ja | 0,00–1,00 ct/kWh |
| Risikofaktoren | Eingeschränkt | Nur mit Manager-Berechtigung |
7.4 Szenario-Vergleichsseite (UI-Beschreibung)
+---------------------------------------------------------------------+
| MatchPoint -- Szenario-Vergleich |
| Kunde: Müller GmbH | Verbrauch: 121.352 kWh/a | Deal #12345 |
+---------------------------------------------------------------------+
| |
| +---- 24h Matching-Chart (Recharts -> Chartkick/Chart.js) --------+|
| | [Stacked Area Chart: Kundenlast vs PV vs Wind vs Spot] ||
| | PV: 45% | Wind: 44% | Spot: 11% | Erneuerbar: 89% ||
| +----------------------------------------------------------------+|
| |
| +--- Szenario A ---+ +--- Szenario B ---+ +--- Szenario C ---+ |
| | STARQ&pro 24M | | STARQ&pro 48M | | STARQ&fix 24M | |
| | | | | | | |
| | Fix: 10,34 ct | | Fix: 9,89 ct | | Preis: 12,18 ct | |
| | Flex: 9,91 ct | | Flex: 9,91 ct | | | |
| | | | | | | |
| | Jahr: 12.540 EUR | | Jahr: 11.980 EUR | | Jahr: 14.780 EUR | |
| | Gesamt: 25.080 EUR| | Gesamt: 47.920 EUR| | Gesamt: 29.560 EUR| |
| | Provision: 328 EUR| | Provision: 510 EUR| | Provision: 328 EUR| |
| | | | | | | |
| | [Anpassen] | | [Anpassen] | | [Anpassen] | |
| | [Auswaehlen] | | [Auswaehlen] | | [Auswaehlen] | |
| +-------------------+ +-------------------+ +-------------------+ |
| |
| [+ Weiteres Szenario] [PDF exportieren] |
+---------------------------------------------------------------------+
7.5 „Auswählen“ → HubSpot
Wenn der Vertriebler ein Szenario auswählt:
- Alle Pricing-Properties werden auf den HubSpot Deal geschrieben (Abschnitt 6.3)
- Deal-Stage wird auf „Angebot erstellt“ gesetzt
- Link zur Szenario-Seite wird als
matchpoint_scenario_urlgespeichert - Portant generiert automatisch das PDF-Angebot aus den HubSpot-Daten
8. Rails-Architektur
8.1 Models
# app/models/load_profile.rb
class LoadProfile < ApplicationRecord
belongs_to :deal_sync # Verknüpfung mit HubSpot Deal
has_many :energy_matching_results
# Attribute:
# - hubspot_deal_id (string)
# - hubspot_file_id (string)
# - file_name (string)
# - file_format (string: csv/excel/pdf)
# - annual_consumption_kwh (integer)
# - peak_load_kw (decimal)
# - usage_hours (integer)
# - hourly_profile (jsonb, Array[24])
# - raw_data_points (integer)
# - data_quality (string: complete/partial/poor)
# - metadata (jsonb: marktlokation, zaehlpunkt, etc.)
# - parsed_at (datetime)
end
# app/models/energy_matching_result.rb
class EnergyMatchingResult < ApplicationRecord
belongs_to :load_profile
has_many :price_scenarios
# Attribute:
# - pv_factor (decimal, 0.00-1.00)
# - wind_factor (decimal, 0.00-1.00)
# - spot_factor (decimal, 0.00-1.00)
# - renewable_percentage (decimal)
# - hourly_chart_data (jsonb, Array[24] mit pv/wind/spot/load)
# - calculated_at (datetime)
end
# app/models/price_scenario.rb
class PriceScenario < ApplicationRecord
belongs_to :energy_matching_result
# Attribute:
# - name (string: "Standard", "Langfristig", "Festpreis", "Individuell")
# - tariff_type (string: "pro", "fix")
# - contract_duration_months (integer: 12/24/36/48)
# - solar_price (decimal, ct/kWh)
# - gebietsrabatt (decimal, ct/kWh)
# - wind_price (decimal, ct/kWh)
# - spot_price (decimal, ct/kWh)
# - beschaffungsgebuehren (decimal, ct/kWh)
# - hkn_price (decimal, ct/kWh)
# - ausgleichsenergie_risiko (decimal, ct/kWh)
# - mengen_risiko (decimal, ct/kWh)
# - marktpreis_risiko (decimal, ct/kWh)
# - kredit_risiko (decimal, ct/kWh)
# - overhead (decimal, ct/kWh)
# - vertriebsprovision (decimal, ct/kWh)
# - laufzeitrabatt (decimal, ct/kWh)
#
# Berechnete Felder (nach save):
# - pro_fix_endpreis (decimal, ct/kWh)
# - pro_flex_endpreis (decimal, ct/kWh)
# - fix_blended_preis (decimal, ct/kWh)
# - annual_cost_eur (decimal)
# - total_cost_eur (decimal)
# - provision_total_eur (decimal)
# - renewable_consumption_kwh (integer)
# - spot_consumption_kwh (integer)
#
# Status:
# - selected (boolean) -- Vertriebler hat dieses Szenario gewählt
# - synced_to_hubspot (boolean)
# - synced_at (datetime)
end
# app/models/pricing_parameter.rb
class PricingParameter < ApplicationRecord
# Globale Parameter-Verwaltung (Admin-bereich)
# Versioniert -- jede Änderung erstellt neuen Datensatz
# Attribute:
# - parameter_name (string, unique)
# - value (decimal)
# - unit (string: "ct/kWh", "EUR/Monat")
# - category (string: "energy", "risk", "cost", "tax")
# - editable_by (string: "admin", "sales", "readonly")
# - valid_from (date)
# - valid_until (date, nullable)
# - changed_by (string)
end
8.2 Service Objects
# app/services/matchpoint/load_profile_parser.rb
module Matchpoint
class LoadProfileParser
# Parsed CSV/Excel Lastprofil
# Input: file_content (String), file_format (String)
# Output: { hourly_profile: Array[24], annual_kwh: Integer,
# peak_kw: Decimal, usage_hours: Integer, metadata: Hash }
def call(file_content, file_format:)
case file_format
when "csv" then parse_csv(file_content)
when "excel" then parse_excel(file_content)
else raise UnsupportedFormatError
end
end
private
def parse_csv(content)
# 1. Header-Zeilen (# ...) parsen -> metadata
# 2. Datenzeilen parsen (Datum;Uhrzeit;kW;Status)
# 3. Deutsches Dezimalkomma -> Punkt
# 4. kW × 0.25 = kWh (15-Min-Intervall)
# 5. Aggregation auf 24h-Profil
# 6. Statistiken berechnen
end
end
end
# app/services/matchpoint/energy_matching_service.rb
module Matchpoint
class EnergyMatchingService
# Berechnet PV/Wind/Spot-Faktoren
# Input: hourly_profile (Array[24]), peak_kw (Decimal)
# Output: { pv_factor: Decimal, wind_factor: Decimal,
# spot_factor: Decimal, chart_data: Array[24] }
def call(hourly_profile, peak_kw:)
pv_profile = load_pv_profile(peak_kw)
wind_profile = generate_wind_profile
chart_data = (0..23).map do |h|
customer_load = hourly_profile[h]
pv = pv_profile[h]
wind = wind_profile[h]
total_renewable = pv + wind
used_renewable = [total_renewable, customer_load].min
spot = customer_load - used_renewable
{ hour: h, load: customer_load, pv: pv, wind: wind, spot: spot }
end
# Faktoren berechnen (mit Überproduktions-Korrektur)
total_load = chart_data.sum { |d| d[:load] }
# ... (wie in Abschnitt 4.6)
end
end
end
# app/services/matchpoint/pricing_calculator_service.rb
module Matchpoint
class PricingCalculatorService
# Berechnet Preise für ein Szenario
# Input: PriceScenario, EnergyMatchingResult
# Output: berechnete Felder auf dem PriceScenario
def call(scenario)
result = scenario.energy_matching_result
case scenario.tariff_type
when "pro" then calculate_pro(scenario, result)
when "fix" then calculate_fix(scenario, result)
end
calculate_provision(scenario)
scenario.save!
end
private
def calculate_pro(scenario, result)
# Siehe Formel Abschnitt 2.1.1
fix_anteil = result.pv_factor + result.wind_factor
flex_anteil = result.spot_factor
solar_netto = scenario.solar_price - scenario.gebietsrabatt
renewable_avg = (result.pv_factor * solar_netto +
result.wind_factor * scenario.wind_price) / fix_anteil
risiken_pro = total_risks(scenario) / 2.0 # HALBIERT!
kosten = overhead_after_discount(scenario) + scenario.vertriebsprovision
scenario.pro_fix_endpreis = renewable_avg + risiken_pro + kosten
scenario.pro_flex_endpreis = scenario.spot_price +
scenario.beschaffungsgebuehren +
scenario.hkn_price
# ...
end
def calculate_fix(scenario, result)
# Siehe Formel Abschnitt 2.1.2
# HKN nur auf Spot-Anteil!
# ...
end
end
end
# app/services/matchpoint/hubspot_sync_service.rb
module Matchpoint
class HubspotSyncService
# Schreibt ausgewähltes Szenario in HubSpot Deal
# Input: PriceScenario (selected = true)
# Output: HubSpot Deal Update
def call(scenario)
deal_id = scenario.energy_matching_result.load_profile.hubspot_deal_id
properties = {
pv_faktor: scenario.energy_matching_result.pv_factor,
wind_faktor: scenario.energy_matching_result.wind_factor,
spot_faktor: scenario.energy_matching_result.spot_factor,
netto_arbeitspreis: effective_price(scenario),
preis_solar_in_ctkwh: scenario.solar_price,
preis_windanteil_in_ctkwh: scenario.wind_price,
preis_spot_in_ctkwh: scenario.spot_price,
preis_risiken_in_ctkwh: total_risks(scenario),
preis_fur_overhead_in_ctkwh: scenario.overhead,
provision_in_ctkwh: scenario.vertriebsprovision,
summe_provision_in_eur: scenario.provision_total_eur,
fixierte_liefermenge: scenario.renewable_consumption_kwh,
liefermenge_flex_in: scenario.spot_consumption_kwh,
energiepreis_flex_in_ctkwh: scenario.pro_flex_endpreis,
matchpoint_status: "calculated",
matchpoint_calculated_at: Time.current.iso8601,
dealstage: "angebot_erstellt"
}
HubspotClient.update_deal(deal_id, properties)
end
end
end
# app/services/matchpoint/scenario_generator_service.rb
module Matchpoint
class ScenarioGeneratorService
# Generiert automatisch 3-4 Preisszenarien
# Input: EnergyMatchingResult
# Output: Array[PriceScenario]
SCENARIO_TEMPLATES = [
{ name: "Standard", tariff: "pro", duration: 24 },
{ name: "Langfristig", tariff: "pro", duration: 48 },
{ name: "Festpreis", tariff: "fix", duration: 24 },
{ name: "Individuell", tariff: "pro", duration: 36 }
].freeze
def call(matching_result)
defaults = PricingParameter.current_defaults
SCENARIO_TEMPLATES.map do |template|
scenario = matching_result.price_scenarios.build(
name: template[:name],
tariff_type: template[:tariff],
contract_duration_months: template[:duration],
**defaults
)
PricingCalculatorService.new.call(scenario)
scenario
end
end
end
end
8.3 Controllers
# app/controllers/matchpoint_controller.rb
class MatchpointController < ApplicationController
# POST /api/matchpoint/trigger (HubSpot Webhook)
def trigger
deal_id = params[:objectId]
MatchpointCalculationJob.perform_later(deal_id)
head :accepted
end
# GET /matchpoint/scenarios/:matching_result_id
def scenarios
@result = EnergyMatchingResult.find(params[:matching_result_id])
@scenarios = @result.price_scenarios.order(:name)
@chart_data = @result.hourly_chart_data
end
# PATCH /matchpoint/scenarios/:id/update_params
def update_scenario
@scenario = PriceScenario.find(params[:id])
@scenario.assign_attributes(scenario_params)
PricingCalculatorService.new.call(@scenario)
respond_to do |format|
format.turbo_stream # Turbo Frame Update
end
end
# POST /matchpoint/scenarios/:id/select
def select_scenario
@scenario = PriceScenario.find(params[:id])
@scenario.update!(selected: true)
HubspotSyncService.new.call(@scenario)
redirect_to scenarios_path, notice: "Szenario ausgewählt und an HubSpot übertragen."
end
end
8.4 Background Job
# app/jobs/matchpoint_calculation_job.rb
class MatchpointCalculationJob < ApplicationJob
queue_as :matchpoint
retry_on StandardError, wait: 5.minutes, attempts: 3
def perform(hubspot_deal_id)
# 1. Kundendaten + Lastprofil aus HubSpot laden
deal_data = HubspotClient.get_deal(hubspot_deal_id,
associations: [:contacts, :companies, :notes])
file_content = download_lastprofil(deal_data)
# 2. Lastprofil parsen
profile = LoadProfile.create!(hubspot_deal_id: hubspot_deal_id)
parsed = Matchpoint::LoadProfileParser.new.call(
file_content, file_format: "csv")
profile.update!(parsed)
# 3. Energy Matching
matching = Matchpoint::EnergyMatchingService.new.call(
parsed[:hourly_profile], peak_kw: parsed[:peak_kw]
)
result = profile.energy_matching_results.create!(matching)
# 4. Szenarien generieren
Matchpoint::ScenarioGeneratorService.new.call(result)
# 5. Status in HubSpot setzen
HubspotClient.update_deal(hubspot_deal_id, {
matchpoint_status: "calculated",
matchpoint_scenario_url: scenario_url(result)
})
end
end
8.5 Views (Turbo + Stimulus)
| View | Route | Beschreibung |
|---|---|---|
matchpoint/scenarios/index | GET /matchpoint/scenarios/:id | Szenario-Vergleichsseite (Hauptseite) |
matchpoint/scenarios/_scenario_card | Partial | Einzelnes Szenario als Turbo Frame |
matchpoint/scenarios/_chart | Partial | 24h Matching-Chart (Chartkick + Chart.js) |
matchpoint/scenarios/_edit_form | Partial | Parameter-Anpassung (Turbo Frame) |
matchpoint/dashboard/index | GET /matchpoint | Übersicht aller MatchPoint-Berechnungen |
8.6 Stimulus Controllers
// app/javascript/controllers/scenario_controller.js
// Handles real-time parameter changes and chart updates
// app/javascript/controllers/chart_controller.js
// 24h Matching Chart mit Chart.js (Stacked Area)
// app/javascript/controllers/compare_controller.js
// Szenario-Vergleich: Highlights, Sorting, Selection
9. Datenbank-Migrationen
# db/migrate/xxx_create_load_profiles.rb
create_table :load_profiles do |t|
t.string :hubspot_deal_id, null: false, index: true
t.string :hubspot_file_id
t.string :file_name
t.string :file_format # csv, excel, pdf
t.integer :annual_consumption_kwh
t.decimal :peak_load_kw, precision: 10, scale: 2
t.integer :usage_hours
t.jsonb :hourly_profile, default: [] # Array[24]
t.integer :raw_data_points
t.string :data_quality # complete, partial, poor
t.jsonb :metadata, default: {}
t.datetime :parsed_at
t.timestamps
end
# db/migrate/xxx_create_energy_matching_results.rb
create_table :energy_matching_results do |t|
t.references :load_profile, null: false, foreign_key: true
t.decimal :pv_factor, precision: 5, scale: 4
t.decimal :wind_factor, precision: 5, scale: 4
t.decimal :spot_factor, precision: 5, scale: 4
t.decimal :renewable_percentage, precision: 5, scale: 2
t.jsonb :hourly_chart_data, default: []
t.datetime :calculated_at
t.timestamps
end
# db/migrate/xxx_create_price_scenarios.rb
create_table :price_scenarios do |t|
t.references :energy_matching_result, null: false, foreign_key: true
t.string :name
t.string :tariff_type # pro, fix
t.integer :contract_duration_months
# Energiepreise
t.decimal :solar_price, precision: 6, scale: 3
t.decimal :gebietsrabatt, precision: 6, scale: 3, default: 0
t.decimal :wind_price, precision: 6, scale: 3
t.decimal :spot_price, precision: 6, scale: 3
t.decimal :beschaffungsgebuehren, precision: 6, scale: 3
t.decimal :hkn_price, precision: 6, scale: 3
# Risiken
t.decimal :ausgleichsenergie_risiko, precision: 6, scale: 3
t.decimal :mengen_risiko, precision: 6, scale: 3
t.decimal :marktpreis_risiko, precision: 6, scale: 3
t.decimal :kredit_risiko, precision: 6, scale: 3
# Kosten
t.decimal :overhead, precision: 6, scale: 3
t.decimal :vertriebsprovision, precision: 6, scale: 3
t.decimal :laufzeitrabatt, precision: 6, scale: 3
# Berechnete Ergebnisse
t.decimal :pro_fix_endpreis, precision: 8, scale: 4
t.decimal :pro_flex_endpreis, precision: 8, scale: 4
t.decimal :fix_blended_preis, precision: 8, scale: 4
t.decimal :annual_cost_eur, precision: 12, scale: 2
t.decimal :total_cost_eur, precision: 12, scale: 2
t.decimal :provision_total_eur, precision: 10, scale: 2
t.integer :renewable_consumption_kwh
t.integer :spot_consumption_kwh
# Status
t.boolean :selected, default: false
t.boolean :synced_to_hubspot, default: false
t.datetime :synced_at
t.timestamps
end
# db/migrate/xxx_create_pricing_parameters.rb
create_table :pricing_parameters do |t|
t.string :parameter_name, null: false
t.decimal :value, precision: 10, scale: 4, null: false
t.string :unit
t.string :category # energy, risk, cost, tax
t.string :editable_by # admin, sales, readonly
t.date :valid_from, null: false
t.date :valid_until
t.string :changed_by
t.timestamps
end
add_index :pricing_parameters, [:parameter_name, :valid_from], unique: true
10. Test-Szenario (Referenzdatensatz)
Eingabedaten (aus echtem HubSpot-Deal)
| Parameter | Wert |
|---|---|
| Kunde | Gewerbekunde Bitburg |
| Jahresverbrauch | 121.352 kWh |
| Lastspitze | 87,5 kW |
| Benutzungsstunden | 1.387 h/a |
| Datenpunkte | 35.040 (15-Min) |
| Marktlokation | 50522423206 |
| Profil | Werktag 07-16 Uhr, Wochenende nur Grundlast |
Erwartete Ergebnisse (mit Default-Parametern)
| Szenario | Tarif | Laufzeit | Fix-Preis | Flex-Preis | Blended | Jahreskosten |
|---|---|---|---|---|---|---|
| Standard | pro | 24M | ~10,34 ct | ~9,91 ct | — | ~12.540 € |
| Langfristig | pro | 48M | ~9,89 ct | ~9,91 ct | — | ~11.980 € |
| Festpreis | fix | 24M | — | — | ~12,18 ct | ~14.780 € |
11. Offene Entscheidungen
| # | Thema | Optionen | Empfehlung |
|---|---|---|---|
| 1 | PV-Profil Datenquelle | a) Statisches CSV (wie aktuell) b) Echte PARQ-Anlagendaten c) Meteo Control API | b) für Genauigkeit, a) als Fallback |
| 2 | Wind-Profil | a) Synthetische Formel (wie aktuell) b) Echte Winddaten (PARQ) c) Wetter-API | b) sobald verfügbar |
| 3 | HubSpot Action Button | a) CRM Extensions API b) Custom Card mit Workflow c) Externer Link im Deal | a) ist am elegantesten |
| 4 | PDF-Export | a) jsPDF in Rails (Prawn gem) b) Portant (bestehendes HubSpot-Tool) c) Beides | b) für Angebots-PDFs, a) für interne Reports |
| 5 | Echtzeit-Updates | a) Turbo Streams (WebSocket) b) Polling c) Nur bei Reload | a) für beste UX |
Anhang A: Quellcode-Referenz (React → Rails Mapping)
| React-Datei | Rails-Äquivalent | Beschreibung |
|---|---|---|
PriceCalculation.tsx | Matchpoint::PricingCalculatorService + Views | RLM Pricing (STARQ&pro + STARQ&fix) |
SlpPriceCalculation.tsx | Matchpoint::PricingCalculatorService (SLP-Modus) | SLP Pricing |
CalculationDetails.tsx | Matchpoint::EnergyMatchingService + Chart-View | Energy Matching + 24h Chart |
SlpCalculation.tsx | Views (SLP Input-Form) | SLP Nutzungszeit-Auswahl |
PrivateCalculation.tsx | Separater B2C Pricing Controller | B2C Tarif-Berechnung |
csvParser.ts | Matchpoint::LoadProfileParser | CSV Parsing |
pdfExport.ts | Prawn Gem oder Portant | PDF-Export |
Anhang B: Abhängigkeiten (npm → Ruby Gems)
| npm Package | Ruby Gem | Zweck |
|---|---|---|
recharts | chartkick + chart.js | Charts |
jspdf + jspdf-autotable | prawn + prawn-table | PDF-Export |
react-router-dom | Rails Router | Routing |
tailwindcss | tailwindcss-rails | CSS |
zod | ActiveModel Validations | Validierung |
sonner | Turbo Flash Messages | Notifications |
@radix-ui/* | ViewComponent oder Stimulus | UI Komponenten |