100% erneuerbare Energie
MatchPoint — Entwickler-Arbeitsanweisung
Phase 2 — Rails 8.1 Rebuild
Pricing-Formeln • Rails-Architektur • HubSpot-Integration
Version
1.0
Datum
25. Februar 2026
Phase
2
Quellcode-Referenz
Processly/matchpoint-starqstrom

Inhaltsverzeichnis

← Zurück zur Dokumentation

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-StackReact 18 + TypeScript + Vite + Tailwind + Recharts
HostingLovable.app (Frontend-only, keine API, keine DB)
DatenflussManuell — Vertriebler öffnet Tool, tippt Werte ein, liest Ergebnis ab
HubSpotKein automatischer Datenfluss
Repositorygithub.com/Processly/matchpoint-starqstrom

Zielzustand (STARQ Platform / Rails 8.1)

Eigenschaft Ziel
Tech-StackRuby 4.0 / Rails 8.1 / PostgreSQL / Sidekiq / Tailwind / Stimulus / Turbo
HostingHetzner (als Teil der STARQ Platform)
DatenflussAutomatisch — HubSpot Action Button → STARQ Platform → Berechnung → Ergebnis zurück in HubSpot
HubSpotBidirektional — Kundendaten rein, Preisszenarien raus
Multi-SzenarioVertriebler 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&proPremium-Tarif mit Dual-PreisFix-Preis (Erneuerbar) + Flex-Preis (Spot)
STARQ&fixEinfacher FestpreisEin einziger Blended-Preis

2.1.1 STARQ&pro — Dual-Preis (Fix + Flex)

Konzept: Der Kunde zahlt zwei separate Preise:

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
PreisstrukturDual (Fix + Flex)Single (Blended)
RisikenHalbiert (÷ 2)Voll (× 1)
HKNIm Flexpreis enthaltenNur auf Spot-Anteil
Vorteil KundeGünstiger bei hohem Spot-AnteilEinfacher, ein Preis
Vorteil STARQstromMarktpreisrisiko beim KundenVoller 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 PARQsolar_preis7,50 ct/kWh7,50 ct/kWhJa (Dropdown 7,00-8,50 in 0,25er Schritten)STARQ-intern
Gebietsrabatt Solargebietsrabatt0,00 ct/kWh0,00 ct/kWhJaSTARQ-intern
Preis Wind PPAwind_preis8,50 ct/kWh8,50 ct/kWhNein (fixiert)PPA-Vertrag
Preis Spotspot_preis9,50 ct/kWh9,50 ct/kWhNein (fixiert)EEX/EPEX
Beschaffungsgebührenbeschaffungsgebuehren0,40 ct/kWh0,40 ct/kWhNein (fixiert)STARQ-intern
HKN (Herkunftsnachweise)hkn0,01 ct/kWhNein (fixiert)Markt
Arbeitspreis B2Carbeitspreis11,19 ct/kWhNein (fixiert)STARQ-intern

3.2 Risikofaktoren

Parameter Variable RLM Default SLP Default Editierbar
Ausgleichsenergie-Risikoausgleichsenergie0,60 ct/kWh0,30 ct/kWhJa
Mengen-Risikomengen0,10 ct/kWh0,10 ct/kWhJa
Marktpreis-Risikomarktpreis0,20 ct/kWh0,10 ct/kWhJa
Kredit-Risikokredit0,50 ct/kWh0,50 ct/kWhJa
Summe Risiken1,40 ct/kWh1,00 ct/kWh
Summe pro (halbiert)0,70 ct/kWh

3.3 Betriebskosten

Parameter Variable Default Editierbar
Overhead-Kostenoverhead1,50 ct/kWhJa (0,50-1,50)
Vertriebsprovisionvertriebsprovision0,15 ct/kWhJa

3.4 Laufzeitrabatte

RLM + SLP (wird vom Overhead abgezogen)

