fix(tibber): deduct verkoopvergoeding (sell_fee) from feed-in sell price
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:
@@ -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
|
||||
----------
|
||||
|
||||
@@ -2,9 +2,10 @@ kind: tibber
|
||||
label: Tibber 动态电价(15 分钟)
|
||||
|
||||
energy:
|
||||
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_adjust
|
||||
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_fee − sell_adjust
|
||||
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
|
||||
sell_adjust: { unit: EUR/kWh, default: 0 } # additional sell-price adjustment (residual spread)
|
||||
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding (feed-in fee, incl. VAT); always subtracted from sell
|
||||
sell_adjust: { unit: EUR/kWh, default: 0 } # manual sell-price adjustment; net-metering: set = −energy_tax to refund the tax
|
||||
|
||||
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||
management_fee: { unit: EUR/month, default: 5.99 }
|
||||
|
||||
@@ -209,12 +209,23 @@ def _tibber_strategy(
|
||||
query on ``starts_at``.
|
||||
|
||||
Formula (§3.4):
|
||||
- ``buy = total`` (Tibber's all-inclusive price, already includes tax)
|
||||
- ``sell = total − energy_tax − sell_adjust``
|
||||
- ``buy = total`` (Tibber's all-inclusive price; already includes energy
|
||||
tax, VAT and the buy-side ``inkoopvergoeding``)
|
||||
- ``sell = total − energy_tax − sell_fee − sell_adjust``
|
||||
- ``import_cost = (Δd1 + Δd2) × buy``
|
||||
- ``export_revenue = (Δr1 + Δr2) × sell``
|
||||
- ``net_cost = import_cost − export_revenue``
|
||||
|
||||
``sell_fee`` models Tibber's per-kWh **verkoopvergoeding** (feed-in fee,
|
||||
€0.0248/kWh incl. VAT since 2026-01-01). It is always deducted from the
|
||||
feed-in payout: even under the net-metering (saldering) scheme, Tibber pays
|
||||
``total − verkoopvergoeding`` per returned kWh (Tibber NL: "€0,28 − €0,0248
|
||||
= €0,2552"). ``total`` already contains the equal buy-side
|
||||
``inkoopvergoeding``, so the two fees do **not** cancel — the feed-in price
|
||||
sits ``sell_fee`` below the buy price. ``sell_adjust`` is a separate manual
|
||||
correction: under net metering it carries back the refunded energy tax
|
||||
(``sell_adjust = −energy_tax``), leaving ``sell = total − sell_fee``.
|
||||
|
||||
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
||||
API price applies to the full delivered/returned volume.
|
||||
|
||||
@@ -248,11 +259,12 @@ def _tibber_strategy(
|
||||
|
||||
energy = values.get("energy", {})
|
||||
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||
sell_fee = _to_decimal(energy.get("sell_fee", 0))
|
||||
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
||||
|
||||
total = _to_decimal(price_row.total)
|
||||
buy = total
|
||||
sell = total - energy_tax - sell_adjust
|
||||
sell = total - energy_tax - sell_fee - sell_adjust
|
||||
|
||||
total_delivered = deltas.d1 + deltas.d2
|
||||
total_returned = deltas.r1 + deltas.r2
|
||||
@@ -269,6 +281,7 @@ def _tibber_strategy(
|
||||
"buy": str(buy),
|
||||
"sell": str(sell),
|
||||
"energy_tax": str(energy_tax),
|
||||
"sell_fee": str(sell_fee),
|
||||
"sell_adjust": str(sell_adjust),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user