From d07a083e03eb533859de443ec9b11eb198372f54 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Jul 2026 13:50:13 +0200 Subject: [PATCH] fix(tibber): deduct verkoopvergoeding (sell_fee) from feed-in sell price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/api/routes/api/energy.py | 8 ++- app/integrations/pricing/profiles.py | 14 ++-- app/integrations/pricing/profiles/tibber.yaml | 5 +- app/integrations/pricing/strategies.py | 19 +++++- docs/design/m6-tibber-dynamic-energy.md | 9 +-- .../Tibber-and-NL-Energy-Pricing.md | 26 ++++++-- openapi/openapi.json | 2 +- openapi/openapi.yaml | 6 +- tests/test_api_energy.py | 54 +++++++++++++++- tests/test_pricing_profiles.py | 18 ++++++ tests/test_pricing_strategies.py | 64 ++++++++++++++++++- 11 files changed, 196 insertions(+), 29 deletions(-) diff --git a/app/api/routes/api/energy.py b/app/api/routes/api/energy.py index 4bf3532..9cdf79a 100644 --- a/app/api/routes/api/energy.py +++ b/app/api/routes/api/energy.py @@ -169,7 +169,7 @@ def get_prices( Response ``points`` carries per-slot: - ``buy = total`` (Tibber all-inclusive price) - - ``sell = total − energy_tax − sell_adjust`` (from active version values) + - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values) - ``level`` (Tibber price level, may be null) ``tariff`` is null. @@ -222,7 +222,8 @@ def get_prices( ) rows = list(reversed(db.execute(stmt).scalars().all())) - # Derive sell price per-point using version values (energy_tax + sell_adjust). + # Derive sell price per-point using version values + # (energy_tax + sell_fee + sell_adjust). from decimal import Decimal def _d(v: Any) -> Decimal: @@ -230,12 +231,13 @@ def get_prices( energy = version.values.get("energy", {}) if version.values else {} energy_tax = _d(energy.get("energy_tax", 0)) + sell_fee = _d(energy.get("sell_fee", 0)) sell_adjust = _d(energy.get("sell_adjust", 0)) points = [] for row in rows: total = _d(row.total) - sell = float(total - energy_tax - sell_adjust) + sell = float(total - energy_tax - sell_fee - sell_adjust) points.append( PricePointSchema( starts_at=_as_utc(row.starts_at), diff --git a/app/integrations/pricing/profiles.py b/app/integrations/pricing/profiles.py index 42d39d8..76b63f6 100644 --- a/app/integrations/pricing/profiles.py +++ b/app/integrations/pricing/profiles.py @@ -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 ---------- diff --git a/app/integrations/pricing/profiles/tibber.yaml b/app/integrations/pricing/profiles/tibber.yaml index 4ec76f9..3b0c0d1 100644 --- a/app/integrations/pricing/profiles/tibber.yaml +++ b/app/integrations/pricing/profiles/tibber.yaml @@ -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 } diff --git a/app/integrations/pricing/strategies.py b/app/integrations/pricing/strategies.py index f8b0f33..38ff46f 100644 --- a/app/integrations/pricing/strategies.py +++ b/app/integrations/pricing/strategies.py @@ -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), } diff --git a/docs/design/m6-tibber-dynamic-energy.md b/docs/design/m6-tibber-dynamic-energy.md index 9b8a20b..a4f2c03 100644 --- a/docs/design/m6-tibber-dynamic-energy.md +++ b/docs/design/m6-tibber-dynamic-energy.md @@ -118,8 +118,9 @@ credits: kind: tibber label: Tibber 动态电价(15 分钟) energy: - source: tibber_api # buy = total; sell = total − energy_tax − sell_adjust + source: tibber_api # buy = total; sell = total − energy_tax − sell_fee − sell_adjust energy_tax: { unit: EUR/kWh } + sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding, always subtracted sell_adjust: { unit: EUR/kWh, default: 0 } standing: management_fee: { unit: EUR/month, default: 5.99 } @@ -164,7 +165,7 @@ credits: 1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**:`Δd1,Δd2,Δr1,Δr2`。 2. 取 active 合同**在 t0 生效的版本** + 其 strategy: - `manual`:`import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)`(`buy_x = energy_buy_x + energy_tax + ode`);`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。 - - `tibber`:取覆盖 t0 的 `tibber_price`(`starts_at ≤ t0` 最近一条);`buy = total`、`sell = total − energy_tax − sell_adjust`;`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。 + - `tibber`:取覆盖 t0 的 `tibber_price`(`starts_at ≤ t0` 最近一条);`buy = total`、`sell = total − energy_tax − sell_fee − sell_adjust`(`sell_fee`=verkoopvergoeding,默认 0.0248,见下修正说明);`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。 3. `net_cost = import_cost − export_revenue`;**upsert** `energy_cost_period`,**快照**当时用的价 + `contract_version_id`。 - 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。 @@ -240,7 +241,7 @@ credits: 1. **两层电价模型**:profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。 2. **kind 不叫 "fixed"**:`manual`(人工填、可双费率、可带时段)/ `tibber`(API 动态);合同 `name` UI 自由填。 -3. **买价**:tibber = API `total`(全包,已证 total=energy+tax);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_adjust`;manual = `sell_档`(回送价,无能源税)。均含 VAT。 +3. **买价**:tibber = API `total`(全包,已证 total=energy+tax;含 inkoopvergoeding);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_fee − sell_adjust`(`sell_fee`=verkoopvergoeding 默认 0.0248);manual = `sell_档`(回送价,无能源税)。均含 VAT。 4. **双费率**:manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。 5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。 6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。 @@ -475,7 +476,7 @@ Phase D(API + 前端) ## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现) 1. **买价**:✅ tibber = API `total`(demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。 -2. **卖价残差(tibber)**:`sell = total − energy_tax − sell_adjust`,`sell_adjust` 默认 0(买卖费相等抵消)。真实账单确认后若有残差再调。 +2. **卖价残差(tibber)**:~~`sell = total − energy_tax − sell_adjust`,`sell_adjust` 默认 0(买卖费相等抵消)~~ → **已修正(2026-07,见 references §3.1)**:`total` 含 inkoopvergoeding,净计量回送 = `total − verkoopvergoeding`,两费**不抵消**。公式改为 `sell = total − energy_tax − sell_fee − sell_adjust`,新增 `sell_fee`(默认 0.0248,始终扣除);`sell_adjust` 净计量期设 `−energy_tax`。真实账单确认后若有残差再调 `sell_fee`。 3. **双费率寄存器映射**:`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。 4. **能源税年值**:manual/tibber 的 `energy_tax` 默认 ~0.1108(2026 第一档含 VAT),按当年实际值核。 5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。 diff --git a/docs/references/Tibber-and-NL-Energy-Pricing.md b/docs/references/Tibber-and-NL-Energy-Pricing.md index 4aacf6e..574ce5f 100644 --- a/docs/references/Tibber-and-NL-Energy-Pricing.md +++ b/docs/references/Tibber-and-NL-Energy-Pricing.md @@ -113,16 +113,26 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \ > "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt." > (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。) -→ **买卖服务费相等(均 €0.0248/kWh)**,在买卖里一进一出**相互抵消**。 +→ **买卖服务费金额相等(均 €0.0248/kWh),但两者对住户都是成本、不互相抵消**: +- 买侧 inkoopvergoeding 已经**包含在 Tibber API 的 `total` 里**(见下 §3.1 的实证拆解),买电按 `total` 计价即已含它。 +- 卖侧 verkoopvergoeding 则是从回送价里**额外扣掉**的一笔——所以回送价 = `total − 0.0248`,比买价低 0.0248/kWh。 +- ⚠️ **早期版本误判为"一进一出抵消 → 回送=total"**,这是错的:`total` 里那笔 inkoopvergoeding 不会退回来充抵 verkoopvergoeding。代码里用 `energy.sell_fee`(默认 0.0248)建模这笔卖侧费用。 --- ## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027 -### 3.1 回送价(净计量期内,文档原文) +### 3.1 回送价(净计量期内,文档原文 + 实证) > "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding." -即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw − verkoopvergoeding`。因 inkoopvergoeding = verkoopvergoeding 抵消 → **= 全额零售价**(spot+能源税+VAT),正是 saldering "回送 1 度 = 用 1 度"的本质。 +> **Worked example(Tibber NL 原文)**:"Stel dat tussen 14:00 en 14:15 de totale stroomprijs €0,28 per kWh incl. is, dan krijg je €0,28 − €0,0248 verkoopvergoeding = **€0,2552** per teruggeleverde kWh terug." + +即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw − verkoopvergoeding`,而官方例子直接写成 **`回送价 = totale stroomprijs − verkoopvergoeding = total − 0.0248`**。能源税**退回**(留在 total 里没动),只有 verkoopvergoeding 这 0.0248 被扣。 + +**✅ 实证(本项目生产库,2026-07-20 三个刻钟)**:按 21% VAT 拆 `total`:`total = 现货×1.21 + energiebelasting(0.11085) + inkoopvergoeding(0.0248)`,三段解出的 inkoop 都精确等于 **0.0248**。→ **我们存的 `tibber_price.total` 就是官方 "totale stroomprijs"(含 inkoopvergoeding 的买价)**,因此: +- 买价 `buy = total`(已含 inkoopvergoeding,正确)。 +- 净计量回送价 `sell = total − verkoopvergoeding = total − 0.0248`。 +- ⚠️ 所以 saldering 下"回送 1 度"仍比"用 1 度"少 0.0248——**不是完全 1:1**。代码用 `sell_fee` 建模这笔扣减,`sell_adjust` 只负责在净计量期把能源税补回(`sell_adjust = −energy_tax`)。 ### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2) > "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …" @@ -146,9 +156,11 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \ > spot 取 API `energy`;`total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。 -- **Tibber 动态合同**(post-2027 口径): - - 买价 `buy = price.total` - - 卖价 `sell = price.total − energy_tax_per_kwh − sell_adjust`(`sell_adjust` 默认 0;含 VAT 归己;买卖费抵消已隐含在 total 里) +- **Tibber 动态合同**: + - 买价 `buy = price.total`(含 energy_tax + VAT + inkoopvergoeding) + - 卖价 `sell = price.total − energy_tax − sell_fee − sell_adjust` + - `sell_fee`:verkoopvergoeding(卖侧上网费,默认 **0.0248**,含 VAT),**始终扣除**——即使净计量期也扣(见 §3.1)。 + - `sell_adjust`:手动修正项(默认 0)。**净计量期**设为 `−energy_tax`(把能源税补回),得 `sell = total − sell_fee`;**2027 取消净计量后**设为 0,得 `sell = total − energy_tax − sell_fee`(无能源税、纯市场价再扣上网费)。 - **固定合同(manual,双费率)**: - 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal}) - 卖价 `sell_档 = sell_档`(回送价,**无能源税**) @@ -242,7 +254,7 @@ extra_device_timestamp, extra_device_delivered # 燃气表(m³,每 ## 8. 待真实数据核对(合同生效后用真实 token / 账单) 1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。 -2. **卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉、回送 VAT 口径 → 调 `sell_adjust`(默认 0)。 +2. ~~**卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉~~ → **已核实(2026-07)**:`total` 含 inkoopvergoeding(0.0248),净计量回送价 = `total − verkoopvergoeding(0.0248)`,两费**不抵消**;代码以 `sell_fee`(默认 0.0248)建模。仍待真实账单核对 `sell_fee` / VAT 口径的最终残差。 3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。 4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`。 5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。 diff --git a/openapi/openapi.json b/openapi/openapi.json index 745e52f..5b4468d 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -701,7 +701,7 @@ "api-energy" ], "summary": "Get Prices", - "description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).", + "description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).", "operationId": "get_prices_api_energy_prices_get", "parameters": [ { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 04b6846..85d50a3 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -527,9 +527,9 @@ paths: \ (most recent first\n within the window, then reversed to ascending order\ \ — identical to the\n modbus readings pattern).\n\n Response ``points``\ \ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\ - \ price)\n - ``sell = total − energy_tax − sell_adjust`` (from active\ - \ version values)\n - ``level`` (Tibber price level,\ - \ may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\ + \ price)\n - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from\ + \ active version values)\n - ``level`` (Tibber price\ + \ level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\ manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\ \ prices\n derived using the billing engine formula:\n - ``buy_dal \ \ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\ diff --git a/tests/test_api_energy.py b/tests/test_api_energy.py index fc593f9..2f63ac1 100644 --- a/tests/test_api_energy.py +++ b/tests/test_api_energy.py @@ -363,13 +363,65 @@ def test_prices_tibber_contract_returns_points(energy_client): starts_at_list = [p["starts_at"] for p in body["points"]] assert starts_at_list == sorted(starts_at_list) - # Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_adjust=0.245-0.1108-0.0 + # Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_fee-sell_adjust + # (this version has no sell_fee/sell_adjust → both default to 0 at read time). for p in body["points"]: assert abs(p["buy"] - 0.245) < 1e-6 assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4 assert p["level"] == "NORMAL" +def test_prices_tibber_sell_reflects_sell_fee(energy_client): + """/prices sell price deducts sell_fee (verkoopvergoeding), net-metering config.""" + client, engine, _app = energy_client + _login(client) + + # Net-metering version: sell_adjust = −energy_tax (refund tax), sell_fee = 0.0248. + now = datetime.now(UTC) + with Session(engine) as session: + contract = EnergyContract( + name="Tibber NetMeter", + kind="tibber", + active=True, + currency="EUR", + created_at=now, + updated_at=now, + ) + session.add(contract) + session.flush() + session.add( + EnergyContractVersion( + contract_id=contract.id, + effective_from=now - timedelta(days=30), + effective_to=None, + values={ + "energy": { + "energy_tax": 0.1108, + "sell_fee": 0.0248, + "sell_adjust": -0.1108, + }, + "standing": {"management_fee": 5.99, "network_fee": 25.0}, + "credits": {"heffingskorting": 600.0}, + }, + created_at=now, + ) + ) + session.commit() + _make_tibber_prices(engine, count=3) + + start = (datetime.now(UTC) - timedelta(hours=2)).isoformat() + end = (datetime.now(UTC) + timedelta(hours=2)).isoformat() + resp = client.get("/api/energy/prices", params={"start": start, "end": end}) + assert resp.status_code == 200 + body = resp.json() + assert body["kind"] == "tibber" + assert len(body["points"]) == 3 + # sell = 0.245 − 0.1108 − 0.0248 − (−0.1108) = 0.245 − 0.0248 = 0.2202 + for p in body["points"]: + assert abs(p["buy"] - 0.245) < 1e-6 + assert abs(p["sell"] - 0.2202) < 1e-4 + + def test_prices_tibber_limit_caps_results(energy_client): client, engine, _app = energy_client _login(client) diff --git a/tests/test_pricing_profiles.py b/tests/test_pricing_profiles.py index a5e7a2b..02e9dce 100644 --- a/tests/test_pricing_profiles.py +++ b/tests/test_pricing_profiles.py @@ -137,6 +137,11 @@ class TestLoadProfileTibber: profile = load_profile("tibber") assert profile.energy.sell_adjust.default == 0 + def test_sell_fee_has_default_verkoopvergoeding(self) -> None: + profile = load_profile("tibber") + assert profile.energy.sell_fee.unit == "EUR/kWh" + assert profile.energy.sell_fee.default == 0.0248 + def test_management_fee_has_default(self) -> None: profile = load_profile("tibber") assert profile.standing.management_fee.default is not None @@ -350,6 +355,19 @@ class TestValidateValuesTibber: filled = validate_values("tibber", values) assert filled["energy"]["sell_adjust"] == 0 + def test_sell_fee_default_applied_when_absent(self) -> None: + values = { + "energy": { + "energy_tax": 0.1108, + "sell_adjust": 0.0, + # sell_fee absent — has default 0.0248 (verkoopvergoeding) + }, + "standing": {"management_fee": 5.99, "network_fee": 9.87}, + "credits": {"heffingskorting": 600.0}, + } + filled = validate_values("tibber", values) + assert filled["energy"]["sell_fee"] == 0.0248 + def test_management_fee_default_applied_when_absent(self) -> None: values = { "energy": {"energy_tax": 0.1108, "sell_adjust": 0.0}, diff --git a/tests/test_pricing_strategies.py b/tests/test_pricing_strategies.py index be994f2..fca2a39 100644 --- a/tests/test_pricing_strategies.py +++ b/tests/test_pricing_strategies.py @@ -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=total−energy_tax−sell_adjust. +5. Tibber strategy: buy=total, sell=total−energy_tax−sell_fee−sell_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)