Vertragslaufzeit Rabatt (ct/kWh) Overhead nach Rabatt
12 Monate0,001,50
24 Monate0,151,35
36 Monate0,301,20
48 Monate0,451,05

B2C (wird vom Arbeitspreis abgezogen)

Vertragslaufzeit Rabatt (ct/kWh) Arbeitspreis nach Rabatt
12 Monate0,0011,19
24 Monate0,5010,69
36 Monate1,0010,19

3.5 Matching-Faktoren (berechnet, nicht manuell)

Parameter Variable Beispielwert Constraint
PV-Faktorpv_faktor0,45 (45%)0,00–1,00
Wind-Faktorwind_faktor0,44 (44%)0,00–1,00
Spot-Faktorspot_faktor0,11 (11%)0,00–1,00
Summe1,00Muss 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:

3.7 B2C Netzentgelte & Steuern (Default-Werte)

Komponente Variable Default (ct/kWh)
Netznutzungsentgeltnetznutzungsentgelt9,700
§19 StromNEV-Umlagenev_umlage1,559
Offshore-Netzumlageoffshore_umlage0,941
KWKG-Umlagekwkg_umlage0,446
Konzessionsabgabekonzessionsabgabe2,390
Stromsteuerstromsteuer2,050
Summe17,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
day08:00–18:00 (10h)Typischer Gewerbebetrieb (Büro, Produktion)
afternoon14:00–23:00 (9h)Gastronomie, Einzelhandel
night20:00–06:00 (10h)Nachtbetrieb, Bäckereien
continuous00: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
JahresverbrauchSumme aller (kW × 0,25)121.352 kWh
DurchschnittsleistungJahresverbrauch / 8.76013,85 kW
Lastspitzemax(alle kW-Werte)87,5 kW
BenutzungsstundenJahresverbrauch / Lastspitze1.387 h/a
Tagesprofil (24h)Durchschnitt pro StundeArray[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:

  1. Custom Action Button auf dem Deal-Record erstellen (HubSpot CRM Extensions API)
  2. Button-Label: „MatchPoint Analyse starten“
  3. Sichtbar ab B2B Pipeline Stage „Angebot angefragt“
  4. 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
tarifGewählter TarifPreisformel
mindestvertragslaufzeit_in_monatenLaufzeitLaufzeitrabatt
dealnameDealnameAnzeige
Associated Contact
firstname, lastnameKundennameAngebots-PDF
emailE-MailBenachrichtigung
Associated Company
nameFirmennameAngebots-PDF
netzgebietNetzgebietNetzentgelt-Zuordnung
Lastprofil
Deal Notes → File AttachmentCSV/Excel-DateiEnergy 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_faktorDecimal0,00–1,00MatchPoint (Matching)
wind_faktorDecimal0,00–1,00MatchPoint (Matching)
spot_faktorDecimal0,00–1,00MatchPoint (Matching)
anteil_erneuerbarePercentage0–100%Berechnet
anteil_boersenzukaufPercentage0–100%Berechnet
preis_solar_in_ctkwhDecimalz.B. 7,50Parameter
preis_windanteil_in_ctkwhDecimalz.B. 8,50Parameter
preis_spot_in_ctkwhDecimalz.B. 9,50Parameter
preis_herkunftsnachweise_in_ctkwhDecimalz.B. 0,01Parameter
preis_risiken_in_ctkwhDecimalz.B. 1,40Berechnet
preis_fur_overhead_in_ctkwhDecimalz.B. 1,50Parameter
provision_in_ctkwhDecimalz.B. 0,15Parameter
netto_arbeitspreisDecimalz.B. 12,18Berechnet
summe_provision_in_eurCurrencyz.B. 327,65Berechnet
fixierte_liefermengeIntegerkWh (Fix-Anteil)Berechnet
liefermenge_flex_inIntegerkWh (Flex-Anteil)Berechnet
energiepreis_flex_in_ctkwhDecimalz.B. 9,91Berechnet

Neue Properties (müssen in HubSpot angelegt werden):

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:

  1. Szenarien vergleichen
  2. Parameter anpassen (Provision, Rabatt, Laufzeit)
  3. Ein Szenario als Angebot auswählen → wird in HubSpot geschrieben

7.2 Automatisch generierte Szenarien

Szenario Tarif Laufzeit Besonderheit
A — StandardSTARQ&pro24 MonateStandardparameter
B — LangfristigSTARQ&pro48 MonateMaximaler Laufzeitrabatt
C — FestpreisSTARQ&fix24 MonateEin Preis, kein Risiko für Kunden
D — IndividuellSTARQ&pro36 MonateAngepasste Provision/Overhead

7.3 Anpassbare Parameter pro Szenario

Der Vertriebler kann folgende Werte pro Szenario ändern:

Parameter Änderbar Bereich
Tarif (pro/fix)JaDropdown
VertragslaufzeitJa12/24/36/48 Monate
VertriebsprovisionJa0,00–0,50 ct/kWh
OverheadJa0,50–1,50 ct/kWh
Solar-PreisJa7,00–8,50 ct/kWh
GebietsrabattJa0,00–1,00 ct/kWh
RisikofaktorenEingeschränktNur 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:

  1. Alle Pricing-Properties werden auf den HubSpot Deal geschrieben (Abschnitt 6.3)
  2. Deal-Stage wird auf „Angebot erstellt“ gesetzt
  3. Link zur Szenario-Seite wird als matchpoint_scenario_url gespeichert
  4. 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/indexGET /matchpoint/scenarios/:idSzenario-Vergleichsseite (Hauptseite)
matchpoint/scenarios/_scenario_cardPartialEinzelnes Szenario als Turbo Frame
matchpoint/scenarios/_chartPartial24h Matching-Chart (Chartkick + Chart.js)
matchpoint/scenarios/_edit_formPartialParameter-Anpassung (Turbo Frame)
matchpoint/dashboard/indexGET /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
KundeGewerbekunde Bitburg
Jahresverbrauch121.352 kWh
Lastspitze87,5 kW
Benutzungsstunden1.387 h/a
Datenpunkte35.040 (15-Min)
Marktlokation50522423206
ProfilWerktag 07-16 Uhr, Wochenende nur Grundlast

Erwartete Ergebnisse (mit Default-Parametern)

Szenario Tarif Laufzeit Fix-Preis Flex-Preis Blended Jahreskosten
Standardpro24M~10,34 ct~9,91 ct~12.540 €
Langfristigpro48M~9,89 ct~9,91 ct~11.980 €
Festpreisfix24M~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.tsxMatchpoint::PricingCalculatorService + ViewsRLM Pricing (STARQ&pro + STARQ&fix)
SlpPriceCalculation.tsxMatchpoint::PricingCalculatorService (SLP-Modus)SLP Pricing
CalculationDetails.tsxMatchpoint::EnergyMatchingService + Chart-ViewEnergy Matching + 24h Chart
SlpCalculation.tsxViews (SLP Input-Form)SLP Nutzungszeit-Auswahl
PrivateCalculation.tsxSeparater B2C Pricing ControllerB2C Tarif-Berechnung
csvParser.tsMatchpoint::LoadProfileParserCSV Parsing
pdfExport.tsPrawn Gem oder PortantPDF-Export

Anhang B: Abhängigkeiten (npm → Ruby Gems)

npm Package Ruby Gem Zweck
rechartschartkick + chart.jsCharts
jspdf + jspdf-autotableprawn + prawn-tablePDF-Export
react-router-domRails RouterRouting
tailwindcsstailwindcss-railsCSS
zodActiveModel ValidationsValidierung
sonnerTurbo Flash MessagesNotifications
@radix-ui/*ViewComponent oder StimulusUI Komponenten