fix(tibber): deduct verkoopvergoeding (sell_fee) from feed-in sell price
frontend / frontend (push) Failing after 5m49s
pytest / test (push) Successful in 20m30s
docker-image / build-and-push (push) Successful in 13m26s

Tibber's API `total` already includes the buy-side inkoopvergoeding
(verified from production data: total = spot×1.21 + energy_tax 0.11085 +
inkoopvergoeding 0.0248). Under net metering Tibber pays back
`total − verkoopvergoeding` per returned kWh (NL: EUR 0.28 -> 0.2552), so the
two EUR 0.0248 fees do NOT cancel — the feed-in price sits 0.0248 below buy.

Model the verkoopvergoeding as a first-class, always-subtracted contract
field `energy.sell_fee` (default 0.0248) instead of folding it into
`sell_adjust`. New sell formula:

    sell = total − energy_tax − sell_fee − sell_adjust

`sell_adjust` now carries only the net-metering energy-tax refund
(= −energy_tax). Applied in both the billing strategy and the /prices
endpoint; recorded in the pricing snapshot. Frontend renders the field
automatically (dynamic profile form). Docs (references, m6) corrected to
drop the wrong "fees cancel" premise.
This commit is contained in:
2026-07-20 13:50:13 +02:00
parent b65f700d56
commit d07a083e03
11 changed files with 196 additions and 29 deletions
+10 -4
View File
@@ -123,6 +123,7 @@ class ManualProfile(BaseModel):
class TibberEnergySpec(BaseModel):
source: str # must be "tibber_api"
energy_tax: FieldSpec # subtracted from total to derive sell price
sell_fee: FieldSpec # verkoopvergoeding (feed-in fee); always subtracted from sell; default 0.0248
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
@@ -268,10 +269,13 @@ def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dic
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
"""Return a copy of *values* with sell_adjust and management_fee defaults applied."""
"""Return a copy of *values* with sell_fee, sell_adjust and management_fee defaults applied."""
filled = dict(values)
energy = dict(filled.get("energy", {}))
# Apply default for sell_fee (default=0.0248) if absent.
if "sell_fee" not in energy and profile.energy.sell_fee.default is not None:
energy["sell_fee"] = profile.energy.sell_fee.default
# Apply default for sell_adjust (default=0) if absent.
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
energy["sell_adjust"] = profile.energy.sell_adjust.default
@@ -347,6 +351,7 @@ def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> d
# Required energy fields.
_require_numeric("energy", "energy_tax", energy)
_require_numeric("energy", "sell_fee", energy)
_require_numeric("energy", "sell_adjust", energy)
# Required standing fields.
_require_numeric("standing", "management_fee", standing)
@@ -361,9 +366,10 @@ def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
"""Validate a contract-values dict against the named profile structure.
Fields that carry a ``default`` in the profile (e.g. ``ode``,
``sell_adjust``, tibber ``management_fee``) are silently filled in when
absent from *values*. Fields with no default that are absent, or fields
whose value is not a number, cause a ``ProfileValidationError``.
``sell_fee``, ``sell_adjust``, tibber ``management_fee``) are silently
filled in when absent from *values*. Fields with no default that are
absent, or fields whose value is not a number, cause a
``ProfileValidationError``.
Parameters
----------