From 2157f9ca9234e416a64a25ebd4b03c09ab063df6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 1 Nov 2026 15:16:22 +0100 Subject: [PATCH] feat(protocol): the price answer names the VAT rate it taxed at Prices gains an optional vatPermille: the VAT rate the box taxed totalMinor at, in permille (250 is 25 %). Absent means an older box and an unknown rate; 0 means no VAT. The protocol doc says so, and the simulated box sends the rate its own totals use. No UI change. Co-Authored-By: Claude Opus 5.5 --- .changeset/price-vat-rate.md | 5 +++++ docs/protocol.md | 9 +++++++++ src/lib/protocol/messages.ts | 9 +++++++++ src/lib/sim/box.ts | 4 +++- src/lib/sim/planner.ts | 2 +- tests/price-e2e.test.ts | 19 +++++++++++++++++++ 6 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/price-vat-rate.md diff --git a/.changeset/price-vat-rate.md b/.changeset/price-vat-rate.md new file mode 100644 index 0000000..6fd2688 --- /dev/null +++ b/.changeset/price-vat-rate.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +The price answer names the VAT rate the box taxed the totals at, as `vatPermille` (250 is 25 %). The protocol doc, the `Prices` type and the simulated box carry it; absent means an older box and an unknown rate. The app does not draw it yet. diff --git a/docs/protocol.md b/docs/protocol.md index 746a4b6..113cb04 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -202,6 +202,15 @@ the box computes it because the box holds that configuration. An app that multiplied spot by its own guess would put a different number under the same hour than the box's own dashboard does. +`vatPermille` is the VAT rate the box taxed `totalMinor` at, in permille: 250 +is 25 %. Like every ratio on the wire it is an integer, rounded once on the +box. It is the rate after the box's default, so 0 means no VAT. An older box +leaves it out, and absent means unknown, never zero. It is what lets an app +show spot with VAT, `spotMinor × (1 + vatPermille / 1000)`, at the box's own +rate. The box taxes a slot when it stores it and rewrites today and tomorrow +every hour, so for an hour after the rate changes, and for older days, a +total can carry the old rate. + Times are wall clock, unlike every age in this protocol: prices are about hours a person plans around rather than about the box. diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts index 93bc30f..7f14c81 100644 --- a/src/lib/protocol/messages.ts +++ b/src/lib/protocol/messages.ts @@ -336,6 +336,15 @@ export interface Prices { * apart. */ stale: boolean + /** + * The VAT rate the box taxed `totalMinor` at, in permille: 250 is 25 %. + * + * Integer permille like every other ratio on the wire, and the rate after + * the box's default, so 0 means no VAT. A box older than the field leaves it + * out, and absent is unknown, never zero. With it a client can show spot + * with VAT at the box's own rate instead of a guess. + */ + vatPermille?: number } // -------------------------------------------------------------------------- diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts index be0ac42..647ed0a 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -60,7 +60,7 @@ import { import { SAFETY_K_DEFAULT, clampSafetyK, trustFromSafetyK, type BatteryExport } from '$lib/format/plan-prefs' import { SimApi, evPluggedIn } from './api' import { roleHasScope, ROLE_SCOPES, CAP_CLOUD_TUN } from '$lib/protocol/contract' -import { buildPlan, priceAt, importTotalMinor } from './planner' +import { buildPlan, priceAt, importTotalMinor, SIM_VAT_PCT } from './planner' import { RESOLUTIONS, DEFAULT_MAX_POINTS, @@ -1141,6 +1141,8 @@ export class SimBox { // walks one unbroken hour at a time. Read the contract, not this line, // for what `stale` means. stale: last === undefined || last.startMs + last.durationMs < q.toMs, + // The rate importTotalMinor taxed the totals at, as the box sends it. + vatPermille: Math.round(SIM_VAT_PCT * 10), } this.#send( diff --git a/src/lib/sim/planner.ts b/src/lib/sim/planner.ts index f6c21b8..bfc1a32 100644 --- a/src/lib/sim/planner.ts +++ b/src/lib/sim/planner.ts @@ -40,7 +40,7 @@ export function priceAt(hourOfDay: number): number { * the wire carries a total per slot at all. */ const SIM_GRID_TARIFF_MINOR = 70 -const SIM_VAT_PCT = 25 +export const SIM_VAT_PCT = 25 /** * What a kilowatt-hour costs to import: spot plus tariff, taxed. diff --git a/tests/price-e2e.test.ts b/tests/price-e2e.test.ts index 19e8f14..a27710f 100644 --- a/tests/price-e2e.test.ts +++ b/tests/price-e2e.test.ts @@ -94,6 +94,25 @@ describe('prices on the wire', () => { } }) + it('names the VAT rate the totals were taxed at', async () => { + // What lets a client show spot with VAT at the box's rate rather than a + // guess. Integer permille, like every other ratio on the wire. + const box = new SimBox({ now: () => MORNING }) + const session = connect(box) + await settle() + + const prices = await session.prices({ fromMs: today(MORNING), toMs: MORNING + 48 * HOUR_MS }) + const vat = prices.vatPermille! + expect(Number.isInteger(vat)).toBe(true) + expect(vat).toBe(250) + + // Taken back out of each total at that rate, what is left above spot is + // the same tariff in every hour. A wrong rate leaves a tariff that follows + // the market. + const tariffs = prices.slots.map((s) => s.totalMinor / (1 + vat / 1000) - s.spotMinor) + expect(Math.max(...tariffs) - Math.min(...tariffs)).toBeLessThan(1) + }) + it('is self-consistent: slots are contiguous and aligned', async () => { const box = new SimBox({ now: () => MORNING }) const session = connect(box)