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": patch
---

The box's price answer to the app, and FTW Cloud's price tile, now carry the VAT rate the box taxes the total at, as `vatPermille` (250 is 25 %). The rate is the one in force after the default, so a box that never set `vat_percent` says 250. With it the iPhone app can show the spot price with VAT at the box's own rate instead of a guess.
7 changes: 4 additions & 3 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,13 +564,14 @@ unknown `t`.
| `slotMs` | uint | Slot length. Where stored slots differ in length, the box repeats a longer slot's price across `slotMs` slots |
| `spotMinor` | [int or null] | Market price, minor units per kWh |
| `totalMinor` | [int or null] | What the household pays, tariff and VAT included |
| `vatPermille` | int | The VAT rate in `totalMinor`, in permille: 250 is 25 %. 0 means no VAT; an older box leaves it out, and the phone reads that as unknown |

`totalMinor` shows the household's tariff, and the privacy notice says so. The
phone sees how far the slots reach. The window runs from the start of today to
the end of tomorrow. Because it starts at midnight and not now, its hash moves
only when new prices land. The box looks at prices once a minute and sends
`prices` only when a hash of `t0Ms`, `slotMs`, `spotMinor` and `totalMinor`
changes. A gap between stored slots is `null`.
only when new prices land or the VAT rate changes. The box looks at prices once a minute and sends
`prices` only when a hash of `t0Ms`, `slotMs`, `spotMinor`, `totalMinor` and
`vatPermille` changes. A gap between stored slots is `null`.

### `plan`: `ftw.plan.read`

Expand Down
5 changes: 5 additions & 0 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,11 @@ const maxPriceSlotMin = 120
func (a *appPrices) Zone() string { return a.svc.Zone }
func (a *appPrices) Currency() string { return a.svc.Currency }

// VATPercent is the rate the service's Applier holds, the one the stored
// totals were taxed at, and not the config's own field: the service has
// already turned an unset rate into its default.
func (a *appPrices) VATPercent() float64 { return a.svc.VATPercent() }

func (a *appPrices) Slots(_ context.Context, fromMs, toMs int64) ([]appproto.PricePoint, error) {
// The store selects on slot start, inclusive at both ends. Widening the
// lower bound by one slot catches the slot in progress at fromMs — the
Expand Down
16 changes: 16 additions & 0 deletions go/cmd/ftw/app_prices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"
"time"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/prices"
"github.com/srcfl/ftw/go/internal/state"
)
Expand Down Expand Up @@ -148,3 +149,18 @@ func TestTheLookbackCoversTheLongestSlot(t *testing.T) {
t.Fatalf("the slot in progress was missed: %+v", got)
}
}

// The app and FTW Cloud get the rate the service taxes at, after its default.
// A box that never set vat_percent taxes at 25 % and must say so, not send
// the config's 0 as "no VAT".
func TestAppPricesSendsTheServicesVATRate(t *testing.T) {
svc := prices.FromConfig(&config.Price{Provider: "sourceful", Zone: "SE3"}, nil, nil)
a := &appPrices{svc: svc}
if got := a.VATPercent(); got != 25 {
t.Fatalf("unset VAT went out as %v, want the default 25", got)
}
svc.SetApplier(prices.Applier{VATPercent: 25.5})
if got := a.VATPercent(); got != 25.5 {
t.Fatalf("after a reload VAT went out as %v, want 25.5", got)
}
}
10 changes: 6 additions & 4 deletions go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,18 +343,20 @@ func (c *cloudSite) Prices() (cloudwire.Prices, bool) {
if err != nil || len(slots) == 0 {
return cloudwire.Prices{}, false
}
return priceColumns(c.prices.Zone(), c.prices.Currency(), slots), true
return priceColumns(c.prices.Zone(), c.prices.Currency(), c.prices.VATPercent(), slots), true
}

