Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/price-vat-rate.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions src/lib/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

// --------------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion src/lib/sim/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/lib/sim/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions tests/price-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading