@@ -71,6 +71,9 @@ async def handle_oauth_401(self):
7171# The tariff is queried via the `... on TariffType` interface fragment (all concrete
7272# tariff types implement it) rather than an enumerated list of concrete fragments —
7373# so an export/SEG tariff whose type wasn't in the old list is no longer dropped.
74+ #
75+ # standingCharge / preVatStandingCharge are on the same interface (EDF and E.ON alike). Their
76+ # ratio is the VAT multiplier used to bring exc-VAT applicableRates prices up to inc-VAT.
7477KRAKEN_ACCOUNT_QUERY = """{{
7578 account(accountNumber: "{account_number}") {{
7679 number
@@ -83,7 +86,7 @@ async def handle_oauth_401(self):
8386 validFrom
8487 validTo
8588 tariff {{
86- ... on TariffType {{ tariffCode displayName productCode }}
89+ ... on TariffType {{ tariffCode displayName productCode standingCharge preVatStandingCharge }}
8790 }}
8891 }}
8992 }}
@@ -99,7 +102,9 @@ async def handle_oauth_401(self):
99102# product such as an EDF SEG export tariff, or a TOU tariff absent from the REST API such as
100103# E-TOU-* on E.ON Next) or 400 (the product is in the REST API but has no /standard-unit-rates/
101104# resource, as day/night-structured EDF tariffs answer — GH#5166).
102- # Returns value (pence/kWh inc VAT), validFrom, validTo for the requested window.
105+ # Returns value (pence/kWh EXC VAT), validFrom, validTo for the requested window. Unlike the
106+ # REST products API there is no inc-VAT field, so the caller scales by the VAT multiplier taken
107+ # from the account agreement (see KrakenAPI._vat_multiplier).
103108# applicableRates is a Relay-style connection (ApplicableRateConnectionTypeConnection),
104109# so the rate fields live under edges { node { ... } }, not directly on the field. The
105110# connection is mandatory-paginated (KT-CT-1201) — a `first` value is required, and `after`
@@ -225,6 +230,10 @@ async def handle_oauth_401(self):
225230KRAKEN_RATES_PAGE_SIZE = 100
226231KRAKEN_RATES_MAX_PAGES = 20
227232
233+ # Largest inc-VAT / exc-VAT ratio accepted from an agreement's standing charges. VAT only ever
234+ # adds, and never more than a quarter, so anything outside 1.0 - this is bad data, not a VAT rate.
235+ KRAKEN_VAT_MULTIPLIER_MAX = 1.25
236+
228237# How stale cached data may get before run() re-queries the API (minutes). Tariff/account data
229238# changes rarely so it is cached for hours; rates are published roughly daily so a 30-minute
230239# refresh is ample. Cached data is restored on restart, so these also bound post-restart re-fetch.
@@ -268,6 +277,7 @@ def initialize(
268277 self .account_id = account_id
269278 self .configured_mpan = mpan # From SaaS config — preferred MPAN to match
270279 self .import_mpan = None # Set after first successful tariff discovery
280+ self .import_vat_multiplier = 1.0 # inc-VAT / exc-VAT ratio from the import agreement, for applicableRates
271281 self .current_tariff = None
272282 self .export_tariff = None # Export tariff (discovered dynamically)
273283 self .wired = False
@@ -387,6 +397,30 @@ async def async_graphql_query(self, query, request_context, _retry_count=0):
387397 self .failures_total += 1
388398 return None
389399
400+ @staticmethod
401+ def _vat_multiplier (tariff ):
402+ """Return the inc-VAT / exc-VAT ratio for a GraphQL tariff, or 1.0 if it cannot be derived.
403+
404+ applicableRates publishes exc-VAT prices only, while everything downstream expects the
405+ inc-VAT basis the REST products API uses. The VAT rate is not hardcoded - GB domestic
406+ electricity is zero-rated from October 2026 to March 2027 and 5% either side - but taken
407+ from the tariff's own standingCharge (inc VAT) / preVatStandingCharge pair, which follows
408+ whatever rate applies now. A missing, zero or implausible pair leaves prices unscaled.
409+ """
410+ if not isinstance (tariff , dict ):
411+ return 1.0
412+ try :
413+ inc_vat = float (tariff .get ("standingCharge" ))
414+ exc_vat = float (tariff .get ("preVatStandingCharge" ))
415+ except (TypeError , ValueError ):
416+ return 1.0
417+ if exc_vat <= 0 :
418+ return 1.0
419+ ratio = inc_vat / exc_vat
420+ if 1.0 <= ratio <= KRAKEN_VAT_MULTIPLIER_MAX :
421+ return ratio
422+ return 1.0
423+
390424 def _find_active_tariff (self , meter_points , preferred_mpan = None , is_export = False ):
391425 """Find the current active tariff from a list of meter points.
392426
@@ -396,7 +430,7 @@ def _find_active_tariff(self, meter_points, preferred_mpan=None, is_export=False
396430 is_export: If True, only match EXPORT tariff codes; if False, skip them
397431
398432 Returns:
399- dict with tariff_code, product_code, mpan or None
433+ dict with tariff_code, product_code, mpan, vat_multiplier or None
400434 """
401435 now = datetime .now (timezone .utc )
402436
@@ -451,6 +485,7 @@ def _find_active_tariff(self, meter_points, preferred_mpan=None, is_export=False
451485 "tariff_code" : tariff_code ,
452486 "product_code" : product_code ,
453487 "mpan" : mpan ,
488+ "vat_multiplier" : self ._vat_multiplier (tariff ),
454489 }
455490
456491 return None
@@ -614,6 +649,13 @@ async def async_find_tariffs(self):
614649 # Store MPAN for GraphQL fallback in async_fetch_rates_graphql()
615650 self .import_mpan = import_result ["mpan" ]
616651
652+ # Refreshed on every discovery, not only a tariff change: the VAT rate can move while the
653+ # tariff stays the same. Kept off the tariff dict so it never reads as a tariff change.
654+ vat_multiplier = import_result ["vat_multiplier" ]
655+ if vat_multiplier != self .import_vat_multiplier :
656+ self .log (f"Kraken: Import VAT multiplier { self .import_vat_multiplier :.4f} -> { vat_multiplier :.4f} (from agreement standing charge)" )
657+ self .import_vat_multiplier = vat_multiplier
658+
617659 # Discover export tariff — always re-discover to detect tariff changes
618660 await self ._discover_export_tariff (all_meter_points , my_address )
619661
@@ -694,7 +736,7 @@ def _normalize_rate_timestamps(results):
694736
695737 return results
696738
697- async def async_fetch_rates_graphql (self , mpan , account_id = None ):
739+ async def async_fetch_rates_graphql (self , mpan , account_id = None , vat_multiplier = 1.0 ):
698740 """Fetch rates via GraphQL applicableRates — fallback when REST returns non-200.
699741
700742 Used when the product code has been removed from the REST API (e.g. product replaced
@@ -706,6 +748,8 @@ async def async_fetch_rates_graphql(self, mpan, account_id=None):
706748 mpan: The MPAN (meter point access number) to query rates for.
707749 account_id: Account number that owns the MPAN. Defaults to the configured
708750 import account; pass the export account for E.ON split import/export accounts.
751+ vat_multiplier: inc-VAT / exc-VAT ratio to scale the exc-VAT prices by. Pass the import
752+ agreement's ratio for import; leave at 1.0 for export, which carries no VAT.
709753
710754 Returns list of rate dicts with value_inc_vat, value_exc_vat, valid_from, valid_to, or None.
711755 """
@@ -738,7 +782,7 @@ async def async_fetch_rates_graphql(self, mpan, account_id=None):
738782 data = await self .async_graphql_query (query , "applicable-rates-graphql" )
739783 if not data :
740784 # Network/auth failure mid-pagination: return what we have if any, else None.
741- return self ._finalize_graphql_rates (raw_rates , mpan ) if raw_rates else None
785+ return self ._finalize_graphql_rates (raw_rates , mpan , vat_multiplier ) if raw_rates else None
742786
743787 connection = data .get ("applicableRates" ) or {}
744788 raw_rates .extend (self ._connection_nodes (connection ))
@@ -757,17 +801,20 @@ async def async_fetch_rates_graphql(self, mpan, account_id=None):
757801 self .log ("Warn: Kraken: applicableRates GraphQL returned no rate periods" )
758802 return None
759803
760- return self ._finalize_graphql_rates (raw_rates , mpan )
804+ return self ._finalize_graphql_rates (raw_rates , mpan , vat_multiplier )
761805
762- def _finalize_graphql_rates (self , raw_rates , mpan ):
763- """Convert applicableRates connection nodes into rate dicts, or None if none are valid."""
806+ def _finalize_graphql_rates (self , raw_rates , mpan , vat_multiplier = 1.0 ):
807+ """Convert applicableRates connection nodes into rate dicts, or None if none are valid.
808+
809+ The node value is exc-VAT; vat_multiplier scales it to the inc-VAT basis used downstream.
810+ """
764811 results = []
765812 for r in raw_rates :
766813 value = r .get ("value" )
767814 if value is None :
768815 continue
769- value_inc_vat = float (value )
770- value_exc_vat = round (value_inc_vat / 1.05 , 4 )
816+ value_exc_vat = float (value )
817+ value_inc_vat = round (value_exc_vat * vat_multiplier , 4 )
771818 results .append (
772819 {
773820 "value_inc_vat" : value_inc_vat ,
@@ -862,9 +909,11 @@ async def async_fetch_rates(self, tariff=None):
862909 if is_export :
863910 fallback_mpan = self .export_mpan
864911 fallback_account = self .export_account_id or self .account_id
912+ fallback_vat_multiplier = 1.0 # export payments carry no VAT
865913 else :
866914 fallback_mpan = self .import_mpan
867915 fallback_account = self .account_id
916+ fallback_vat_multiplier = self .import_vat_multiplier
868917 url = self .build_rates_url (tariff ["product_code" ], tariff ["tariff_code" ])
869918
870919 # 1) Public (unauthenticated) attempt.
@@ -898,7 +947,7 @@ async def async_fetch_rates(self, tariff=None):
898947 reason = "product not in the REST API" if err in KRAKEN_REST_PRODUCT_NOT_FOUND_STATUSES else "tariff has no standard-unit-rates endpoint"
899948 self .log (f"Kraken: REST rates HTTP { err } for { tariff ['tariff_code' ]} ({ reason } ), using GraphQL applicableRates for { kind } MPAN { fallback_mpan } " )
900949 failures_before = self .failures_total
901- rates = await self .async_fetch_rates_graphql (fallback_mpan , account_id = fallback_account )
950+ rates = await self .async_fetch_rates_graphql (fallback_mpan , account_id = fallback_account , vat_multiplier = fallback_vat_multiplier )
902951 if rates is None and self .failures_total == failures_before :
903952 # async_graphql_query counts its own failures (auth, non-200, GraphQL errors,
904953 # network), so only add a count when the fallback came back empty without
@@ -1051,6 +1100,8 @@ async def load_kraken_cache(self):
10511100 self .current_tariff = data .get ("current_tariff" )
10521101 self .export_tariff = data .get ("export_tariff" )
10531102 self .import_mpan = data .get ("import_mpan" )
1103+ # Absent from a cache written before the multiplier existed; the next discovery sets it.
1104+ self .import_vat_multiplier = data .get ("import_vat_multiplier" ) or 1.0
10541105 # Prefer cached discovered values but never clobber a configured value with None.
10551106 self .export_mpan = data .get ("export_mpan" ) or self .export_mpan
10561107 self .export_account_id = data .get ("export_account_id" ) or self .export_account_id
@@ -1082,6 +1133,7 @@ async def save_kraken_cache(self):
10821133 "current_tariff" : self .current_tariff ,
10831134 "export_tariff" : self .export_tariff ,
10841135 "import_mpan" : self .import_mpan ,
1136+ "import_vat_multiplier" : self .import_vat_multiplier ,
10851137 "export_mpan" : self .export_mpan ,
10861138 "export_account_id" : self .export_account_id ,
10871139 "import_rates" : self .import_rates ,
0 commit comments