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
+63 -1
View File
@@ -6,7 +6,7 @@ Acceptance criteria covered
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
3. Manual strategy: Decimal precision — no float binary rounding errors.
4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0.
5. Tibber strategy: buy=total, sell=totalenergy_taxsell_adjust.
5. Tibber strategy: buy=total, sell=totalenergy_taxsell_feesell_adjust.
6. Tibber strategy: negative total → negative export_revenue (not clamped).
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
8. ``register_strategy`` / ``get_strategy`` round-trip works.
@@ -371,6 +371,68 @@ class TestTibberStrategy:
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
def test_sell_deducts_sell_fee(self, tibber_db) -> None:
"""verkoopvergoeding (sell_fee) is subtracted from the feed-in price.
Under net metering the energy tax is refunded (sell_adjust = energy_tax),
so sell should equal total sell_fee. Verifies the fee is a first-class,
always-deducted term and does NOT cancel against the buy-side inkoopvergoeding
that is already baked into total.
"""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3073)
session.commit()
# Net-metering config: sell_adjust = energy_tax refunds the tax;
# sell_fee = 0.0248 (Tibber verkoopvergoeding) is still deducted.
values = {
"energy": {
"energy_tax": 0.11085,
"sell_fee": 0.0248,
"sell_adjust": -0.11085,
},
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("1"),
)
result = self._call(deltas, t0, session, values=values)
# sell = 0.3073 0.11085 0.0248 (0.11085) = 0.3073 0.0248 = 0.2825
expected_sell = (
Decimal("0.3073") - Decimal("0.11085") - Decimal("0.0248") - Decimal("-0.11085")
)
assert expected_sell == Decimal("0.2825")
assert result["export_revenue"] == Decimal("1") * expected_sell
assert result["pricing"]["sell_fee"] == "0.0248"
assert Decimal(result["pricing"]["sell"]) == Decimal("0.2825")
def test_sell_fee_absent_defaults_to_zero(self, tibber_db) -> None:
"""A version without sell_fee (pre-migration) reads it as 0 — no silent deduction."""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
session.commit()
values = {
"energy": {"energy_tax": 0.10, "sell_adjust": 0.0}, # no sell_fee key
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("1"),
)
result = self._call(deltas, t0, session, values=values)
# sell = 0.25 0.10 0 0 = 0.15
assert result["export_revenue"] == Decimal("0.15")
assert result["pricing"]["sell_fee"] == "0"
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
"""Correct row: starts_at ≤ t0, most recent wins."""
t0 = _ts(10, 0)