// priceColumns lays slots out at the shortest slot length. A longer slot's
// price repeats across the columns it covers; a gap is null.
func priceColumns(zone, currency string, slots []appproto.PricePoint) cloudwire.Prices {
// price repeats across the columns it covers; a gap is null. The VAT rate
// crosses in permille, rounded once, as in the app protocol's price answer.
func priceColumns(zone, currency string, vatPercent float64, slots []appproto.PricePoint) cloudwire.Prices {
sort.Slice(slots, func(a, b int) bool { return slots[a].StartMs < slots[b].StartMs })
slotMs := int64(math.MaxInt64)
for _, s := range slots {
slotMs = min(slotMs, int64(s.LenMin)*60_000)
}
p := cloudwire.Prices{Zone: zone, Currency: currency, T0Ms: slots[0].StartMs, SlotMs: slotMs}
p := cloudwire.Prices{Zone: zone, Currency: currency, T0Ms: slots[0].StartMs, SlotMs: slotMs,
VATPermille: int64(math.Round(vatPercent * 10))}
for _, s := range slots {
for at := s.StartMs; at < s.StartMs+int64(s.LenMin)*60_000; at += slotMs {
i := int((at - p.T0Ms) / slotMs)
Expand Down
5 changes: 4 additions & 1 deletion go/cmd/ftw/cloud_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,17 @@ import (
func TestPriceColumnsRepeatLongSlotsAndLeaveGapsNull(t *testing.T) {
t0 := int64(1_791_763_200_000)
q := int64(15 * 60_000)
p := priceColumns("SE3", "SEK", []appproto.PricePoint{
p := priceColumns("SE3", "SEK", 25.5, []appproto.PricePoint{
{StartMs: t0 + 4*q, LenMin: 60, SpotMinor: 40.4, TotalMinor: 110.6},
{StartMs: t0, LenMin: 15, SpotMinor: 41.5, TotalMinor: 112},
{StartMs: t0 + q, LenMin: 15, SpotMinor: -2.4, TotalMinor: 60},
})
if p.T0Ms != t0 || p.SlotMs != q || len(p.SpotMinor) != 8 || len(p.TotalMinor) != 8 {
t.Fatalf("columns %+v", p)
}
if p.VATPermille != 255 {
t.Fatalf("VAT 25.5 %% went out as %d permille, want 255", p.VATPermille)
}
want := []any{int64(42), int64(-2), nil, nil, int64(40), int64(40), int64(40), int64(40)}
for i, w := range want {
got := p.SpotMinor[i]
Expand Down
3 changes: 3 additions & 0 deletions go/internal/appproto/ports.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ type PriceReader interface {
// Currency is the ISO code their minor units are in. Without it the app
// would have to guess whether 45 is öre or cents.
Currency() string
// VATPercent is the VAT rate the box applies to TotalMinor, after any
// default. Still a float, as the config keeps it; the wire rounds it once.
VATPercent() float64
// Slots returns every stored slot overlapping [fromMs, toMs), oldest
// first. Overlapping, not contained: the slot in progress at fromMs is
// the price right now, and a window starting at "now" that left it out
Expand Down
14 changes: 10 additions & 4 deletions go/internal/appproto/price.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,16 @@ const maxPriceWindowMs = 8 * 24 * 60 * 60 * 1000
// asked for at breakfast genuinely ends early.
//
// Staleness covers the whole window, both edges and the middle — see below.
func priceFrom(zone, currency string, points []PricePoint, fromMs, toMs int64) Price {
//
// vatPercent is the rate the box applies to the totals. It becomes integer
// permille here, once, by the same rule as the money: every rate in use has at
// most one decimal in percent, so 25.5 % crosses as 255 and nothing is lost.
func priceFrom(zone, currency string, vatPercent float64, points []PricePoint, fromMs, toMs int64) Price {
out := Price{
Zone: zone,
Currency: currency,
Slots: make([]PriceSlot, 0, len(points)),
Zone: zone,
Currency: currency,
Slots: make([]PriceSlot, 0, len(points)),
VATPermille: int64(math.Round(vatPercent * 10)),
}

for _, p := range points {
Expand Down Expand Up @@ -136,6 +141,7 @@ func (h *Handler) onPriceGet(ctx context.Context, env Envelope) error {
return h.sendPrice(env.ID, priceFrom(
h.cfg.Prices.Zone(),
h.cfg.Prices.Currency(),
h.cfg.Prices.VATPercent(),
points,
q.FromMs,
q.ToMs,
Expand Down
37 changes: 35 additions & 2 deletions go/internal/appproto/price_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ import (
type fakePrices struct {
zone string
currency string
vat float64
points []PricePoint
err error

askedFrom int64
askedTo int64
}

func (f *fakePrices) Zone() string { return f.zone }
func (f *fakePrices) Currency() string { return f.currency }
func (f *fakePrices) Zone() string { return f.zone }
func (f *fakePrices) Currency() string { return f.currency }
func (f *fakePrices) VATPercent() float64 { return f.vat }

func (f *fakePrices) Slots(_ context.Context, fromMs, toMs int64) ([]PricePoint, error) {
f.askedFrom, f.askedTo = fromMs, toMs
Expand Down Expand Up @@ -154,6 +156,37 @@ func TestPriceRoundsToWholeMinorUnits(t *testing.T) {
}
}

// The answer carries the VAT rate the box taxed the totals at, so an app can
// show spot with VAT at the box's rate rather than its own guess. Integer
// permille, rounded once like the money, and sent at zero too: 0 is a rate,
// and only a box without the field leaves it out.
func TestPriceCarriesTheAppliedVATRate(t *testing.T) {
at := int64(1_760_000_000_000)
for _, tc := range []struct {
vat float64
want int64
}{
{25, 250},
{25.5, 255}, // Finland since September 2024
{0, 0},
} {
h, rec, _ := newPriceRig(t, &fakePrices{
zone: "SE3", currency: "SEK", vat: tc.vat, points: quarterHours(at, 4, 42, 91),
})

deliver(t, h, MsgPriceGet, ptrU32(7), PriceGet{FromMs: at, ToMs: at + 3_600_000})

f := rec.only(t, MsgPrice)
if p := body[Price](t, f); p.VATPermille != tc.want {
t.Errorf("VAT %v %% went out as %d permille, want %d", tc.vat, p.VATPermille, tc.want)
}
keys := body[map[string]any](t, f)
if _, ok := keys["vatPermille"]; !ok {
t.Errorf("VAT %v %%: no vatPermille key; an absent rate reads as unknown, not as zero", tc.vat)
}
}
}

// Tomorrow's rates arrive in the afternoon. Until they do, the honest answer
// ends early and says so — the app stops drawing rather than showing a cliff
// the market did not have.
Expand Down
2 changes: 1 addition & 1 deletion go/internal/cloudlink/sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ func abs(v int64) int64 {
}

func pricesKey(p cloudwire.Prices) string {
return digest([]any{p.T0Ms, p.SlotMs, p.SpotMinor, p.TotalMinor})
return digest([]any{p.T0Ms, p.SlotMs, p.SpotMinor, p.TotalMinor, p.VATPermille})
}

// rosterNumber builds the roster and its number. The number rises when the
Expand Down
9 changes: 9 additions & 0 deletions go/internal/prices/prices.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,15 @@ func (s *Service) SetApplier(a Applier) {
s.applierMu.Unlock()
}

// VATPercent is the VAT rate the next fetch applies to the consumer total,
// after the default. Read under the same lock SetApplier takes, so a reader
// never sees a rate halfway through a config reload.
func (s *Service) VATPercent() float64 {
s.applierMu.Lock()
defer s.applierMu.Unlock()
return s.Applier.VATPercent
}

// FXConverter abstracts currency conversion so the prices package
// doesn't need to import currency/ directly (and FromConfig callers
// can pass a test stub). When it is nil, or has no rate for the pair, a
Expand Down
17 changes: 17 additions & 0 deletions go/internal/prices/prices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,23 @@ func TestFromConfigDefaultsZoneAndVAT(t *testing.T) {
}
}

// VATPercent is the rate the totals are taxed at: the default when the config
// sets none, and whatever a reload swaps in after that. The app's price answer
// and FTW Cloud's tile both send it, so it must never be the config's 0.
func TestVATPercentIsTheRateTheTotalsAreTaxedAt(t *testing.T) {
s := FromConfig(&config.Price{Provider: "sourceful", GridTariffOreKwh: 30}, nil, nil)
if got := s.VATPercent(); got != 25 {
t.Fatalf("unset VAT reads as %v, want the default 25", got)
}
if _, total := s.Applier.Apply(1.00); total != (100+30)*(1+s.VATPercent()/100) {
t.Fatalf("total %v is not taxed at the rate VATPercent reports", total)
}
s.SetApplier(Applier{GridTariffOreKwh: 30, VATPercent: 25.5})
if got := s.VATPercent(); got != 25.5 {
t.Fatalf("after a reload VAT reads as %v, want 25.5", got)
}
}

// A base_url reads that server only: a day it does not have stays empty
// instead of falling back to live Nord Pool prices.
func TestFromConfigBaseURLReadsOnlyThatServer(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion go/protocol/cloudwire/allowlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ var Fields = map[string][]string{
},
"CarGoal": {"targetPermille", "readyByMin", "days", "finishAtLimit"},

MsgPrices: {"zone", "currency", "t0Ms", "slotMs", "spotMinor", "totalMinor"},
MsgPrices: {"zone", "currency", "t0Ms", "slotMs", "spotMinor", "totalMinor", "vatPermille"},

MsgHB: {"clock", "srcRev", "liveSeq", "lpSeq", "ages"},

Expand Down
3 changes: 3 additions & 0 deletions go/protocol/cloudwire/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,9 @@ type Prices struct {
// has no price. TotalMinor includes tariff and VAT.
SpotMinor []*int64 `cbor:"spotMinor"`
TotalMinor []*int64 `cbor:"totalMinor"`
// VATPermille is the VAT rate in TotalMinor, as in the app protocol's
// price answer: 250 is 25 %, and 0 means no VAT.
VATPermille int64 `cbor:"vatPermille"`
}

// HB says the box is there and how old its readings are. It never vouches
Expand Down
Loading
Loading